diff --git a/config/services.yaml b/config/services.yaml index 466d1f45..9e158ff6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -10,7 +10,7 @@ parameters: banner: '' # The info text shown in the homepage use_gravatar: true # Set to false, if no Gravatar images should be used for user profiles. default_currency: 'EUR' # The currency that should be used - media_directory: '' + media_directory: 'data/media/' services: # default configuration for services in *this* file diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index 5ede844f..09f14038 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -32,10 +32,12 @@ namespace App\Controller; use App\Entity\Parts\Category; use App\Entity\Parts\Part; +use App\Form\AttachmentFormType; use App\Form\Part\PartBaseType; use App\Services\AttachmentHelper; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; use Symfony\Contracts\Translation\TranslatorInterface; @@ -70,7 +72,8 @@ class PartController extends AbstractController * @param EntityManagerInterface $em * @return \Symfony\Component\HttpFoundation\Response */ - public function edit(Part $part, Request $request, EntityManagerInterface $em, TranslatorInterface $translator) + public function edit(Part $part, Request $request, EntityManagerInterface $em, TranslatorInterface $translator, + AttachmentHelper $attachmentHelper) { $this->denyAccessUnlessGranted('edit', $part); @@ -78,10 +81,17 @@ class PartController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + //Upload passed files + $attachments = $form['attachments']; + foreach ($attachments as $attachment) { + /** @var $attachment FormInterface */ + $attachmentHelper->upload( $attachment->getData(), $attachment['file']->getData()); + } + + $em->persist($part); $em->flush(); $this->addFlash('info', $translator->trans('part.edited_flash')); - //Reload form, so the SIUnitType entries use the new part unit $form = $this->createForm(PartBaseType::class, $part); } elseif ($form->isSubmitted() && ! $form->isValid()) { diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index a9ac313a..ca88642b 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -44,13 +44,13 @@ abstract class Attachment extends NamedDBElement * @var bool * @ORM\Column(type="boolean") */ - protected $show_in_table; + protected $show_in_table = false; /** * @var string The filename using the %BASE% variable * @ORM\Column(type="string", name="filename") */ - protected $path; + protected $path = ""; /** * ORM mapping is done in sub classes (like PartAttachment) @@ -63,7 +63,7 @@ abstract class Attachment extends NamedDBElement * @ORM\JoinColumn(name="type_id", referencedColumnName="id") * @Selectable() */ - protected $attachement_type; + protected $attachment_type; /*********************************************************** * Various function @@ -205,9 +205,9 @@ abstract class Attachment extends NamedDBElement * @return AttachmentType the type of this attachement * */ - public function getType(): AttachmentType + public function getAttachmentType(): ?AttachmentType { - return $this->attachement_type; + return $this->attachment_type; } /** @@ -237,6 +237,39 @@ abstract class Attachment extends NamedDBElement return $this; } + abstract public function setElement(AttachmentContainingDBElement $element) : Attachment; + + /** + * @param string $path + * @return Attachment + */ + public function setPath(string $path): Attachment + { + $this->path = $path; + return $this; + } + + /** + * @param AttachmentType $attachement_type + * @return Attachment + */ + public function setAttachmentType(AttachmentType $attachement_type): Attachment + { + $this->attachment_type = $attachement_type; + return $this; + } + + public function setURL(?string $url) : Attachment + { + //Only set if the URL is not empty + if (!empty($url)) { + $this->path = $url; + } + + return $this; + } + + /***************************************************************************************************** * Static functions *****************************************************************************************************/ diff --git a/src/Entity/Attachments/AttachmentContainingDBElement.php b/src/Entity/Attachments/AttachmentContainingDBElement.php index 26affaeb..f8599268 100644 --- a/src/Entity/Attachments/AttachmentContainingDBElement.php +++ b/src/Entity/Attachments/AttachmentContainingDBElement.php @@ -72,9 +72,6 @@ abstract class AttachmentContainingDBElement extends NamedDBElement */ protected $attachments; - //TODO - protected $attachmentTypes; - public function __construct() { $this->attachments = new ArrayCollection(); @@ -87,44 +84,35 @@ abstract class AttachmentContainingDBElement extends NamedDBElement *********************************************************************************/ /** - * Get all different attachement types of the attachements of this element. - * - * @return AttachmentType[] the attachement types as a one-dimensional array of AttachementType objects, - * sorted by their names - * - * @throws Exception if there was an error + * Gets all attachments associated with this element. + * @return Attachment[]|Collection */ - public function getAttachmentTypes(): ?array + public function getAttachments() : Collection { - return $this->attachmentTypes; + return $this->attachments; } /** - * Get all attachements of this element / Get the element's attachements with a specific type. - * - * @param int $type_id * if NULL, all attachements of this element will be returned - * * if this is a number > 0, only attachements with this type ID will be returned - * @param bool $only_table_attachements if true, only attachements with "show_in_table == true" - * - * @return Collection|Attachment[] the attachements as a one-dimensional array of Attachement objects - * - * @throws Exception if there was an error + * Adds an attachment to this element + * @param Attachment $attachment Attachment + * @return $this */ - public function getAttachments($type_id = null, bool $only_table_attachements = false) : Collection + public function addAttachment(Attachment $attachment) : self { - if ($only_table_attachements || $type_id) { - $attachements = $this->attachments; + //Attachment must be associated with this element + $attachment->setElement($this); + $this->attachments->add($attachment); + return $this; + } - foreach ($attachements as $key => $attachement) { - if (($only_table_attachements && (!$attachement->getShowInTable())) - || ($type_id && ($attachement->getType()->getID() !== $type_id))) { - unset($attachements[$key]); - } - } - - return $attachements; - } - - return $this->attachments; + /** + * Removes the given attachment from this element + * @param Attachment $attachment + * @return $this + */ + public function removeAttachment(Attachment $attachment) : self + { + $this->attachments->removeElement($attachment); + return $this; } } diff --git a/src/Entity/Attachments/PartAttachment.php b/src/Entity/Attachments/PartAttachment.php index 7b543231..6eae7bb6 100644 --- a/src/Entity/Attachments/PartAttachment.php +++ b/src/Entity/Attachments/PartAttachment.php @@ -31,6 +31,7 @@ namespace App\Entity\Attachments; +use App\Entity\Parts\Part; use Doctrine\ORM\Mapping as ORM; /** @@ -48,4 +49,14 @@ class PartAttachment extends Attachment */ protected $element; + public function setElement(AttachmentContainingDBElement $element): Attachment + { + if (!$element instanceof Part) { + throw new \InvalidArgumentException("The element associated with a PartAttachment must be a Part!"); + } + + $this->element = $element; + + return $this; + } } \ No newline at end of file diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index de6867ea..bf678769 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -84,7 +84,8 @@ class Part extends AttachmentContainingDBElement public const INSTOCK_UNKNOWN = -2; /** - * @ORM\OneToMany(targetEntity="App\Entity\Attachments\PartAttachment", mappedBy="element") + * @ORM\OneToMany(targetEntity="App\Entity\Attachments\PartAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) + * @Assert\Valid() */ protected $attachments; diff --git a/src/Form/AttachmentFormType.php b/src/Form/AttachmentFormType.php new file mode 100644 index 00000000..df383ab2 --- /dev/null +++ b/src/Form/AttachmentFormType.php @@ -0,0 +1,102 @@ +attachment_helper = $attachmentHelper; + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('name', TextType::class, + [ + 'label' => 'attachment.edit.name' + ]) + ->add('attachment_type', StructuralEntityType::class, [ + 'label' => 'attachment.edit.attachment_type', + 'class' => AttachmentType::class, + 'disable_not_selectable' => true, + ]); + + $builder->add('showInTable', CheckboxType::class, ['required' => false, + 'label' => 'attachment.edit.show_in_table', + 'attr' => ['class' => 'form-control-sm'], + 'label_attr' => ['class' => 'checkbox-custom']]); + + $builder->add('url', UrlType::class, [ + 'label' => 'attachment.edit.url', + 'required' => false + ]); + + $builder->add('file', FileType::class, [ + 'label' => 'attachment.edit.file', + 'mapped' => false, + 'required' => false, + 'attr' => ['class' => 'file', 'data-show-preview' => 'false', 'data-show-upload' => 'false'], + 'constraints' => [ + new File([ + 'maxSize' => $options['max_file_size'] + ]) + ] + + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Attachment::class, + 'max_file_size' => '16M' + ]); + + } +} \ No newline at end of file diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 2c25d68d..a708587d 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -31,12 +31,15 @@ namespace App\Form\Part; +use App\Entity\Attachments\PartAttachment; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\Storelocation; +use App\Form\AttachmentFormType; +use App\Form\AttachmentType; use App\Form\Type\SIUnitType; use App\Form\Type\StructuralEntityType; use Doctrine\DBAL\Types\FloatType; @@ -130,6 +133,17 @@ class PartBaseType extends AbstractType 'by_reference' => false ]); + //Attachment section + $builder->add('attachments', CollectionType::class, [ + 'entry_type' => AttachmentFormType::class, + 'allow_add' => true, 'allow_delete' => true, + 'label' => false, + 'entry_options' => [ + 'data_class' => PartAttachment::class + ], + 'by_reference' => false + ]); + $builder //Buttons ->add('save', SubmitType::class, ['label' => 'part.edit.save']) diff --git a/src/Services/AttachmentHelper.php b/src/Services/AttachmentHelper.php index 78c136d0..d05b346e 100644 --- a/src/Services/AttachmentHelper.php +++ b/src/Services/AttachmentHelper.php @@ -33,15 +33,18 @@ namespace App\Services; use App\Entity\Attachments\Attachment; -use Doctrine\ORM\EntityManagerInterface; -use SebastianBergmann\CodeCoverage\Node\File; +use App\Entity\Attachments\PartAttachment; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpKernel\KernelInterface; class AttachmentHelper { - + /** + * @var string The folder where the attachments are saved. By default this is data/media in the project root string + */ protected $base_path; public function __construct(ParameterBagInterface $params, KernelInterface $kernel) @@ -54,10 +57,42 @@ class AttachmentHelper if ($fs->isAbsolutePath($tmp_base_path)) { $this->base_path = $tmp_base_path; } else { - $this->base_path = realpath($kernel->getProjectDir() . $tmp_base_path); + $this->base_path = realpath($kernel->getProjectDir() . DIRECTORY_SEPARATOR . $tmp_base_path); } } + /** + * Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk. + * @param string $placeholder_path The filepath with placeholder for which the real path should be determined. + * @return string The absolute real path of the file + */ + protected function placeholderToRealPath(string $placeholder_path) : string + { + //The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory + $placeholder_path = str_replace("%MEDIA%", $this->base_path, $placeholder_path); + + //Older path entries are given via %BASE% which was the project root + $placeholder_path = str_replace("%BASE%/data/media", $this->base_path, $placeholder_path); + + return $placeholder_path; + } + + /** + * Converts an real absolute filepath to a placeholder version. + * @param string $real_path The absolute path, for which the placeholder version should be generated. + * @param bool $old_version By default the %MEDIA% placeholder is used, which is directly replaced with the + * media directory. If set to true, the old version with %BASE% will be used, which is the project directory. + * @return string The placeholder version of the filepath + */ + protected function realPathToPlaceholder(string $real_path, bool $old_version = false) : string + { + if ($old_version) { + return str_replace($this->base_path, "%BASE%/data/media", $real_path); + } + + return str_replace($this->base_path, "%MEDIA%", $real_path); + } + /** * Returns the absolute filepath of the attachment. Null is returned, if the attachment is externally saved. * @param Attachment $attachment The attachment for which the filepath should be determined @@ -70,7 +105,7 @@ class AttachmentHelper } $path = $attachment->getPath(); - $path = str_replace("%BASE%", $this->base_path, $path); + $path = $this->placeholderToRealPath($path); return realpath($path); } @@ -127,4 +162,61 @@ class AttachmentHelper return sprintf("%.{$decimals}f", $bytes / 1024 ** $factor) . @$sz[$factor]; } + /** + * Generate a path to a folder, where this attachment can save its file. + * @param Attachment $attachment The attachment for which the folder should be generated + * @return string The path to the folder (without trailing slash) + */ + public function generateFolderForAttachment(Attachment $attachment) : string + { + $mapping = [PartAttachment::class => 'part']; + + $path = $this->base_path . DIRECTORY_SEPARATOR . $mapping[get_class($attachment)] . DIRECTORY_SEPARATOR . $attachment->getElement()->getID(); + return $path; + } + + /** + * Moves the given uploaded file to a permanent place and saves it into the attachment + * @param Attachment $attachment The attachment in which the file should be saved + * @param UploadedFile|null $file The file which was uploaded + * @return Attachment The attachment with the new filepath + */ + public function upload(Attachment $attachment, ?UploadedFile $file) : Attachment + { + //If file is null, do nothing (helpful, so we dont have to check if the file was reuploaded in controller) + if (!$file) { + return $attachment; + } + + $folder = $this->generateFolderForAttachment($attachment); + + //Sanatize filename + $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); + $safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename); + $newFilename = $safeFilename . '.' . $file->getClientOriginalExtension(); + + //If a file with this name is already existing add a number to the filename + if (file_exists($folder . DIRECTORY_SEPARATOR . $newFilename)) { + $bak = $newFilename; + + $number = 1; + $newFilename = $folder . DIRECTORY_SEPARATOR . $safeFilename . '-' . $number . '.' . $file->getClientOriginalExtension(); + while (file_exists($newFilename)) { + $number++; + $newFilename = $folder . DIRECTORY_SEPARATOR . $safeFilename . '-' . $number . '.' . $file->getClientOriginalExtension(); + } + } + + //Move our temporay attachment to its final location + $file_path = $file->move($folder, $newFilename)->getRealPath(); + + //Make our file path relative to %BASE% + $file_path = $this->realPathToPlaceholder($file_path); + + //Save the path to the attachment + $attachment->setPath($file_path); + + return $attachment; + } + } \ No newline at end of file diff --git a/templates/Parts/edit/_attachments.html.twig b/templates/Parts/edit/_attachments.html.twig new file mode 100644 index 00000000..f9e6a573 --- /dev/null +++ b/templates/Parts/edit/_attachments.html.twig @@ -0,0 +1,61 @@ +{% set delete_btn %} + +{% endset %} + + +
+ {{ form_widget(attachment) }} + | ++ {{ delete_btn }} + | +