Added the option to only show attachment types for certain element classes

This commit is contained in:
Jan Böhmer 2026-02-08 19:30:06 +01:00
parent 2c56ec746c
commit a4c2b8f885
8 changed files with 231 additions and 5 deletions

View file

@ -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<T>
*/
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.

View file

@ -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<Attachment>[]|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<Attachment>[]|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<Attachment>[]|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;
}
}

View file

@ -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',

View file

@ -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'),
]);

View file

@ -0,0 +1,56 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
});
}
}

View file

@ -6,6 +6,7 @@
{% block additional_controls %}
{{ form_row(form.filetype_filter) }}
{{ form_row(form.allowed_targets) }}
{{ form_row(form.alternative_names) }}
{% endblock %}

View file

@ -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));
}
}

View file

@ -12425,5 +12425,17 @@ Buerklin-API Authentication server:
<target>GTIN / EAN barcode</target>
</segment>
</unit>
<unit id="cmchX59" name="attachment_type.edit.allowed_targets">
<segment>
<source>attachment_type.edit.allowed_targets</source>
<target>Use only for</target>
</segment>
</unit>
<unit id="t5R8p1l" name="attachment_type.edit.allowed_targets.help">
<segment>
<source>attachment_type.edit.allowed_targets.help</source>
<target>Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes.</target>
</segment>
</unit>
</file>
</xliff>