diff --git a/config/packages/chill_doc_store.yaml b/config/packages/chill_doc_store.yaml index eb7f6be01..2182b17cc 100644 --- a/config/packages/chill_doc_store.yaml +++ b/config/packages/chill_doc_store.yaml @@ -1,5 +1,5 @@ chill_doc_store: - use_driver: openstack + use_driver: local_storage local_storage: storage_path: '%kernel.project_dir%/var/storage' openstack: diff --git a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php index 4b18989cb..aa35f2052 100644 --- a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php +++ b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php @@ -43,11 +43,17 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, [...$context, UserNormalizer::AT_DATE => $object->getCreatedAt()]), ]; - if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) { + $normalizationGroups = $context[AbstractNormalizer::GROUPS] ?? []; + + if (is_string($normalizationGroups)) { + $normalizationGroups = [$normalizationGroups]; + } + + if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $normalizationGroups, true)) { $data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context); } - if (in_array(self::WITH_RESTORED_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) { + if (in_array(self::WITH_RESTORED_CONTEXT, $normalizationGroups, true)) { $data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]); } diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php index 5ad6eed05..a39186efe 100644 --- a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php @@ -18,12 +18,12 @@ use Chill\TicketBundle\Entity\Ticket; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Clock\ClockInterface; -final readonly class ReplaceMotiveCommandHandler +class ReplaceMotiveCommandHandler { public function __construct( - private ClockInterface $clock, - private EntityManagerInterface $entityManager, - private ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler, + private readonly ClockInterface $clock, + private readonly EntityManagerInterface $entityManager, + private readonly ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler, ) {} public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php index 8af2773e8..3074ee6c1 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php @@ -60,7 +60,7 @@ final readonly class ReplaceMotiveController $this->entityManager->flush(); return new JsonResponse( - $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), Response::HTTP_CREATED, [], true diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php index 965787059..ff65ea016 100644 --- a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php +++ b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php @@ -11,6 +11,8 @@ declare(strict_types=1); namespace Chill\TicketBundle\DataFixtures\ORM; +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\Motive; use Doctrine\Bundle\FixturesBundle\Fixture; @@ -19,6 +21,8 @@ use Doctrine\Persistence\ObjectManager; final class LoadMotives extends Fixture implements FixtureGroupInterface { + public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager) {} + public static function getGroups(): array { return ['ticket']; @@ -26,6 +30,12 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface public function load(ObjectManager $manager) { + $docs = [ + ['label' => '🌙 De 21h à 07h du matin', 'path' => __DIR__.'/docs/peloton_1.pdf'], + ['label' => '☀️ De 07h à 21h', 'path' => __DIR__.'/docs/peloton_2.pdf'], + ['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'], + ]; + foreach (explode("\n", self::MOTIVES) as $row) { if ('' === trim($row)) { continue; @@ -44,7 +54,26 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface default => throw new \UnexpectedValueException('Unexpected value'), }); - foreach (array_slice($data, 2) as $supplementaryComment) { + $numberOfDocs = (int) $data[2]; + for ($i = 1; $i <= $numberOfDocs; ++$i) { + $doc = $docs[$i - 1]; + $storedObject = new StoredObject(); + $storedObject->setTitle($doc['label']); + + $content = file_get_contents($doc['path']); + $contentType = match (substr($doc['path'], -3, 3)) { + 'pdf' => 'application/pdf', + 'png' => 'image/png', + default => throw new \UnexpectedValueException('Not supported content type here'), + }; + $this->storedObjectManager->write($storedObject, $content, $contentType); + + $motive->addStoredObject($storedObject); + $manager->persist($storedObject); + } + + + foreach (array_slice($data, 3) as $supplementaryComment) { if ('' !== trim((string) $supplementaryComment)) { $motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]); } @@ -56,43 +85,43 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface } private const MOTIVES = <<<'CSV' - "Coordonnées",false,"Nouvelles coordonnées", - "Horaire de passage",false, - "Retard de livraison",false, - "Erreur de livraison",false, - "Colis incomplet",false, - "MATLOC",false, - "Retard DASRI",false, - "Planning d'astreintes",false, - "Planning des tournées",false, - "Contrôle pompe",true, - "Changement de rendez-vous",false,"Date du nouveau rendez-vous","Lieu du nouveau rendez-vous", - "Renseignement facturation/prestation",false, - "Décès patient",false,"Date et heures du décès","Autorisation préalable du médecin pour le décès", - "Demande de prise en charge",false, - "Information absence",false, - "Demande bulletin de situation",false, - "Difficultés accès logement",false, - "Déplacement inutile",false, - "Problème de prélèvement/de commande",false, - "Parc auto",false, - "Demande d'admission",false, - "Retrait de matériel au domicile",false, - "Comptes-rendus",false, - "Démarchage commercial",false, - "Demande de transport",false, - "Demande laboratoire",false, - "Demande admission",false, - "Suivi de prise en charge",false, - "Mauvaise adresse",false, - "Patient absent",false, - "Annulation",false, - "Colis perdu",false, - "Changement de rendez-vous",false, - "Coordination interservices",false, - "Problème de substitution produits",true, - "Problème ordonnance",false, - "Réclamations facture",false,"Numéro de facture concerné", - "Préparation urgente",true, + "Coordonnées",false,"3","Nouvelles coordonnées", + "Horaire de passage",false,"0", + "Retard de livraison",false,"0", + "Erreur de livraison",false,"0", + "Colis incomplet",false,"0", + "MATLOC",false,"0", + "Retard DASRI",false,"1", + "Planning d'astreintes",false,"0", + "Planning des tournées",false,"0", + "Contrôle pompe",true,"0", + "Changement de rendez-vous",false,"0","Date du nouveau rendez-vous","Lieu du nouveau rendez-vous", + "Renseignement facturation/prestation",false,"0", + "Décès patient",false,"0","Date et heures du décès","Autorisation préalable du médecin pour le décès", + "Demande de prise en charge",false,"0", + "Information absence",false,"0", + "Demande bulletin de situation",false,"0", + "Difficultés accès logement",false,"0", + "Déplacement inutile",false,"0", + "Problème de prélèvement/de commande",false,"0", + "Parc auto",false,"0", + "Demande d'admission",false,"0", + "Retrait de matériel au domicile",false,"0", + "Comptes-rendus",false,"0", + "Démarchage commercial",false,"0", + "Demande de transport",false,"0", + "Demande laboratoire",false,"0", + "Demande admission",false,"0", + "Suivi de prise en charge",false,"0", + "Mauvaise adresse",false,"0", + "Patient absent",false,"0", + "Annulation",false,"0", + "Colis perdu",false,"0", + "Changement de rendez-vous",false,"0", + "Coordination interservices",false,"0", + "Problème de substitution produits",true,"0", + "Problème ordonnance",false,"3", + "Réclamations facture",false,"0","Numéro de facture concerné", + "Préparation urgente",true,"3", CSV; } diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_1.pdf b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_1.pdf new file mode 100644 index 000000000..7b393d6e2 Binary files /dev/null and b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_1.pdf differ diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_2.pdf b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_2.pdf new file mode 100644 index 000000000..7a6e626a4 Binary files /dev/null and b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_2.pdf differ diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/schema_1.png b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/schema_1.png new file mode 100644 index 000000000..edb9ed774 Binary files /dev/null and b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/schema_1.png differ diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php index 2ea3b47c2..c2d8cc6c9 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php @@ -11,6 +11,10 @@ declare(strict_types=1); namespace Chill\TicketBundle\Entity; +use Chill\DocStoreBundle\Entity\StoredObject; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\ReadableCollection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation as Serializer; @@ -37,10 +41,40 @@ class Motive #[Serializer\Groups(['read'])] private ?EmergencyStatusEnum $makeTicketEmergency = null; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: StoredObject::class)] + #[ORM\JoinTable(name: 'motive_stored_objects', schema: 'chill_ticket')] + private Collection $storedObjects; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])] #[Serializer\Groups(['read'])] private array $supplementaryComments = []; + public function __construct() + { + $this->storedObjects = new ArrayCollection(); + } + + public function addStoredObject(StoredObject $storedObject): void + { + if (!$this->storedObjects->contains($storedObject)) { + $this->storedObjects[] = $storedObject; + } + } + + public function removeStoredObject(StoredObject $storedObject): void + { + $this->storedObjects->removeElement($storedObject); + } + + #[Serializer\Groups(['read'])] + public function getStoredObjects(): ReadableCollection + { + return $this->storedObjects; + } + public function isActive(): bool { return $this->active; diff --git a/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php index 0c1174a59..7863305fe 100644 --- a/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php +++ b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php @@ -11,6 +11,8 @@ declare(strict_types=1); namespace Chill\TicketBundle\Repository; +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\TicketBundle\Entity\Motive; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -18,10 +20,22 @@ use Doctrine\Persistence\ManagerRegistry; /** * @template-extends ServiceEntityRepository */ -class MotiveRepository extends ServiceEntityRepository +class MotiveRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Motive::class); } + + public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Motive + { + $query = $this->createQueryBuilder('m'); + $query->select('m') + ->where(':stored_object MEMBER OF m.storedObjects') + ->setParameter('stored_object', $storedObject) + ->setMaxResults(1) + ; + + return $query->getQuery()->getOneOrNullResult(); + } } diff --git a/src/Bundle/ChillTicketBundle/src/Security/Authorization/StoredObjectVoter/MotiveStoredObjectVoter.php b/src/Bundle/ChillTicketBundle/src/Security/Authorization/StoredObjectVoter/MotiveStoredObjectVoter.php new file mode 100644 index 000000000..64bfd763b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Security/Authorization/StoredObjectVoter/MotiveStoredObjectVoter.php @@ -0,0 +1,33 @@ +motiveRepository->findAssociatedEntityToStoredObject($subject); + } + + public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool + { + return true; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index 659121f8c..fb0945fe6 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -20,6 +20,9 @@ services: Chill\TicketBundle\Security\Voter\: resource: '../Security/Voter/' + Chill\TicketBundle\Security\Authorization\: + resource: '../Security/Authorization/' + Chill\TicketBundle\Serializer\: resource: '../Serializer/' diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250718124651.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250718124651.php new file mode 100644 index 000000000..b34c80d68 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250718124651.php @@ -0,0 +1,51 @@ +addSql( + <<<'SQL' + CREATE TABLE chill_ticket.motive_stored_objects (motive_id INT NOT NULL, storedobject_id INT NOT NULL, PRIMARY KEY(motive_id, storedobject_id)) + SQL + ); + $this->addSql('CREATE INDEX IDX_4247C4849658649C ON chill_ticket.motive_stored_objects (motive_id)'); + $this->addSql('CREATE INDEX IDX_4247C484EE684399 ON chill_ticket.motive_stored_objects (storedobject_id)'); + $this->addSql( + <<<'SQL' + ALTER TABLE chill_ticket.motive_stored_objects ADD CONSTRAINT FK_4247C4849658649C FOREIGN KEY (motive_id) REFERENCES chill_ticket.motive (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL + ); + $this->addSql( + <<<'SQL' + ALTER TABLE chill_ticket.motive_stored_objects ADD CONSTRAINT FK_4247C484EE684399 FOREIGN KEY (storedobject_id) REFERENCES chill_doc.stored_object (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL + ); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive_stored_objects DROP CONSTRAINT FK_4247C4849658649C'); + $this->addSql('ALTER TABLE chill_ticket.motive_stored_objects DROP CONSTRAINT FK_4247C484EE684399'); + $this->addSql('DROP TABLE chill_ticket.motive_stored_objects'); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php index 5e899e2b4..28bf7dac5 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php @@ -11,20 +11,19 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Controller; -use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; -use Chill\TicketBundle\Controller\ReplaceMotiveController; +use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; use Chill\TicketBundle\Entity\Motive; -use Chill\TicketBundle\Entity\MotiveHistory; +use PHPUnit\Framework\TestCase; +use Chill\TicketBundle\Controller\ReplaceMotiveController; use Chill\TicketBundle\Entity\Ticket; use Doctrine\ORM\EntityManagerInterface; -use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Clock\MockClock; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Validator\ValidatorInterface; /** @@ -32,88 +31,62 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; * * @coversNothing */ -class ReplaceMotiveControllerTest extends KernelTestCase +class ReplaceMotiveControllerTest extends TestCase { use ProphecyTrait; - private SerializerInterface $serializer; - - private ValidatorInterface $validator; - - protected function setUp(): void + private function buildController(Ticket $ticket, string $body, Motive $motive): ReplaceMotiveController { - self::bootKernel(); - $this->serializer = self::getContainer()->get(SerializerInterface::class); - $this->validator = self::getContainer()->get(ValidatorInterface::class); + $command = new ReplaceMotiveCommand($motive); + + // Mock Security + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + // Mock EntityManager + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $replaceMotiveCommandHandler = $this->prophesize(ReplaceMotiveCommandHandler::class); + $replaceMotiveCommandHandler->handle($ticket, $command)->shouldBeCalled(); + + // Mock Validator + $validator = $this->prophesize(ValidatorInterface::class); + $validator->validate($command) + ->shouldBeCalled() + ->willReturn(new ConstraintViolationList([])); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->deserialize($body, ReplaceMotiveCommand::class, 'json', [AbstractNormalizer::GROUPS => ['write']]) + ->willReturn($command); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->shouldBeCalled() + ->willReturn('{"type": "ticket", "id": 1}'); + + return new ReplaceMotiveController( + $security->reveal(), + $replaceMotiveCommandHandler->reveal(), + $serializer->reveal(), + $validator->reveal(), + $entityManager->reveal() + ); } - protected function tearDown(): void - { - self::ensureKernelShutdown(); - } - - /** - * @dataProvider generateMotiveId - */ - public function testAddValidMotive(int $motiveId): void + public function testAddValidMotive(): void { $ticket = new Ticket(); - $payload = <<buildController(); + $controller = $this->buildController($ticket, $payload, $motive); $response = $controller($ticket, $request); self::assertEquals(201, $response->getStatusCode()); } - - public static function generateMotiveId(): iterable - { - self::bootKernel(); - $em = self::getContainer()->get(EntityManagerInterface::class); - - $motive = $em->createQuery('SELECT m FROM '.Motive::class.' m ') - ->setMaxResults(1) - ->getOneOrNullResult(); - - if (null === $motive) { - throw new \RuntimeException('the motive table seems to be empty'); - } - - self::ensureKernelShutdown(); - - yield [$motive->getId()]; - } - - private function buildController(): ReplaceMotiveController - { - $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); - - $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled(); - $entityManager->flush()->shouldBeCalled(); - - $changeEmergencyCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); - $changeEmergencyCommandHandler->__invoke(Argument::any(), Argument::any())->shouldBeCalled() - ->will(fn (array $args) => $args[0]); - - $handler = new ReplaceMotiveCommandHandler( - new MockClock(), - $entityManager->reveal(), - $changeEmergencyCommandHandler->reveal(), - ); - - return new ReplaceMotiveController( - $security->reveal(), - $handler, - $this->serializer, - $this->validator, - $entityManager->reveal(), - ); - } }