Add an importer for motives

This commit is contained in:
2025-09-02 10:12:47 +02:00
parent 10b73e06e1
commit 25561cdf63
6 changed files with 308 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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