From 3b36b2a4dcdc11c11e2187e2be87691216a74cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 11 Mar 2023 22:40:53 +0100 Subject: [PATCH 01/31] Improved exporter service --- .../Base/AbstractPartsContainingDBElement.php | 3 +- .../ImportExportSystem/EntityExporter.php | 142 +++++++++++------- .../ImportExportSystem/EntityExporterTest.php | 82 ++++++++++ 3 files changed, 171 insertions(+), 56 deletions(-) create mode 100644 tests/Services/ImportExportSystem/EntityExporterTest.php diff --git a/src/Entity/Base/AbstractPartsContainingDBElement.php b/src/Entity/Base/AbstractPartsContainingDBElement.php index f30819f5..a8ba8e8c 100644 --- a/src/Entity/Base/AbstractPartsContainingDBElement.php +++ b/src/Entity/Base/AbstractPartsContainingDBElement.php @@ -29,7 +29,6 @@ use Doctrine\ORM\Mapping as ORM; * * @ORM\MappedSuperclass(repositoryClass="App\Repository\AbstractPartsContainingRepository") */ -abstract class -AbstractPartsContainingDBElement extends AbstractStructuralDBElement +abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement { } diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php index 2d85097c..e4b4e552 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\ImportExportSystem; use App\Entity\Base\AbstractNamedDBElement; +use Symfony\Component\OptionsResolver\OptionsResolver; use function in_array; use InvalidArgumentException; use function is_array; @@ -32,6 +33,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Serializer\SerializerInterface; +use function Symfony\Component\String\u; /** * Use this class to export an entity to multiple file formats. @@ -42,104 +44,136 @@ class EntityExporter public function __construct(SerializerInterface $serializer) { - /*$encoders = [new XmlEncoder(), new JsonEncoder(), new CSVEncoder(), new YamlEncoder()]; - $normalizers = [new ObjectNormalizer(), new DateTimeNormalizer()]; - $this->serializer = new Serializer($normalizers, $encoders); - $this->serializer-> */ $this->serializer = $serializer; } + protected function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('format', 'csv'); + $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); + + $resolver->setDefault('csv_delimiter', ','); + $resolver->setAllowedTypes('csv_delimiter', 'string'); + + $resolver->setDefault('level', 'extended'); + $resolver->setAllowedValues('level', ['simple', 'extended', 'full']); + + $resolver->setDefault('include_children', false); + $resolver->setAllowedTypes('include_children', 'bool'); + } + /** - * Exports an Entity or an array of entities to multiple file formats. + * Export the given entities using the given options. + * @param AbstractNamedDBElement|AbstractNamedDBElement[] $entities The data to export + * @param array $options The options to use for exporting + * @return string The serialized data + */ + public function exportEntities($entities, array $options): string + { + if (!is_array($entities)) { + $entities = [$entities]; + } + + //Ensure that all entities are of type AbstractNamedDBElement + $entity_type = null; + foreach ($entities as $entity) { + if (!$entity instanceof AbstractNamedDBElement) { + throw new InvalidArgumentException('All entities must be of type AbstractNamedDBElement!'); + } + } + + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + $options = $resolver->resolve($options); + + //If include children is set, then we need to add the include_children group + $groups = [$options['level']]; + if ($options['include_children']) { + $groups[] = 'include_children'; + } + + return $this->serializer->serialize($entities, $options['format'], + [ + 'groups' => $groups, + 'as_collection' => true, + 'csv_delimiter' => $options['csv_delimiter'], + 'xml_root_node_name' => 'PartDBExport', + ] + ); + } + + /** + * Exports an Entity or an array of entities to multiple file formats. * * @param Request $request the request that should be used for option resolving - * @param AbstractNamedDBElement|object[] $entity + * @param AbstractNamedDBElement|object[] $entities * * @return Response the generated response containing the exported data * * @throws ReflectionException */ - public function exportEntityFromRequest($entity, Request $request): Response + public function exportEntityFromRequest($entities, Request $request): Response { - $format = $request->get('format') ?? 'json'; + $options = [ + 'format' => $request->get('format') ?? 'json', + 'level' => $request->get('level') ?? 'extended', + 'include_children' => $request->request->getBoolean('include_children') ?? false, + ]; - //Check if we have one of the supported formats - if (!in_array($format, ['json', 'csv', 'yaml', 'xml'], true)) { - throw new InvalidArgumentException('Given format is not supported!'); + if (!is_array($entities)) { + $entities = [$entities]; } - //Check export verbosity level - $level = $request->get('level') ?? 'extended'; - if (!in_array($level, ['simple', 'extended', 'full'], true)) { - throw new InvalidArgumentException('Given level is not supported!'); - } + //Do the serialization with the given options + $serialized_data = $this->exportEntities($entities, $options); - //Check for include children option - $include_children = $request->get('include_children') ?? false; + $response = new Response($serialized_data); - //Check which groups we need to export, based on level and include_children - $groups = [$level]; - if ($include_children) { - $groups[] = 'include_children'; - } + //Resolve the format + $optionsResolver = new OptionsResolver(); + $this->configureOptions($optionsResolver); + $options = $optionsResolver->resolve($options); + + //Determine the content type for the response //Plain text should work for all types $content_type = 'text/plain'; //Try to use better content types based on the format + $format = $options['format']; switch ($format) { case 'xml': $content_type = 'application/xml'; - break; case 'json': $content_type = 'application/json'; - break; } - - //Ensure that we always serialize an array. This makes it easier to import the data again. - if (is_array($entity)) { - $entity_array = $entity; - } else { - $entity_array = [$entity]; - } - - $serialized_data = $this->serializer->serialize($entity_array, $format, - [ - 'groups' => $groups, - 'as_collection' => true, - 'csv_delimiter' => ';', //Better for Excel - 'xml_root_node_name' => 'PartDBExport', - ]); - - $response = new Response($serialized_data); - $response->headers->set('Content-Type', $content_type); //If view option is not specified, then download the file. if (!$request->get('view')) { - if ($entity instanceof AbstractNamedDBElement) { - $entity_name = $entity->getName(); - } elseif (is_array($entity)) { - if (empty($entity)) { - throw new InvalidArgumentException('$entity must not be empty!'); - } - //Use the class name of the first element for the filename - $reflection = new ReflectionClass($entity[0]); - $entity_name = $reflection->getShortName(); + //Determine the filename + //When we only have one entity, then we can use the name of the entity + if (count($entities) === 1) { + $entity_name = $entities[0]->getName(); } else { - throw new InvalidArgumentException('$entity type is not supported!'); + //Use the class name of the first element for the filename otherwise + $reflection = new ReflectionClass($entities[0]); + $entity_name = $reflection->getShortName(); } + $level = $options['level']; + $filename = 'export_'.$entity_name.'_'.$level.'.'.$format; // Create the disposition of the file $disposition = $response->headers->makeDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename, - $string = preg_replace('![^'.preg_quote('-', '!').'a-z0-_9\s]+!', '', strtolower($filename)) + u($filename)->ascii()->toString(), ); // Set the content disposition $response->headers->set('Content-Disposition', $disposition); diff --git a/tests/Services/ImportExportSystem/EntityExporterTest.php b/tests/Services/ImportExportSystem/EntityExporterTest.php new file mode 100644 index 00000000..1d3dd1f3 --- /dev/null +++ b/tests/Services/ImportExportSystem/EntityExporterTest.php @@ -0,0 +1,82 @@ +. + */ + +namespace App\Tests\Services\ImportExportSystem; + +use App\Entity\Parts\Category; +use App\Services\ImportExportSystem\EntityExporter; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\Request; + +class EntityExporterTest extends WebTestCase +{ + /** + * @var EntityExporter + */ + protected $service; + + protected function setUp(): void + { + parent::setUp(); + self::bootKernel(); + $this->service = self::getContainer()->get(EntityExporter::class); + } + + private function getEntities(): array + { + $entity1 = (new Category())->setName('Enitity 1')->setComment('Test'); + $entity1_1 = (new Category())->setName('Enitity 1.1')->setParent($entity1); + $entity2 = (new Category())->setName('Enitity 2'); + + return [$entity1, $entity1_1, $entity2]; + } + + public function testExportStructuralEntities(): void + { + $entities = $this->getEntities(); + + $json_without_children = $this->service->exportEntities($entities, ['format' => 'json', 'level' => 'simple']); + $this->assertSame('[{"comment":"Test","name":"Enitity 1","type":null},{"comment":"","name":"Enitity 1.1","type":null},{"comment":"","name":"Enitity 2","type":null}]', + $json_without_children); + + $json_with_children = $this->service->exportEntities($entities, + ['format' => 'json', 'level' => 'simple', 'include_children' => true]); + $this->assertSame('[{"children":[{"children":[],"comment":"","name":"Enitity 1.1","type":null}],"comment":"Test","name":"Enitity 1","type":null},{"children":[],"comment":"","name":"Enitity 1.1","type":null},{"children":[],"comment":"","name":"Enitity 2","type":null}]', + $json_with_children); + } + + public function testExportEntityFromRequest(): void + { + $entities = $this->getEntities(); + + $request = new Request(); + $request->request->set('format', 'json'); + $request->request->set('level', 'simple'); + $response = $this->service->exportEntityFromRequest($entities, $request); + + $this->assertSame('[{"comment":"Test","name":"Enitity 1","type":null},{"comment":"","name":"Enitity 1.1","type":null},{"comment":"","name":"Enitity 2","type":null}]', + $response->getContent()); + + $this->assertSame('application/json', $response->headers->get('Content-Type')); + $this->assertNotEmpty($response->headers->get('Content-Disposition')); + + + } +} From 49944cda87d9d00c1304134f14cd9a90b65b20cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 12 Mar 2023 00:27:04 +0100 Subject: [PATCH 02/31] Added possibility to export Parts from part tables --- .../elements/datatables/parts_controller.js | 7 +++ src/Controller/PartImportExportController.php | 63 +++++++++++++++++++ src/Controller/SelectAPIController.php | 19 ++++++ .../ImportExportSystem/EntityExporter.php | 2 +- .../Parts/PartsTableActionHandler.php | 28 +++++++++ .../components/datatables.macro.html.twig | 6 ++ translations/messages.en.xlf | 40 ++++++++++-- translations/security.en.xlf | 2 +- translations/validators.en.xlf | 2 +- 9 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 src/Controller/PartImportExportController.php diff --git a/assets/controllers/elements/datatables/parts_controller.js b/assets/controllers/elements/datatables/parts_controller.js index 33362648..1fe11a20 100644 --- a/assets/controllers/elements/datatables/parts_controller.js +++ b/assets/controllers/elements/datatables/parts_controller.js @@ -107,6 +107,13 @@ export default class extends DatatablesController { //Hide the select element (the tomselect button is the sibling of the select element) select_target.nextElementSibling.classList.add('d-none'); } + + //If the selected option has a data-turbo attribute, set it to the form + if (selected_option.dataset.turbo) { + this.element.dataset.turbo = selected_option.dataset.turbo; + } else { + this.element.dataset.turbo = true; + } } confirmDeletionAtSubmit(event) { diff --git a/src/Controller/PartImportExportController.php b/src/Controller/PartImportExportController.php new file mode 100644 index 00000000..e3013cb8 --- /dev/null +++ b/src/Controller/PartImportExportController.php @@ -0,0 +1,63 @@ +. + */ + +namespace App\Controller; + +use App\Services\ImportExportSystem\EntityExporter; +use App\Services\Parts\PartsTableActionHandler; +use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; + +class PartImportExportController extends AbstractController +{ + private EntityManagerInterface $entityManager; + private PartsTableActionHandler $partsTableActionHandler; + + public function __construct(EntityManagerInterface $entityManager, PartsTableActionHandler $partsTableActionHandler) + { + $this->entityManager = $entityManager; + $this->partsTableActionHandler = $partsTableActionHandler; + } + + /** + * @Route("/parts/export", name="parts_export", methods={"GET"}) + * @return Response + */ + public function exportParts(Request $request, EntityExporter $entityExporter): Response + { + $ids = $request->query->get('ids', ''); + $parts = $this->partsTableActionHandler->idStringToArray($ids); + + if (empty($parts)) { + throw new \RuntimeException('No parts found!'); + } + + //Ensure that we have access to the parts + foreach ($parts as $part) { + $this->denyAccessUnlessGranted('read', $part); + } + + return $entityExporter->exportEntityFromRequest($parts, $request); + } +} \ No newline at end of file diff --git a/src/Controller/SelectAPIController.php b/src/Controller/SelectAPIController.php index 0f30b648..1b7784e4 100644 --- a/src/Controller/SelectAPIController.php +++ b/src/Controller/SelectAPIController.php @@ -95,6 +95,25 @@ class SelectAPIController extends AbstractController return $this->getResponseForClass(Project::class, false); } + /** + * @Route("/export_level", name="select_export_level") + */ + public function exportLevel(): Response + { + $entries = [ + 1 => $this->translator->trans('export.level.simple'), + 2 => $this->translator->trans('export.level.extended'), + 3 => $this->translator->trans('export.level.full'), + ]; + + return $this->json(array_map(function ($key, $value) { + return [ + 'text' => $value, + 'value' => $key, + ]; + }, array_keys($entries), $entries)); + } + /** * @Route("/label_profiles", name="select_label_profiles") * @return Response diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php index e4b4e552..2b84b115 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -52,7 +52,7 @@ class EntityExporter $resolver->setDefault('format', 'csv'); $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); - $resolver->setDefault('csv_delimiter', ','); + $resolver->setDefault('csv_delimiter', ';'); $resolver->setAllowedTypes('csv_delimiter', 'string'); $resolver->setDefault('level', 'extended'); diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 8b695141..3061d2f3 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -102,6 +102,34 @@ final class PartsTableActionHandler ); } + //When action starts with "export_" we have to redirect to the export controller + $matches = []; + if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) { + $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); + switch ($target_id) { + case 1: + default: + $level = 'simple'; + break; + case 2: + $level = 'extended'; + break; + case 3: + $level = 'full'; + break; + } + + + return new RedirectResponse( + $this->urlGenerator->generate('parts_export', [ + 'format' => $matches[1], + 'level' => $level, + 'ids' => $ids, + '_redirect' => $redirect_url + ]) + ); + } + //Iterate over the parts and apply the action to it: foreach ($selected_parts as $part) { diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index e8a91c7f..fcc38453 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -63,6 +63,12 @@ + + + + + +