From 25561cdf639fbca5db60d86ff677f17c37b99ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 2 Sep 2025 10:12:47 +0200 Subject: [PATCH] Add an importer for motives --- ...ImportTicketMotiveConfigurationCommand.php | 50 +++++++ .../ChillTicketBundle/src/Entity/Motive.php | 10 ++ .../src/Repository/MotiveRepository.php | 14 ++ .../Import/ImportMotivesFromDirectory.php | 136 ++++++++++++++++++ .../src/config/services.yaml | 3 + .../Import/ImportMotivesFromDirectoryTest.php | 95 ++++++++++++ 6 files changed, 308 insertions(+) create mode 100644 src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php create mode 100644 src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php diff --git a/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php b/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php new file mode 100644 index 000000000..02808960e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php @@ -0,0 +1,50 @@ +setDescription('Import ticket motives from a directory containing motives.yaml and referenced files') + ->addArgument('directory', InputArgument::REQUIRED, 'The directory path containing motives.yaml') + ->addArgument('lang', InputArgument::REQUIRED, 'The language key to use for matching labels (e.g., fr)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $directory = (string) $input->getArgument('directory'); + $lang = (string) $input->getArgument('lang'); + + $this->importMotivesFromDirectory->import($directory, $lang); + + $output->writeln('Ticket motives import completed successfully.'); + + return Command::SUCCESS; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php index c2d8cc6c9..380ea3d02 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php @@ -132,4 +132,14 @@ class Motive { $this->supplementaryComments[] = $supplementaryComments; } + + public function setSupplementaryComment(array $supplementaryComments): void + { + foreach ($this->supplementaryComments as $key => $supplementaryComment) { + unset($this->supplementaryComments[$key]); + } + foreach (array_values($supplementaryComments) as $key => $supplementaryComment) { + $this->supplementaryComments[$key] = $supplementaryComment; + } + } } diff --git a/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php index 7863305fe..234a7fd70 100644 --- a/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php +++ b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php @@ -38,4 +38,18 @@ class MotiveRepository extends ServiceEntityRepository implements AssociatedEnti return $query->getQuery()->getOneOrNullResult(); } + + /** + * @return list + */ + public function findByLabel(string $label, string $lang): array + { + $query = $this->createQueryBuilder('m'); + $query->select('m') + ->where($query->expr()->like('JSON_EXTRACT(m.label, :lang)', ':label')) + ->setParameter('label', $label) + ->setParameter('lang', $lang); + + return $query->getQuery()->getResult(); + } } diff --git a/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php new file mode 100644 index 000000000..946a25922 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php @@ -0,0 +1,136 @@ + $item) { + if (!\is_array($item)) { + throw new \RuntimeException(sprintf('Item at index %d is not an object/array.', $index)); + } + + // label: set as-is (expects an array) + if (!array_key_exists('label', $item) || !\is_array($item['label'])) { + throw new \RuntimeException(sprintf('Item %d: missing or invalid "label" (expected array).', $index)); + } + $labelArray = $item['label']; + $labelForSearch = $labelArray[$lang] ?? null; + if (!\is_string($labelForSearch) || '' === trim($labelForSearch)) { + throw new \RuntimeException(sprintf('Item %d: missing label for language "%s".', $index, $lang)); + } + + $existing = $this->motiveRepository->findByLabel($labelForSearch, $lang); + if (\count($existing) > 1) { + throw new \RuntimeException(sprintf('Item %d: multiple motives found with label "%s" for lang "%s".', $index, $labelForSearch, $lang)); + } + $motive = $existing[0] ?? new Motive(); + + $motive->setLabel($labelArray); + + // urgent: if true => YES, if explicitly false => NO, if absent => leave null + if (array_key_exists('urgent', $item)) { + $urgent = (bool) $item['urgent']; + $motive->setMakeTicketEmergency($urgent ? EmergencyStatusEnum::YES : EmergencyStatusEnum::NO); + } + + // supplementary informations (support both keys with/without underscore) + $suppKey = array_key_exists('supplementary_informations', $item) ? 'supplementary_informations' : (array_key_exists('supplementary informations', $item) ? 'supplementary informations' : null); + if ($suppKey && \is_array($item[$suppKey])) { + $motive->setSupplementaryComment(array_map(fn (array $supplementaryDefinition) => ['label' => $supplementaryDefinition['label'][$lang]], $item[$suppKey])); + } + + // stored objects + $storedKey = array_key_exists('stored_objects', $item) ? 'stored_objects' : (array_key_exists('stored object', $item) ? 'stored object' : null); + if ($storedKey && \is_array($item[$storedKey])) { + foreach ($item[$storedKey] as $docIndex => $doc) { + if (!\is_array($doc)) { + throw new \RuntimeException(sprintf('Item %d, stored object %d: invalid entry.', $index, $docIndex)); + } + $label = $doc['label'][$lang] ?? ($doc['label'] ?? null); + $filename = $doc['filename'] ?? null; + if (null === $filename || !\is_string($filename)) { + throw new \RuntimeException(sprintf('Item %d, stored object %d: missing filename.', $index, $docIndex)); + } + + $fullPath = $directory.DIRECTORY_SEPARATOR.$filename; + if (!is_file($fullPath)) { + throw new \RuntimeException(sprintf('Referenced file not found: %s', $fullPath)); + } + + $storedObject = new StoredObject(); + if (\is_string($label)) { + $storedObject->setTitle($label); + } elseif (\is_array($label) && isset($label['fr']) && \is_string($label['fr'])) { + $storedObject->setTitle($label['fr']); + } else { + $storedObject->setTitle(pathinfo($filename, PATHINFO_FILENAME)); + } + + $content = file_get_contents($fullPath); + if (false === $content) { + throw new \RuntimeException(sprintf('Unable to read file: %s', $fullPath)); + } + + $ext = strtolower((string) pathinfo($fullPath, PATHINFO_EXTENSION)); + $contentType = match ($ext) { + 'pdf' => 'application/pdf', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'webp' => 'image/webp', + 'txt' => 'text/plain', + default => 'application/octet-stream', + }; + + $this->storedObjectManager->write($storedObject, $content, $contentType); + + $motive->addStoredObject($storedObject); + $this->entityManager->persist($storedObject); + } + } + + $this->entityManager->persist($motive); + } + + $this->entityManager->flush(); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index fb0945fe6..1089b574e 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -9,6 +9,9 @@ services: Chill\TicketBundle\Action\Comment\Handler\: resource: '../Action/Comment/Handler/' + Chill\TicketBundle\Command\: + resource: '../Command/' + Chill\TicketBundle\Controller\: resource: '../Controller/' tags: diff --git a/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php new file mode 100644 index 000000000..0553901b0 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php @@ -0,0 +1,95 @@ +assertTrue(mkdir($tmpBase, 0777, true)); + + $filePath = $tmpBase.DIRECTORY_SEPARATOR.'file.txt'; + file_put_contents($filePath, 'hello world'); + + $yaml = <<<'YAML' +- label: + fr: "Test Motive" + urgent: true + supplementary_informations: + - label: + fr: "Some note" + stored_objects: + - label: + fr: "Doc title" + filename: "file.txt" +YAML; + file_put_contents($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml', $yaml); + + // 2) Create mocks (Prophecy) for dependencies + $emProphecy = $this->prophesize(EntityManagerInterface::class); + // persist should be called for StoredObject and Motive at least once + $emProphecy->persist(Argument::type(StoredObject::class))->shouldBeCalled(); + $emProphecy->persist(Argument::that(fn($arg) => + // Motive class is in another namespace; just check it's an object with setLabel method + \is_object($arg) && method_exists($arg, 'setLabel')))->shouldBeCalled(); + $emProphecy->flush()->shouldBeCalled(); + + $somMock = $this->createMock(StoredObjectManagerInterface::class); + $somMock + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (StoredObject $so, string $content, ?string $contentType) { + $this->assertSame('text/plain', $contentType); + + return new StoredObjectVersion($so, 1, [], [], $contentType ?? 'application/octet-stream', 'file.txt'); + }); + + $repoProphecy = $this->prophesize(MotiveRepository::class); + $repoProphecy->findByLabel('Test Motive', 'fr')->willReturn([])->shouldBeCalled(); + + // 3) Run the importer + $importer = new ImportMotivesFromDirectory( + $emProphecy->reveal(), + $somMock, + $repoProphecy->reveal(), + ); + + $importer->import($tmpBase, 'fr'); + + // 4) Cleanup + @unlink($filePath); + @unlink($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml'); + @rmdir($tmpBase); + + // If we reached here, it's a successful smoke test + $this->assertTrue(true); + } +}