Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/Controller/AdminPages/BaseAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ protected function additionalActionEdit(FormInterface $form, AbstractNamedDBElem
return true;
}

/**
* @return AbstractDBElement[]
*/
protected function getHistoryElements(AbstractNamedDBElement $entity): array
{
return $this->historyHelper->getAssociatedElements($entity);
}

protected function _edit(AbstractNamedDBElement $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response
{
$this->denyAccessUnlessGranted('read', $entity);
Expand All @@ -136,7 +144,7 @@ protected function _edit(AbstractNamedDBElement $entity, Request $request, Entit
$table = $this->dataTableFactory->createFromType(
LogDataTable::class,
[
'filter_elements' => $this->historyHelper->getAssociatedElements($entity),
'filter_elements' => $this->getHistoryElements($entity),
'mode' => 'element_history',
],
['pageLength' => 10]
Expand Down
14 changes: 14 additions & 0 deletions src/Controller/AdminPages/ProjectAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Form\AdminPages\ProjectAdminForm;
use App\Services\ImportExportSystem\EntityExporter;
use App\Services\ImportExportSystem\EntityImporter;
Expand All @@ -45,6 +47,18 @@ class ProjectAdminController extends BaseAdminController
protected string $attachment_class = ProjectAttachment::class;
protected ?string $parameter_class = ProjectParameter::class;

/**
* Project BOM history remains available on individual BOM entries. Do not
* expand it while rendering project metadata, as that would create an
* unbounded log query for large projects.
*
* @return AbstractDBElement[]
*/
protected function getHistoryElements(AbstractNamedDBElement $entity): array
{
return $this->historyHelper->getAssociatedElements($entity, false);
}

#[Route(path: '/{id}', name: 'project_delete', methods: ['DELETE'])]
public function delete(Request $request, Project $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse
{
Expand Down
74 changes: 72 additions & 2 deletions src/Controller/ProjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Form\ProjectSystem\ProjectAddPartsType;
use App\Form\ProjectSystem\ProjectBOMEntryType;
use App\Form\ProjectSystem\ProjectBuildType;
use App\Helpers\Projects\ProjectBuildRequest;
use App\Services\ImportExportSystem\BOMImporter;
Expand All @@ -50,6 +51,9 @@
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Serializer\SerializerInterface;
use App\Services\ImportExportSystem\ProjectBomExporter;
use App\Services\LogSystem\EventCommentHelper;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;

use function Symfony\Component\Translation\t;

Expand Down Expand Up @@ -83,6 +87,72 @@ public function info(Project $project, Request $request, ProjectBuildHelper $bui
]);
}

#[Route(path: '/{id}/bom/{bomEntry}/edit', name: 'project_bom_entry_edit', requirements: ['id' => '\d+', 'bomEntry' => '\d+'])]
public function editBOMEntry(
#[MapEntity(id: 'id')] Project $project,
#[MapEntity(mapping: ['bomEntry' => 'id'])] ProjectBOMEntry $bomEntry,
Request $request,
EntityManagerInterface $entityManager,
EventCommentHelper $commentHelper,
): Response {
if ($bomEntry->getProject()?->getId() !== $project->getId()) {
throw $this->createNotFoundException();
}

$this->denyAccessUnlessGranted('edit', $project);

$form = $this->createForm(ProjectBOMEntryType::class, $bomEntry, [
'include_log_comment' => true,
'constraints' => [
new UniqueEntity(fields: ['part', 'project'], message: 'project.bom_entry.part_already_in_bom', entityClass: ProjectBOMEntry::class),
new UniqueEntity(fields: ['name', 'project'], message: 'project.bom_entry.name_already_in_bom', entityClass: ProjectBOMEntry::class, ignoreNull: true),
],
]);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$commentHelper->setMessage($form->get('log_comment')->getData());
$entityManager->flush();
$this->addFlash('success', 'entity.edit_flash');

return $this->redirectToRoute('project_info', ['id' => $project->getId()]);
}

if ($form->isSubmitted()) {
$this->addFlash('error', 'entity.edit_flash.invalid');
}

return $this->render('projects/edit_bom_entry.html.twig', [
'project' => $project,
'bom_entry' => $bomEntry,
'form' => $form,
]);
}

#[Route(path: '/{id}/bom/{bomEntry}', name: 'project_bom_entry_delete', requirements: ['id' => '\d+', 'bomEntry' => '\d+'], methods: ['DELETE'])]
public function deleteBOMEntry(
#[MapEntity(id: 'id')] Project $project,
#[MapEntity(mapping: ['bomEntry' => 'id'])] ProjectBOMEntry $bomEntry,
Request $request,
EntityManagerInterface $entityManager,
EventCommentHelper $commentHelper,
): Response {
if ($bomEntry->getProject()?->getId() !== $project->getId()) {
throw $this->createNotFoundException();
}

$this->denyAccessUnlessGranted('edit', $project);

if ($this->isCsrfTokenValid('delete' . $bomEntry->getId(), $request->request->get('_token'))) {
$commentHelper->setMessage($request->request->get('log_comment'));
$entityManager->remove($bomEntry);
$entityManager->flush();
$this->addFlash('success', 'attachment_type.deleted');
}

return $this->redirectToRoute('project_info', ['id' => $project->getId()]);
}

#[Route(path: '/{id}/build', name: 'project_build', requirements: ['id' => '\d+'])]
public function build(Project $project, Request $request, ProjectBuildHelper $buildHelper, EntityManagerInterface $entityManager): Response
{
Expand Down Expand Up @@ -343,7 +413,7 @@ public function importBOM(
]);

// Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries');
$errors = $validator->validateProperty($project, 'bom_entries', ['project_bom']);

// If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0) {
Expand Down Expand Up @@ -583,7 +653,7 @@ public function importBOMMapFields(
}

// Validate the project entries (includes collection constraints)
$errors = $validator->validateProperty($project, 'bom_entries');
$errors = $validator->validateProperty($project, 'bom_entries', ['project_bom']);

// If no validation errors occurred, save and redirect
if (count($errors) === 0) {
Expand Down
16 changes: 15 additions & 1 deletion src/DataTables/ProjectBomEntriesDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
Expand All @@ -47,6 +48,8 @@
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

final readonly class ProjectBomEntriesDataTable implements DataTableTypeInterface
Expand All @@ -58,6 +61,8 @@ public function __construct(
protected PartDataTableHelper $partDataTableHelper,
protected ProjectBuildHelper $projectBuildHelper,
protected MoneyFormatter $moneyFormatter,
protected Security $security,
protected UrlGeneratorInterface $urlGenerator,
) {
}

Expand Down Expand Up @@ -86,7 +91,6 @@ public function configure(DataTable $dataTable, array $options): void
'label' => $this->translator->trans('part.table.id'),
'visible' => false,
])

->add('quantity', TextColumn::class, [
'label' => $this->translator->trans('project.bom.quantity'),
'className' => 'text-center',
Expand Down Expand Up @@ -241,6 +245,16 @@ public function configure(DataTable $dataTable, array $options): void
'label' => $this->translator->trans('part.table.lastModified'),
'visible' => false,
])
->add('edit', IconLinkColumn::class, [
'label' => $this->translator->trans('part.table.edit'),
'className' => 'no-colvis no-export',
'href' => fn(mixed $value, ProjectBOMEntry $context): string => $this->urlGenerator->generate(
'project_bom_entry_edit',
['id' => $options['project']->getId(), 'bomEntry' => $context->getId()]
),
'disabled' => fn(mixed $value, ProjectBOMEntry $context): bool => !$this->security->isGranted('edit', $context->getProject()),
'title' => $this->translator->trans('part.table.edit.title'),
])
;

$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
Expand Down
21 changes: 9 additions & 12 deletions src/Entity/ProjectSystem/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,11 @@ class Project extends AbstractStructuralDBElement
/**
* @var Collection<int, ProjectBOMEntry>
*/
#[Assert\Valid]
#[Assert\Valid(groups: ['project_bom'])]
#[Groups(['extended', 'full', 'import', 'mcp_project_details:read'])]
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectBOMEntry::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'])]
#[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'])]
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectBOMEntry::class, cascade: ['persist', 'remove'], orphanRemoval: true, fetch: 'EXTRA_LAZY')]
#[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'], groups: ['project_bom'])]
#[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'], groups: ['project_bom'])]
protected Collection $bom_entries;

