From a4c2b8f885b1358e971897af9ed57ca16448f6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 19:30:06 +0100 Subject: [PATCH] Added the option to only show attachment types for certain element classes --- src/Entity/Attachments/Attachment.php | 2 +- src/Entity/Attachments/AttachmentType.php | 84 ++++++++++++++++++- .../AdminPages/AttachmentTypeAdminForm.php | 27 +++++- src/Form/AttachmentFormType.php | 5 +- src/Form/Type/AttachmentTypeType.php | 56 +++++++++++++ .../admin/attachment_type_admin.html.twig | 1 + .../Entity/Attachments/AttachmentTypeTest.php | 49 +++++++++++ translations/messages.en.xlf | 12 +++ 8 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 src/Form/Type/AttachmentTypeType.php diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index ac625e92..d4b15ac7 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -136,7 +136,7 @@ abstract class Attachment extends AbstractNamedDBElement * @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses. * @phpstan-var class-string */ - protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class; + public const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class; /** * @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it. diff --git a/src/Entity/Attachments/AttachmentType.php b/src/Entity/Attachments/AttachmentType.php index 273e800a..375defa0 100644 --- a/src/Entity/Attachments/AttachmentType.php +++ b/src/Entity/Attachments/AttachmentType.php @@ -135,11 +135,16 @@ class AttachmentType extends AbstractStructuralDBElement protected Collection $attachments_with_type; /** - * @var array|null A list of allowed targets where this attachment type can be assigned to. + * @var string[]|null A list of allowed targets where this attachment type can be assigned to, as a list of portable names */ #[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)] protected ?array $allowed_targets = null; + /** + * @var class-string[]|null + */ + protected ?array $allowed_targets_parsed_cache = null; + #[Groups(['attachment_type:read'])] protected ?\DateTimeImmutable $addedDate = null; #[Groups(['attachment_type:read'])] @@ -190,4 +195,81 @@ class AttachmentType extends AbstractStructuralDBElement return $this; } + + /** + * Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions. + * @return class-string[]|null + */ + public function getAllowedTargets(): ?array + { + //Use cached value if available + if ($this->allowed_targets_parsed_cache !== null) { + return $this->allowed_targets_parsed_cache; + } + + if (empty($this->allowed_targets)) { + return null; + } + + $tmp = []; + foreach ($this->allowed_targets as $target) { + if (Attachment::ORM_DISCRIMINATOR_MAP[$target]) { + $tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target]; + } + //Otherwise ignore the entry, as it is invalid + } + + //Cache the parsed value + $this->allowed_targets_parsed_cache = $tmp; + return $tmp; + } + + /** + * Sets the allowed targets for this attachment type. Allowed targets are specified as a list of class names (e.g. PartAttachment::class). If null is passed, there are no restrictions. + * @param class-string[]|null $allowed_targets + * @return $this + */ + public function setAllowedTargets(?array $allowed_targets): self + { + if ($allowed_targets === null) { + $this->allowed_targets = null; + } else { + $tmp = []; + foreach ($allowed_targets as $target) { + $discriminator = array_search($target, Attachment::ORM_DISCRIMINATOR_MAP, true); + if ($discriminator !== false) { + $tmp[] = $discriminator; + } else { + throw new \InvalidArgumentException("Invalid allowed target: $target. Allowed targets must be a class name of an Attachment subclass."); + } + } + $this->allowed_targets = $tmp; + } + + //Reset the cache + $this->allowed_targets_parsed_cache = null; + return $this; + } + + /** + * Checks if this attachment type is allowed for the given attachment target. + * @param Attachment|string $attachment + * @return bool + */ + public function isAllowedForTarget(Attachment|string $attachment): bool + { + //If no restrictions are set, allow all targets + if ($this->getAllowedTargets() === null) { + return true; + } + + //Iterate over all allowed targets and check if the attachment is an instance of any of them + foreach ($this->getAllowedTargets() as $allowed_target) { + if (is_a($attachment, $allowed_target, true)) { + return true; + } + } + + return false; + } } diff --git a/src/Form/AdminPages/AttachmentTypeAdminForm.php b/src/Form/AdminPages/AttachmentTypeAdminForm.php index d777d4d4..cf410a43 100644 --- a/src/Form/AdminPages/AttachmentTypeAdminForm.php +++ b/src/Form/AdminPages/AttachmentTypeAdminForm.php @@ -22,17 +22,23 @@ declare(strict_types=1); namespace App\Form\AdminPages; +use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Attachments\ProjectAttachment; +use App\Services\ElementTypeNameGenerator; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractNamedDBElement; use App\Services\Attachments\FileTypeFilterTools; use App\Services\LogSystem\EventCommentNeededHelper; use Symfony\Component\Form\CallbackTransformer; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Translation\StaticMessage; class AttachmentTypeAdminForm extends BaseEntityAdminForm { - public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper) + public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper, private readonly ElementTypeNameGenerator $elementTypeNameGenerator) { parent::__construct($security, $eventCommentNeededHelper); } @@ -41,6 +47,25 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm { $is_new = null === $entity->getID(); + + $choiceLabel = function (string $class) { + if (!is_a($class, Attachment::class, true)) { + return $class; + } + return new StaticMessage($this->elementTypeNameGenerator->typeLabel($class::ALLOWED_ELEMENT_CLASS)); + }; + + + $builder->add('allowed_targets', ChoiceType::class, [ + 'required' => false, + 'choices' => array_values(Attachment::ORM_DISCRIMINATOR_MAP), + 'choice_label' => $choiceLabel, + 'preferred_choices' => [PartAttachment::class, ProjectAttachment::class], + 'label' => 'attachment_type.edit.allowed_targets', + 'help' => 'attachment_type.edit.allowed_targets.help', + 'multiple' => true, + ]); + $builder->add('filetype_filter', TextType::class, [ 'required' => false, 'label' => 'attachment_type.edit.filetype_filter', diff --git a/src/Form/AttachmentFormType.php b/src/Form/AttachmentFormType.php index eb484a58..5cbde178 100644 --- a/src/Form/AttachmentFormType.php +++ b/src/Form/AttachmentFormType.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form; +use App\Form\Type\AttachmentTypeType; use App\Settings\SystemSettings\AttachmentsSettings; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Attachments\Attachment; @@ -67,10 +68,10 @@ class AttachmentFormType extends AbstractType 'required' => false, 'empty_data' => '', ]) - ->add('attachment_type', StructuralEntityType::class, [ + ->add('attachment_type', AttachmentTypeType::class, [ 'label' => 'attachment.edit.attachment_type', - 'class' => AttachmentType::class, 'disable_not_selectable' => true, + 'attachment_filter_class' => $options['data_class'] ?? null, 'allow_add' => $this->security->isGranted('@attachment_types.create'), ]); diff --git a/src/Form/Type/AttachmentTypeType.php b/src/Form/Type/AttachmentTypeType.php new file mode 100644 index 00000000..099ed282 --- /dev/null +++ b/src/Form/Type/AttachmentTypeType.php @@ -0,0 +1,56 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Type; + +use App\Entity\Attachments\AttachmentType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Form type to select the AttachmentType to use in an attachment form. This is used to filter the available attachment types based on the target class. + */ +class AttachmentTypeType extends AbstractType +{ + public function getParent(): ?string + { + return StructuralEntityType::class; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null); + + $resolver->setDefault('class', AttachmentType::class); + + $resolver->setDefault('choice_filter', function (Options $options) { + if (is_a($options['class'], AttachmentType::class, true) && $options['attachment_filter_class'] !== null) { + return static function (?AttachmentType $choice) use ($options) { + return $choice?->isAllowedForTarget($options['attachment_filter_class']); + }; + } + return null; + }); + } +} diff --git a/templates/admin/attachment_type_admin.html.twig b/templates/admin/attachment_type_admin.html.twig index 87a053af..9aeba934 100644 --- a/templates/admin/attachment_type_admin.html.twig +++ b/templates/admin/attachment_type_admin.html.twig @@ -6,6 +6,7 @@ {% block additional_controls %} {{ form_row(form.filetype_filter) }} + {{ form_row(form.allowed_targets) }} {{ form_row(form.alternative_names) }} {% endblock %} diff --git a/tests/Entity/Attachments/AttachmentTypeTest.php b/tests/Entity/Attachments/AttachmentTypeTest.php index f9f781d8..ea80db11 100644 --- a/tests/Entity/Attachments/AttachmentTypeTest.php +++ b/tests/Entity/Attachments/AttachmentTypeTest.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace App\Tests\Entity\Attachments; use App\Entity\Attachments\AttachmentType; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Attachments\UserAttachment; use Doctrine\Common\Collections\Collection; use PHPUnit\Framework\TestCase; @@ -34,4 +36,51 @@ class AttachmentTypeTest extends TestCase $this->assertInstanceOf(Collection::class, $attachment_type->getAttachmentsForType()); $this->assertEmpty($attachment_type->getFiletypeFilter()); } + + public function testSetAllowedTargets(): void + { + $attachmentType = new AttachmentType(); + + + $this->expectException(\InvalidArgumentException::class); + $attachmentType->setAllowedTargets(['target1', 'target2']); + } + + public function testGetSetAllowedTargets(): void + { + $attachmentType = new AttachmentType(); + + $attachmentType->setAllowedTargets([PartAttachment::class, UserAttachment::class]); + $this->assertSame([PartAttachment::class, UserAttachment::class], $attachmentType->getAllowedTargets()); + //Caching should also work + $this->assertSame([PartAttachment::class, UserAttachment::class], $attachmentType->getAllowedTargets()); + + //Setting null should reset the allowed targets + $attachmentType->setAllowedTargets(null); + $this->assertNull($attachmentType->getAllowedTargets()); + } + + public function testIsAllowedForTarget(): void + { + $attachmentType = new AttachmentType(); + + //By default, all targets should be allowed + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class)); + + //Set specific allowed targets + $attachmentType->setAllowedTargets([PartAttachment::class]); + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertFalse($attachmentType->isAllowedForTarget(UserAttachment::class)); + + //Set both targets + $attachmentType->setAllowedTargets([PartAttachment::class, UserAttachment::class]); + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class)); + + //Reset allowed targets + $attachmentType->setAllowedTargets(null); + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class)); + } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index a1ebc9b9..f47c4e9e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12425,5 +12425,17 @@ Buerklin-API Authentication server: GTIN / EAN barcode + + + attachment_type.edit.allowed_targets + Use only for + + + + + attachment_type.edit.allowed_targets.help + Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes. + +