diff --git a/composer.json b/composer.json index b47856954..f6d3eb27a 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "sensio/framework-extra-bundle": "^5.5", "spomky-labs/base64url": "^2.0", "symfony/browser-kit": "^4.4", + "symfony/clock": "^6.2", "symfony/css-selector": "^4.4", "symfony/expression-language": "^4.4", "symfony/form": "^4.4", diff --git a/src/Bundle/ChillMainBundle/config/services.yaml b/src/Bundle/ChillMainBundle/config/services.yaml index 54153ccfb..5976cb8b0 100644 --- a/src/Bundle/ChillMainBundle/config/services.yaml +++ b/src/Bundle/ChillMainBundle/config/services.yaml @@ -1,6 +1,9 @@ parameters: # cl_chill_main.example.class: Chill\MainBundle\Example +imports: + - ./services/clock.yaml + services: _defaults: autowire: true diff --git a/src/Bundle/ChillMainBundle/config/services/clock.yaml b/src/Bundle/ChillMainBundle/config/services/clock.yaml new file mode 100644 index 000000000..0629cd869 --- /dev/null +++ b/src/Bundle/ChillMainBundle/config/services/clock.yaml @@ -0,0 +1,4 @@ +# temporary, waiting for symfony 6.0 to load clock +services: + Symfony\Component\Clock\NativeClock: ~ + Symfony\Component\Clock\ClockInterface: '@Symfony\Component\Clock\NativeClock' diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeCronjob.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeCronjob.php new file mode 100644 index 000000000..bec31d45d --- /dev/null +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeCronjob.php @@ -0,0 +1,46 @@ +clock->now(); + + if ($now->sub(new \DateInterval('P1D')) < $cronJobExecution->getLastStart()) { + return false; + } + + return in_array((int) $now->format('H'), [1, 2, 3, 4, 5, 6], true); + } + + public function getKey(): string + { + return 'accompanying-period-step-change'; + } + + public function run(): void + { + ($this->requestor)(); + } +} diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeMessageHandler.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeMessageHandler.php new file mode 100644 index 000000000..881b3999a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeMessageHandler.php @@ -0,0 +1,38 @@ +accompanyingPeriodRepository->find($message->getPeriodId())) { + throw new \RuntimeException(self::LOG_PREFIX . 'Could not find period with this id: '. $message->getPeriodId()); + } + + ($this->changer)($period, $message->getTransition()); + } + +} diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeRequestMessage.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeRequestMessage.php new file mode 100644 index 000000000..457e78d35 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeRequestMessage.php @@ -0,0 +1,47 @@ +periodId = $period; + } else { + if (null !== $id = $period->getId()) { + $this->periodId = $id; + } + + throw new \LogicException("This AccompanyingPeriod does not have and id yet"); + } + } + + public function getPeriodId(): int + { + return $this->periodId; + } + + public function getTransition(): string + { + return $this->transition; + } +} diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeRequestor.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeRequestor.php new file mode 100644 index 000000000..3d4979608 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeRequestor.php @@ -0,0 +1,88 @@ +get('chill_person')['accompanying_period_lifecycle_delays']; + $this->isMarkInactive = $config['mark_inactive']; + $this->intervalForShortInactive = new \DateInterval($config['mark_inactive_short_after']); + $this->intervalForLongInactive = new \DateInterval($config['mark_inactive_long_after']); + } + + public function __invoke(): void + { + if (!$this->isMarkInactive) { + return; + } + + // get the oldest ones first + foreach ( + $olders = $this->accompanyingPeriodInfoRepository->findAccompanyingPeriodIdInactiveAfter( + $this->intervalForLongInactive, + [AccompanyingPeriod::STEP_CONFIRMED, AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT] + ) as $accompanyingPeriodId + ) { + $this->logger->debug('request mark period as inactive_short', ['period' => $accompanyingPeriodId]); + $this->messageBus->dispatch(new AccompanyingPeriodStepChangeRequestMessage($accompanyingPeriodId, 'mark_inactive_long')); + } + + // the newest + foreach ( + $this->accompanyingPeriodInfoRepository->findAccompanyingPeriodIdInactiveAfter( + $this->intervalForShortInactive, + [AccompanyingPeriod::STEP_CONFIRMED] + ) as $accompanyingPeriodId + ) { + if (in_array($accompanyingPeriodId, $olders, true)) { + continue; + } + + $this->logger->debug('request mark period as inactive_long', ['period' => $accompanyingPeriodId]); + $this->messageBus->dispatch(new AccompanyingPeriodStepChangeRequestMessage($accompanyingPeriodId, 'mark_inactive_short')); + } + + // a new event has been created => remove inactive long, or short + foreach ( + $this->accompanyingPeriodInfoRepository->findAccompanyingPeriodIdActiveSince( + $this->intervalForShortInactive, + [AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT, AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_LONG] + ) as $accompanyingPeriodId + ) { + $this->logger->debug('request mark period as active', ['period' => $accompanyingPeriodId]); + $this->messageBus->dispatch(new AccompanyingPeriodStepChangeRequestMessage($accompanyingPeriodId, 'mark_active')); + } + } + +} diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChanger.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChanger.php new file mode 100644 index 000000000..05dfee6db --- /dev/null +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChanger.php @@ -0,0 +1,58 @@ +workflowRegistry->get($period, $workflowName); + + if (!$workflow->can($period, $transition)) { + $this->logger->info(self::LOG_PREFIX . 'not able to apply the transition on period', [ + 'period_id' => $period->getId(), + 'transition' => $transition + ]); + + return; + } + + $workflow->apply($period, $transition); + + $this->entityManager->flush(); + + $this->logger->info(self::LOG_PREFIX . 'could apply a transition', [ + 'period_id' => $period->getId(), + 'transition' => $transition + ]); + } +} diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 6a2dc924e..569dd1502 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -15,6 +15,7 @@ use Chill\MainBundle\DependencyInjection\MissingBundleException; use Chill\MainBundle\Security\Authorization\ChillExportVoter; use Chill\PersonBundle\Controller\HouseholdCompositionTypeApiController; use Chill\PersonBundle\Doctrine\DQL\AddressPart; +use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodCommentVoter; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodResourceVoter; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; @@ -1010,18 +1011,42 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac ], 'initial_marking' => 'DRAFT', 'places' => [ - 'DRAFT', - 'CONFIRMED', - 'CLOSED', + AccompanyingPeriod::STEP_DRAFT, + AccompanyingPeriod::STEP_CONFIRMED, + AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT, + AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_LONG, + AccompanyingPeriod::STEP_CLOSED, ], 'transitions' => [ 'confirm' => [ - 'from' => 'DRAFT', - 'to' => 'CONFIRMED', + 'from' => AccompanyingPeriod::STEP_DRAFT, + 'to' => AccompanyingPeriod::STEP_CONFIRMED, + ], + 'mark_inactive_short' => [ + 'from' => AccompanyingPeriod::STEP_CONFIRMED, + 'to' => AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT, + ], + 'mark_inactive_long' => [ + 'from' => [ + AccompanyingPeriod::STEP_CONFIRMED, + AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT + ], + 'to' => AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_LONG, + ], + 'mark_active' => [ + 'from' => [ + AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_LONG, + AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT, + ], + 'to' => AccompanyingPeriod::STEP_CONFIRMED ], 'close' => [ - 'from' => 'CONFIRMED', - 'to' => 'CLOSED', + 'from' => [ + AccompanyingPeriod::STEP_CONFIRMED, + AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT, + AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_LONG, + ], + 'to' => AccompanyingPeriod::STEP_CLOSED, ], ], ], diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php index a591663ad..64ca07066 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php @@ -128,6 +128,15 @@ class Configuration implements ConfigurationInterface ->info('Can we have more than one simultaneous accompanying period in the same time. Default false.') ->defaultValue(false) ->end() + ->arrayNode('accompanying_period_lifecycle_delays') + ->addDefaultsIfNotSet() + ->info('Delays before marking an accompanying period as inactive') + ->children() + ->booleanNode('mark_inactive')->defaultTrue()->end() + ->scalarNode('mark_inactive_short_after')->defaultValue('P6M')->end() + ->scalarNode('mark_inactive_long_after')->defaultValue('P2Y')->end() + ->end() + ->end() // end of 'accompanying_period_lifecycle_delays ->end() // children of 'root', parent = root ; diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index f7eeec28e..a724596a9 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -116,7 +116,16 @@ class AccompanyingPeriod implements * confirmed, but no activity (Activity, AccompanyingPeriod, ...) * has been associated, or updated, within this accompanying period. */ - public const STEP_CONFIRMED_INACTIVE = 'CONFIRMED_INACTIVE'; + public const STEP_CONFIRMED_INACTIVE_SHORT = 'CONFIRMED_INACTIVE_SHORT'; + + /** + * Mark an accompanying period as confirmed, but inactive + * + * this means that the accompanying period **is** + * confirmed, but no activity (Activity, AccompanyingPeriod, ...) + * has been associated, or updated, within this accompanying period. + */ + public const STEP_CONFIRMED_INACTIVE_LONG = 'CONFIRMED_INACTIVE_LONG'; /** * Mark an accompanying period as "draft". diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodInfoRepository.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodInfoRepository.php new file mode 100644 index 000000000..57925b131 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodInfoRepository.php @@ -0,0 +1,93 @@ +entityRepository = $em->getRepository($this->getClassName()); + } + + public function findAccompanyingPeriodIdInactiveAfter(DateInterval $interval, array $statuses = []): array + { + $query = $this->em->createQuery(); + $baseDql = 'SELECT DISTINCT IDENTITY(ai.accompanyingPeriod) FROM '.AccompanyingPeriodInfo::class.' ai JOIN ai.accompanyingPeriod a WHERE NOT EXISTS + (SELECT 1 FROM ' . AccompanyingPeriodInfo::class . ' aiz WHERE aiz.infoDate > :after AND IDENTITY(aiz.accompanyingPeriod) = IDENTITY(ai.accompanyingPeriod))'; + + if ([] !== $statuses) { + $dql = $baseDql . ' AND a.step IN (:statuses)'; + $query->setParameter('statuses', $statuses); + } else { + $dql = $baseDql; + } + + return $query->setDQL($dql) + ->setParameter('after', $this->clock->now()->sub($interval)) + ->getSingleColumnResult(); + } + + public function findAccompanyingPeriodIdActiveSince(DateInterval $interval, array $statuses = []): array + { + $query = $this->em->createQuery(); + $baseDql = 'SELECT DISTINCT IDENTITY(ai.accompanyingPeriod) FROM ' . AccompanyingPeriodInfo::class . ' ai + JOIN ai.accompanyingPeriod a WHERE ai.infoDate > :after'; + + if ([] !== $statuses) { + $dql = $baseDql . ' AND a.step IN (:statuses)'; + $query->setParameter('statuses', $statuses); + } else { + $dql = $baseDql; + } + + return $query->setDQL($dql) + ->setParameter('after', $this->clock->now()->sub($interval)) + ->getSingleColumnResult(); + } + + public function find($id): ?AccompanyingPeriodInfo + { + throw new LogicException("Calling an accompanying period info by his id does not make sense"); + } + + public function findAll(): array + { + return $this->entityRepository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?AccompanyingPeriodInfo + { + return $this->entityRepository->findOneBy($criteria); + } + + public function getClassName(): string + { + return AccompanyingPeriodInfo::class; + } + +} diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodInfoRepositoryInterface.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodInfoRepositoryInterface.php new file mode 100644 index 000000000..07397cd48 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodInfoRepositoryInterface.php @@ -0,0 +1,38 @@ + + */ +interface AccompanyingPeriodInfoRepositoryInterface extends ObjectRepository +{ + /** + * Return a list of id for inactive accompanying periods + * + * @param \DateInterval $interval + * @param list $statuses + * @return list + */ + public function findAccompanyingPeriodIdInactiveAfter(\DateInterval $interval, array $statuses = []): array; + + /** + * @param \DateInterval $interval + * @param list $statuses + * @return list + */ + public function findAccompanyingPeriodIdActiveSince(\DateInterval $interval, array $statuses = []): array; +} diff --git a/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeCronjobTest.php b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeCronjobTest.php new file mode 100644 index 000000000..5c3c29179 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Lifecycle/AccompanyingPeriodStepChangeCronjobTest.php @@ -0,0 +1,55 @@ +prophesize(AccompanyingPeriodStepChangeRequestor::class); + $clock = new MockClock($datetime); + + $cronJob = new AccompanyingPeriodStepChangeCronjob($clock, $requestor->reveal()); + $cronJobExecution = (new CronJobExecution($cronJob->getKey()))->setLastStart($lastExecutionStart); + + $this->assertEquals($canRun, $cronJob->canRun($cronJobExecution)); + } + + public function provideRunTimes(): iterable + { + // can run, during the night + yield ['2023-01-15T01:00:00+02:00', new \DateTimeImmutable('2023-01-14T00:00:00+02:00'), true]; + + // can not run, not during the night + yield ['2023-01-15T10:00:00+02:00', new \DateTimeImmutable('2023-01-14T00:00:00+02:00'), false]; + + // can not run: not enough elapsed time + yield ['2023-01-15T01:00:00+02:00', new \DateTimeImmutable('2023-01-15T00:30:00+02:00'), false]; + } + +} diff --git a/src/Bundle/ChillPersonBundle/config/services/accompanyingPeriod.yaml b/src/Bundle/ChillPersonBundle/config/services/accompanyingPeriod.yaml index 7d7f9072d..e6b35de77 100644 --- a/src/Bundle/ChillPersonBundle/config/services/accompanyingPeriod.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/accompanyingPeriod.yaml @@ -25,6 +25,11 @@ services: autowire: true autoconfigure: true + Chill\PersonBundle\AccompanyingPeriod\Lifecycle\: + resource: './../../AccompanyingPeriod/Lifecycle' + autowire: true + autoconfigure: true + Chill\PersonBundle\AccompanyingPeriod\Events\UserRefEventSubscriber: autowire: true autoconfigure: true