#[ORM\Column(type: Types::INTEGER)]
Expand Down Expand Up @@ -358,14 +358,11 @@ public function validate(ExecutionContextInterface $context, $payload): void
if (!$child->getBuildPart() instanceof Part) {
continue;
}
//We have to search all bom entries for the build part
$found = false;
foreach ($this->getBomEntries() as $bom_entry) {
if ($bom_entry->getPart() === $child->getBuildPart()) {
$found = true;
break;
}
}
//Use the extra-lazy collection so validating project metadata does
//not initialize the complete BOM.
$found = !$this->getBomEntries()->matching(
Criteria::create()->where(Criteria::expr()->eq('part', $child->getBuildPart()))
)->isEmpty();

//When the build part is not found, we have to add an error
if (!$found) {
Expand Down
10 changes: 5 additions & 5 deletions src/Entity/ProjectSystem/ProjectBOMEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
{
use TimestampTrait;

#[Assert\Positive]
#[Assert\Positive(groups: ['Default', 'project_bom'])]
#[ORM\Column(name: 'quantity', type: Types::FLOAT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
protected float $quantity = 1.0;
Expand All @@ -96,7 +96,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
/**
* @var string|null An optional name describing this BOM entry (useful for non-part entries)
*/
#[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed')]
#[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed', groups: ['Default', 'project_bom'])]
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
protected ?string $name = null;
Expand Down Expand Up @@ -128,7 +128,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
/**
* @var BigDecimal|null The price of this non-part BOM entry
*/
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()], groups: ['Default', 'project_bom'])]
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full', 'mcp_project_details:read'])]
protected ?BigDecimal $price = null;
Expand All @@ -138,7 +138,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/
#[ORM\ManyToOne(targetEntity: Currency::class)]
#[ORM\JoinColumn]
#[Selectable]
#[Selectable(groups: ['Default', 'project_bom'])]
protected ?Currency $price_currency = null;

