Add build management features and views
All checks were successful
Build / build (push) Successful in 2m10s

This commit is contained in:
2025-06-15 09:03:47 +02:00
parent 8cbededc79
commit 8e26a73243
12 changed files with 291 additions and 17 deletions

View File

@@ -43,6 +43,11 @@ public class WebController {
private static final String INPUT_LIST = "inputLists";
/// The name of the model attribute that holds the current work log entries.
private static final String WORKLOG = "worklog";
/// The name of the model attribute that holds the current build being viewed or edited.
private static final String BUILD = "build";
/// The name of the model attribute that holds the build information.
private static final String BUILD_INFO = "buildInfo";
/// The name of the model attribute that holds the builds available for selection.
private static final String BUILDS = "builds";
/// The name of the input field for the current size of the work log.
@@ -93,6 +98,54 @@ public class WebController {
return "browse_type";
}
/**
* Handles the browsing of all builds.
* Displays a list of all builds available in the system.
*/
@GetMapping("/builds")
public String browseBuilds(Model model) {
model.addAttribute(TIME, System.currentTimeMillis());
model.addAttribute(BUILDS, buildService.getAllBuilds());
return "builds";
}
/**
* Handles the viewing of a specific build by its ID.
* If the build does not exist, it redirects to the builds page.
*/
@GetMapping("/build/{id}")
public String viewBuild(Model model, @PathVariable long id) {
model.addAttribute(TIME, System.currentTimeMillis());
var build = buildService.getBuildById(id);
if (build == null) {
return "redirect:/builds";
}
model.addAttribute(BUILD, build);
model.addAttribute(BUILD_INFO, buildService.getBuildInfo(build));
model.addAttribute(DESCRIPTORS, assetService.getAssetDescriptors());
return "build_view";
}
/**
* Handles the creation of a new build.
*/
@PostMapping("/create_build")
public String createBuild(Model model, @RequestBody MultiValueMap<String, String> formData) {
model.addAttribute(TIME, System.currentTimeMillis());
var build = buildService.createBuild(formData.getFirst("name"), formData.getFirst("description"));
return "redirect:/build/" + build.getId();
}
/**
* Deletes a build by its ID.
*/
@GetMapping("/delete_build/{id}")
public String deleteBuild(Model model, @PathVariable long id) {
model.addAttribute(TIME, System.currentTimeMillis());
buildService.deleteBuild(id);
return "redirect:/builds";
}
/**
* Handles the view of an asset by its QR code.
* If the asset does not exist, it redirects to the index page.
@@ -227,6 +280,7 @@ public class WebController {
model.addAttribute(DESCRIPTORS, assetService.getAssetDescriptorTree(assetType));
model.addAttribute(DESCRIPTOR, assetService.getAssetDescriptor(assetType));
model.addAttribute(INPUT_LIST, assetService.getInputList(assetType));
model.addAttribute(BUILDS, buildService.getAllBuilds());
return "create_asset";
}

View File

@@ -99,6 +99,19 @@ public class AssetDescriptor {
return String.format("%s-%s", type, property.getName());
}
/**
* Gets the property with the specified name.
*
* @param name The name of the property to retrieve.
* @return The AssetProperty with the given name.
*/
public AssetProperty getProperty(String name) {
return properties.stream()
.filter(property -> property.getName().equals(name))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No property found with name: " + name));
}
/**
* Creates a new instance of the asset type described by this descriptor.
*

View File

@@ -24,6 +24,37 @@ public class AssetDescriptors {
assets.add(property);
}
/**
* Gets the descriptor for a specific asset type.
*
* @param type The type of the asset to retrieve the descriptor for.
*/
public AssetDescriptor getDescriptorForType(String type) {
return assets.stream()
.filter(assetDescriptor -> assetDescriptor.getType().equals(type))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No asset descriptor found for type: " + type));
}
/**
* Gets the property for a specific asset type and property name.
*
* @param type The type of the asset to retrieve the property for.
* @param propertyName The name of the property to retrieve.
*/
public AssetProperty getPropertyForType(String type, String propertyName) {
return getDescriptorForType(type).getProperty(propertyName);
}
/**
* Gets the generic property for a specific property name.
*
* @param propertyName The name of the property to retrieve.
*/
public AssetProperty getGenericProperty(String propertyName) {
return getPropertyForType("asset", propertyName);
}
@Override
public String toString() {
var builder = new StringBuilder();

View File

@@ -183,6 +183,9 @@ public class AssetProperty {
return value.toString();
} else if (type == PropertyType.CAPACITY) {
return convertCapacity((Long) value).toString();
} else if (type == PropertyType.BUILD) {
var build = (Build) value;
return build.getName();
} else if (type.isEnum) {
if (value instanceof AssetEnum assetEnum) {
return assetEnum.getDisplayName();

View File

@@ -21,10 +21,30 @@ public class Build
@GeneratedValue
private long id;
/**
* Indicates whether this build is a meta build.
* A meta build is a build that does not represent a physical computer,
* but rather a collection of parts that can be used in other builds.
*
* It is used internally to represents parts that are explicitly not part of a build.
*/
private boolean meta;
/**
* The name of the build.
*/
private String name;
/**
* A description of the build.
* This can be used to provide additional information about the build.
*/
private String description;
@OneToMany(cascade = CascadeType.ALL)
/**
* A list of parts that are included in the build.
*/
@OneToMany(mappedBy = "build")
@OrderBy("type, brand, model, qr")
private List<GenericAsset> parts;
}

View File

@@ -0,0 +1,20 @@
package be.seeseepuff.pcinv.models;
import be.seeseepuff.pcinv.meta.CapacityInfo;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class BuildInfo {
private long totalRam;
/**
* Calculates the total RAM capacity of the build.
*
* @return A CapacityInfo object representing the total RAM capacity.
*/
public CapacityInfo getTotalRamCapacity() {
return CapacityInfo.of(totalRam);
}
}

View File

@@ -4,7 +4,14 @@ import be.seeseepuff.pcinv.models.Build;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BuildRepository extends JpaRepository<Build, Long>
{
Build getBuildByNameAndMeta(String name, boolean meta);
Build getBuildById(long id);
List<Build> findAllByMeta(boolean meta);
}

View File

@@ -2,11 +2,9 @@ package be.seeseepuff.pcinv.services;
import be.seeseepuff.pcinv.meta.*;
import be.seeseepuff.pcinv.models.Asset;
import be.seeseepuff.pcinv.models.Build;
import be.seeseepuff.pcinv.models.GenericAsset;
import be.seeseepuff.pcinv.models.WorkLogEntry;
import be.seeseepuff.pcinv.repositories.AssetRepository;
import be.seeseepuff.pcinv.repositories.BuildRepository;
import be.seeseepuff.pcinv.repositories.GenericAssetRepository;
import be.seeseepuff.pcinv.repositories.WorkLogRepository;
import jakarta.persistence.EntityManager;
@@ -30,6 +28,7 @@ public class AssetService {
private final WorkLogRepository workLogRepository;
private final Collection<AssetRepository<?>> repositories;
private final EntityManager entityManager;
private final BuildService buildService;
/**
* Returns the count of all assets in the repository.
@@ -253,6 +252,12 @@ public class AssetService {
case "false" -> false;
default -> null;
};
} else if (property.getType() == PropertyType.BUILD) {
var build = buildService.getBuildById(Integer.parseInt(stringValue));
if (build == null) {
throw new IllegalArgumentException("Invalid build ID for property '" + property.getName() + "': " + stringValue);
}
return build;
} else if (property.getType().isEnum) {
for (var option : property.getOptions()) {
if (option.getValue().equals(stringValue)) {

View File

@@ -1,11 +1,14 @@
package be.seeseepuff.pcinv.services;
import be.seeseepuff.pcinv.models.Build;
import be.seeseepuff.pcinv.models.BuildInfo;
import be.seeseepuff.pcinv.repositories.BuildRepository;
import be.seeseepuff.pcinv.repositories.RamRepository;
import jakarta.annotation.PostConstruct;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
@@ -13,14 +16,22 @@ import java.util.List;
*/
@Service
@RequiredArgsConstructor
public class BuildService
{
public class BuildService {
private final BuildRepository buildRepository;
public static final Build EMPTY = Build.builder()
.id(0)
.name("(None)")
.description("A meta build used to indicate that the part is not being used by anything")
private final RamRepository ramRepository;
@PostConstruct
private void init() {
Build empty = buildRepository.getBuildByNameAndMeta("None", true);
if (empty == null) {
empty = Build.builder()
.name("None")
.meta(true)
.build();
}
empty.setDescription("A meta build to hold unused parts.");
empty = buildRepository.save(empty);
}
/**
* Gets a list of all computer builds, including meta builds.
@@ -28,10 +39,7 @@ public class BuildService
* @return A list of all builds.
*/
public List<Build> getAllBuilds() {
var build = new ArrayList<Build>();
build.add(EMPTY);
build.addAll(getAllRealBuilds());
return build;
return buildRepository.findAll();
}
/**
@@ -40,6 +48,66 @@ public class BuildService
* @return A list of all builds.
*/
public List<Build> getAllRealBuilds() {
return buildRepository.findAll();
return buildRepository.findAllByMeta(false);
}
/**
* Gets a build by its ID.
*
* @param id The ID of the build to retrieve.
* @return The build with the given ID, or null if not found.
*/
public Build getBuildById(long id) {
return buildRepository.getBuildById(id);
}
/**
* Creates a new build with the given name and description.
*
* @param name The name of the build.
* @param description The description of the build.
* @return The created build.
*/
@Transactional
public Build createBuild(String name, String description) {
Build build = Build.builder()
.name(name)
.description(description)
.build();
return buildRepository.saveAndFlush(build);
}
/**
* Deletes a build by its ID.
*
* @param id The ID of the build to delete.
*/
@Transactional
public void deleteBuild(long id) {
Build build = buildRepository.getBuildById(id);
for (var part : build.getParts()) {
part.setBuild(null);
}
buildRepository.deleteById(id);
}
/**
* Gets the build information for a given build ID.
*
* @param build The build object for which to retrieve the information.
* @return The BuildInfo object containing the build information.
* @throws IllegalArgumentException if the build with the given ID does not exist.
*/
public BuildInfo getBuildInfo(Build build) {
var buildInfo = new BuildInfo();
for (var part : build.getParts()) {
if (part.getType().equals("ram")) {
var asset = ramRepository.findByAsset(part);
if (asset.getCapacity() != null) {
buildInfo.setTotalRam(buildInfo.getTotalRam() + asset.getCapacity());
}
}
}
return buildInfo;
}
}

View File

@@ -0,0 +1,24 @@
<body th:replace="~{fragments :: base(title=${'Build ' + build.getName()}, content=~{::content})}">
<div th:fragment="content">
<ul>
<li><b>Name: </b><span th:text="${build.name}"></span></li>
<li><b>Description: </b><span th:text="${build.description}"></span></li>
<li><b>Part Count: </b><span th:text="${build.getParts()?.size() ?: 0}"></span></li>
<li><b>Total RAM: </b><span th:text="${buildInfo.getTotalRamCapacity().getCapacityInUnit()} + ' ' + ${buildInfo.getTotalRamCapacity().getIdealUnit().displayName}"></span></li>
</ul>
<table border="1" cellpadding="4">
<tr bgcolor="#d3d3d3">
<th>QR</th>
<th>Type</th>
<th>Brand</th>
<th>Model</th>
</tr>
<tr th:each="p : ${build.getParts()}">
<td><a th:href="'/view/' + ${p.qr}" th:text="${p.qr}"></a></td>
<td th:text="${descriptors.getDescriptorForType(p.type).displayName}"></td>
<td th:text="${descriptors.getGenericProperty('brand').renderValue(p)}"></td>
<td th:text="${descriptors.getGenericProperty('model').renderValue(p)}"></td>
</tr>
</table>
</div>
</body>

View File

@@ -0,0 +1,28 @@
<body th:replace="~{fragments :: base(title='Builds', content=~{::content})}">
<div th:fragment="content">
<table border="1" cellpadding="4">
<tr bgcolor="#d3d3d3">
<th>Name</th>
<th>Description</th>
<th>Part Count</th>
<th>Actions</th>
</tr>
<tr th:each="b : ${builds}">
<td><a th:href="'/build/' + ${b.id}" th:text="${b.getName()}"></a></td>
<td th:text="${b.getDescription()}"></td>
<td th:text="${b.getParts()?.size() ?: 0}"></td>
<td>
<a th:if="${!b.isMeta()}" th:href="${'/delete_build/' + b.id}">Delete</a>
</td>
</tr>
<form action="/create_build" method="post">
<tr>
<td><label><input type="text" name="name" placeholder="Name"></label></td>
<td><label><input type="text" name="description" placeholder="Description"></label></td>
<td></td>
<td><input type="submit" value="Create"></td>
</tr>
</form>
</table>
</div>
</body>

View File

@@ -7,7 +7,8 @@
<table border="1" cellpadding="4">
<tr th:each="p : ${d.properties}">
<th bgcolor="lightgray"><b th:text="${p.displayName}"></b></th>
<td th:text="${p.renderValue(asset)}"></td>
<td th:if="${p.name == 'build'}"><a th:href="'/build/' + ${asset.getAsset().getBuild().id}" th:text="${p.renderValue(asset)}"></a></td>
<td th:if="${p.name != 'build'}" th:text="${p.renderValue(asset)}"></td>
</tr>
</table>
</div>