Add UserGroup and UserJob synchronization feature

Implement UserGroupRelatedToUserJobSync to manage associations between UserGroups and UserJobs, including creating, updating, and removing relationships. Introduce a cron job to automate the synchronization process and add tests to ensure functionality. Update translations and repository logic as part of the implementation.
This commit is contained in:
Julien Fastré 2025-04-25 13:07:52 +02:00
parent d506409d93
commit fb1c34f9c1
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
7 changed files with 256 additions and 9 deletions

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityManagerInterface;
@ -53,12 +54,20 @@ readonly class UserJobRepository implements UserJobRepositoryInterface
return $jobs;
}
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return array|object[]|UserJob[]
*/
public function findAllNotAssociatedWithUserGroup(): array
{
$qb = $this->repository->createQueryBuilder('u');
$qb->select('u');
$qb->where(
$qb->expr()->not(
$qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug.userJob = u', UserGroup::class))
)
);
return $qb->getQuery()->getResult();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);

View File

@ -33,11 +33,14 @@ interface UserJobRepositoryInterface extends ObjectRepository
public function findAllOrderedByName(): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
* Find all the user job which are not related to a UserGroup.
*
* @return array|object[]|UserJob[]
* This is useful for synchronizing UserGroups with jobs.
*
* @return list<UserJob>
*/
public function findAllNotAssociatedWithUserGroup(): array;
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null);
public function findOneBy(array $criteria): ?UserJob;

View File

@ -0,0 +1,114 @@
<?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\MainBundle\Service\UserGroup;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class UserGroupRelatedToUserJobSync implements UserGroupRelatedToUserJobSyncInterface
{
private const LOG_PREFIX = '[UserGroupRelatedToUserJobSync] ';
public function __construct(
private UserJobRepositoryInterface $userJobRepository,
private EntityManagerInterface $entityManager,
private TranslatorInterface $translator,
private TranslatableStringHelperInterface $translatableStringHelper,
private LoggerInterface $logger,
) {}
public function __invoke(): array
{
$created = $this->createNotExistingUserGroups();
$connection = $this->entityManager->getConnection();
$stats = $connection->transactional(function (Connection $connection) {
$removed = $this->removeUserNotRelatedToJob($connection);
$created = $this->createNewAssociations($connection);
return ['association_removed' => $removed, 'association_created' => $created];
});
$fullStats = ['userjob_created' => $created, ...$stats];
$this->logger->info(self::LOG_PREFIX.'Executed synchronisation', $fullStats);
return $fullStats;
}
private function createNotExistingUserGroups(): int
{
$jobs = $this->userJobRepository->findAllNotAssociatedWithUserGroup();
$counter = 0;
foreach ($jobs as $job) {
$userGroup = new UserGroup();
$userGroup->setUserJob($job);
$userGroup->setLabel(
[
$this->translator->getLocale() => $this->translator->trans(
'user_group.label_related_to_user_job',
['job' => $this->translatableStringHelper->localize($job->getLabel())]
)]
);
$userGroup->setBackgroundColor('#e5a50a')->setForegroundColor('#f6f5f4');
$this->entityManager->persist($userGroup);
$this->logger->info(self::LOG_PREFIX.'Will create user group', ['job' => $this->translatableStringHelper->localize($userGroup->getLabel())]);
++$counter;
}
$this->entityManager->flush();
return $counter;
}
private function removeUserNotRelatedToJob(Connection $connection): int
{
$sql = <<<'SQL'
DELETE FROM chill_main_user_group_user
USING users AS u, chill_main_user_group ug, chill_main_user_job_history jh
WHERE
chill_main_user_group_user.usergroup_id = ug.id
AND chill_main_user_group_user.user_id = u.id
AND jh.user_id = u.id AND tsrange(jh.startdate, jh.enddate) @> localtimestamp
-- only when the user's jobid is different than the user_group id
AND ug.userjob_id IS NOT NULL
AND jh.job_id <> ug.userjob_id
SQL;
$result = $connection->executeQuery($sql);
return $result->rowCount();
}
private function createNewAssociations(Connection $connection): int
{
$sql = <<<'SQL'
INSERT INTO chill_main_user_group_user (usergroup_id, user_id)
SELECT cmug.id, jh.user_id
FROM chill_main_user_group cmug
JOIN chill_main_user_job_history jh ON jh.job_id = cmug.userjob_id AND tsrange(jh.startdate, jh.enddate) @> localtimestamp
WHERE cmug.userjob_id IS NOT NULL
ON CONFLICT DO NOTHING
SQL;
$result = $connection->executeQuery($sql);
return $result->rowCount();
}
}

View File

@ -0,0 +1,42 @@
<?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\MainBundle\Service\UserGroup;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Symfony\Component\Clock\ClockInterface;
final readonly class UserGroupRelatedToUserJobSyncCronJob implements CronJobInterface
{
private const KEY = 'user-group-related-to-user-job-sync';
public function __construct(private ClockInterface $clock, private UserGroupRelatedToUserJobSyncInterface $userJobSync) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
return $cronJobExecution->getLastStart() < $this->clock->now()->sub(new \DateInterval('P1D'));
}
public function getKey(): string
{
return self::KEY;
}
public function run(array $lastExecutionData): ?array
{
return ($this->userJobSync)();
}
}

View File

@ -0,0 +1,20 @@
<?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\MainBundle\Service\UserGroup;
interface UserGroupRelatedToUserJobSyncInterface
{
/**
* @return array{userjob_created: int, association_removed: int, association_created: int}
*/
public function __invoke(): array;
}

View File

@ -0,0 +1,58 @@
<?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\MainBundle\Tests\Services\UserGroup;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Service\UserGroup\UserGroupRelatedToUserJobSyncCronJob;
use Chill\MainBundle\Service\UserGroup\UserGroupRelatedToUserJobSyncInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
/**
* @internal
*
* @coversNothing
*/
class UserGroupRelatedToUserJobSyncCronJobTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider canRunDataProvider
*/
public function testCanRun(\DateTimeImmutable $now, ?\DateTimeImmutable $lastStartExecution, bool $exected): void
{
$clock = new MockClock($now);
$job = $this->prophesize(UserGroupRelatedToUserJobSyncInterface::class);
$cronJob = new UserGroupRelatedToUserJobSyncCronJob($clock, $job->reveal());
if (null !== $lastStartExecution) {
$lastExecution = new CronJobExecution('user-group-related-to-user-job-sync');
$lastExecution->setLastStart($lastStartExecution);
}
$actual = $cronJob->canRun($lastExecution ?? null);
self::assertEquals($exected, $actual);
}
public static function canRunDataProvider(): iterable
{
$now = new \DateTimeImmutable('2025-04-27T00:00:00Z');
yield 'never executed' => [$now, null, true];
yield 'executed 12 hours ago' => [$now, new \DateTimeImmutable('2025-04-26T12:00:00Z'), false];
yield 'executed more than 12 hours ago' => [$now, new \DateTimeImmutable('2025-04-25T12:00:00Z'), true];
}
}

View File

@ -15,6 +15,7 @@ user_group:
}
user_removed: L'utilisateur {user} est enlevé du groupe {user_group} avec succès
user_added: L'utilisateur {user} est ajouté groupe {user_group} avec succès
label_related_to_user_job: Groupe {job} (Groupe métier)
notification:
My notifications with counter: >-