mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-26 16:45:01 +00:00
Add an importer for motives
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Command;
|
||||
|
||||
use Chill\TicketBundle\Service\Import\ImportMotivesFromDirectory;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(name: 'chill:ticket:import_ticket_motive_configuration', description: 'Import ticket motives from a directory configuration.')]
|
||||
class ImportTicketMotiveConfigurationCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'chill:ticket:import_ticket_motive_configuration';
|
||||
|
||||
public function __construct(private readonly ImportMotivesFromDirectory $importMotivesFromDirectory)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->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('<info>Ticket motives import completed successfully.</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -38,4 +38,18 @@ class MotiveRepository extends ServiceEntityRepository implements AssociatedEnti
|
||||
|
||||
return $query->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Motive>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Service\Import;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
|
||||
use Chill\TicketBundle\Entity\Motive;
|
||||
use Chill\TicketBundle\Repository\MotiveRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
final readonly class ImportMotivesFromDirectory
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private StoredObjectManagerInterface $storedObjectManager,
|
||||
private MotiveRepository $motiveRepository,
|
||||
) {}
|
||||
|
||||
public function import(string $directory, string $lang): void
|
||||
{
|
||||
$directory = rtrim($directory, DIRECTORY_SEPARATOR);
|
||||
if (!is_dir($directory)) {
|
||||
throw new \InvalidArgumentException(sprintf('The given path "%s" is not a directory.', $directory));
|
||||
}
|
||||
|
||||
$configPath = $directory.DIRECTORY_SEPARATOR.'motives.yaml';
|
||||
if (!is_file($configPath)) {
|
||||
throw new \RuntimeException(sprintf('Missing motives.yaml in directory: %s', $directory));
|
||||
}
|
||||
|
||||
$items = Yaml::parseFile($configPath);
|
||||
if (!\is_array($items)) {
|
||||
throw new \RuntimeException('The motives.yaml must contain a YAML array of items.');
|
||||
}
|
||||
|
||||
foreach ($items as $index => $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();
|
||||
}
|
||||
}
|
@@ -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:
|
||||
|
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Tests\Service\Import;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\TicketBundle\Repository\MotiveRepository;
|
||||
use Chill\TicketBundle\Service\Import\ImportMotivesFromDirectory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ImportMotivesFromDirectoryTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testImportSmoke(): void
|
||||
{
|
||||
// 1) Prepare temporary directory with a minimal motives.yaml and a small file
|
||||
$tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'chill_ticket_import_'.bin2hex(random_bytes(6));
|
||||
$this->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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user