public function __construct()
Expand Down Expand Up @@ -256,7 +256,7 @@ public function isPartBomEntry(): bool
return $this->part instanceof Part;
}

#[Assert\Callback]
#[Assert\Callback(groups: ['Default', 'project_bom'])]
public function validate(ExecutionContextInterface $context, $payload): void
{
//Round quantity to whole numbers, if the part is not a decimal part
Expand Down
3 changes: 0 additions & 3 deletions src/Form/AdminPages/ProjectAdminForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
namespace App\Form\AdminPages;

use App\Entity\Base\AbstractNamedDBElement;
use App\Form\ProjectSystem\ProjectBOMEntryCollectionType;
use App\Form\Type\RichTextEditorType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
Expand All @@ -43,8 +42,6 @@ protected function additionalFormElements(FormBuilderInterface $builder, array $
],
]);

$builder->add('bom_entries', ProjectBOMEntryCollectionType::class);

$builder->add('status', ChoiceType::class, [
'attr' => [
'class' => 'form-select',
Expand Down
18 changes: 18 additions & 0 deletions src/Form/ProjectSystem/ProjectBOMEntryType.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use App\Form\Type\PartSelectType;
use App\Form\Type\RichTextEditorType;
use App\Form\Type\SIUnitType;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Services\LogSystem\EventCommentType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\TextType;
Expand All @@ -20,6 +22,10 @@
class ProjectBOMEntryType extends AbstractType
{

public function __construct(private readonly EventCommentNeededHelper $eventCommentNeededHelper)
{
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{

Expand Down Expand Up @@ -81,12 +87,24 @@ public function buildForm(FormBuilderInterface $builder, array $options): void

;

if ($options['include_log_comment']) {
$builder->add('log_comment', TextType::class, [
'label' => 'edit.log_comment',
'mapped' => false,
'required' => $this->eventCommentNeededHelper->isCommentNeeded(EventCommentType::DATASTRUCTURE_EDIT),
'empty_data' => null,
]);
}

}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ProjectBOMEntry::class,
'include_log_comment' => false,
]);

$resolver->setAllowedTypes('include_log_comment', 'bool');
}
}
13 changes: 11 additions & 2 deletions src/Services/ImportExportSystem/EntityImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Repository\StructuralDBElementRepository;
use App\Serializer\APIPlatform\SkippableItemNormalizer;
use Symfony\Component\Validator\ConstraintViolationList;
Expand Down Expand Up @@ -149,7 +150,11 @@ public function massCreation(string $lines, string $class_name, ?AbstractStructu

//Validate entity
foreach ($entities as $entity) {
$tmp = $this->validator->validate($entity);
$tmp = $this->validator->validate(
$entity,
null,
$entity instanceof Project ? ['Default', 'project_bom'] : null
);
//If no error occured, write entry to DB:
if (0 === count($tmp)) {
$valid_entities[] = $entity;
Expand Down Expand Up @@ -245,7 +250,11 @@ public function importString(string $data, array $options = [], array &$errors =
}

//Validate entity
$tmp = $this->validator->validate($entity);
$tmp = $this->validator->validate(
$entity,
null,
$entity instanceof Project ? ['Default', 'project_bom'] : null
);

if (count($tmp) > 0) { //Log validation errors to global log.
$name = $entity instanceof AbstractStructuralDBElement ? $entity->getFullPath() : $entity->getName();
Expand Down
Loading