Merge remote-tracking branch 'origin/master' into upgrade-sf5

This commit is contained in:
Julien Fastré 2024-04-04 18:45:01 +02:00
commit 931c69eee4
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
162 changed files with 3849 additions and 713 deletions

3
.changes/v2.16.2.md Normal file
View File

@ -0,0 +1,3 @@
## v2.16.2 - 2024-02-21
### Fixed
* Check for null values in closing motive of parcours d'accompagnement for correct rendering of template

5
.changes/v2.16.3.md Normal file
View File

@ -0,0 +1,5 @@
## v2.16.3 - 2024-02-26
### Fixed
* ([#236](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/236)) Fix translation of user job -> 'service' must be 'métier'
### UX
* ([#232](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/232)) Order user jobs and services alphabetically in export filters

9
.changes/v2.17.0.md Normal file
View File

@ -0,0 +1,9 @@
## v2.17.0 - 2024-03-19
### Feature
* ([#237](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/237)) New export filter for social actions with an evaluation created between two dates
* ([#258](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/258)) In the list of accompangying period, add the list of person's centers and the duration of the course
* ([#238](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/238)) Allow to customize list person with new fields
* ([#159](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/159)) Admin can publish news on the homepage
### Fixed
* ([#264](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/264)) Fix languages: load the languages in all availables languages configured for Chill
* ([#259](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/259)) Keep a consistent behaviour between the filtering of activities within the document generation (model "accompanying period with activities"), and the same filter in the list of activities for an accompanying period

5
.changes/v2.18.0.md Normal file
View File

@ -0,0 +1,5 @@
## v2.18.0 - 2024-03-26
### Feature
* ([#268](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/268)) Improve admin UX to configure document templates for document generation
### Fixed
* ([#267](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/267)) Fix the join between job and user in the user list (admin): show only the current user job

3
.changes/v2.18.1.md Normal file
View File

@ -0,0 +1,3 @@
## v2.18.1 - 2024-03-26
### Fixed
* Fix layout issue in document generation for admin (minor)

View File

@ -23,3 +23,7 @@ max_line_length = 0
indent_size = 2
indent_style = space
[.rst]
ident_size = 3
ident_style = space

View File

@ -91,7 +91,7 @@ rector_tests:
stage: Tests
image: gitea.champs-libres.be/chill-project/chill-skeleton-basic/base-image:php82
script:
- tests/console cache:clear --env=test
- tests/console cache:clear
- bin/rector process --dry-run
cache:
paths:

View File

@ -6,6 +6,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.18.1 - 2024-03-26
### Fixed
* Fix layout issue in document generation for admin (minor)
## v2.18.0 - 2024-03-26
### Feature
* ([#268](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/268)) Improve admin UX to configure document templates for document generation
### Fixed
* ([#267](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/267)) Fix the join between job and user in the user list (admin): show only the current user job
## v2.17.0 - 2024-03-19
### Feature
* ([#237](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/237)) New export filter for social actions with an evaluation created between two dates
* ([#258](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/258)) In the list of accompangying period, add the list of person's centers and the duration of the course
* ([#238](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/238)) Allow to customize list person with new fields
* ([#159](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/159)) Admin can publish news on the homepage
### Fixed
* ([#264](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/264)) Fix languages: load the languages in all availables languages configured for Chill
* ([#259](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/259)) Keep a consistent behaviour between the filtering of activities within the document generation (model "accompanying period with activities"), and the same filter in the list of activities for an accompanying period
## v2.16.3 - 2024-02-26
### Fixed
* ([#236](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/236)) Fix translation of user job -> 'service' must be 'métier'
### UX
* ([#232](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/232)) Order user jobs and services alphabetically in export filters
## v2.16.2 - 2024-02-21
### Fixed
* Check for null values in closing motive of parcours d'accompagnement for correct rendering of template
## v2.16.1 - 2024-02-09
### Fixed
* Force bootstrap version to avoid error in builds with newer version

View File

@ -242,3 +242,129 @@ This is an example of the *filter by birthdate*. This filter asks some informati
Continue to explain the export framework
.. _main bundle: https://git.framasoft.org/Chill-project/Chill-Main
With many-to-* relationship, why should we set WHERE clauses in an EXISTS subquery instead of a JOIN ?
``````````````````````````````````````````````````````````````````````````````````````````````````````
As we described above, the doctrine builder is converted into a sql query. Let's see how to compute the "number of course
which count at least one activity type with the id 7". For the purpose of this demonstration, we will restrict this on
two accompanying period only: the ones with id 329 and 334.
Let's see the list of activities associated with those accompanying period:
.. code-block:: sql
SELECT id, accompanyingperiod_id, type_id FROM activity WHERE accompanyingperiod_id IN (329, 334) AND type_id = 7
ORDER BY accompanyingperiod_id;
We see that we have 6 activities for the accompanying period with id 329, and only one for the 334's one.
.. csv-table::
:header: id, accompanyingperiod_id, type_id
990,329,7
986,329,7
987,329,7
993,329,7
991,329,7
992,329,7
1000,334,7
Let's calculate the average duration for those accompanying periods, and the number of period:
.. code-block:: sql
SELECT AVG(age(COALESCE(closingdate, CURRENT_DATE), openingdate)), COUNT(id) from chill_person_accompanying_period WHERE id IN (329, 334);
The result of this query is:
.. csv-table::
:header: AVG, COUNT
2 years 2 mons 21 days 12 hours 0 mins 0.0 secs,2
Now, we count the number of accompanying period, adding a :code:`JOIN` clause which make a link to the :code:`activity` table, and add a :code:`WHERE` clause to keep
only the accompanying period which contains the given activity type:
.. code-block:: sql
SELECT COUNT(chill_person_accompanying_period.id) from chill_person_accompanying_period
JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id
WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7;
What are the results here ?
.. csv-table::
:header: COUNT
7
:code:`7` ! Why this result ? Because the number of lines is duplicated for each activity. Let's see the list of rows which
are taken into account for the computation:
.. code-block:: sql
SELECT chill_person_accompanying_period.id, activity.id from chill_person_accompanying_period
JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id
WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7;
.. csv-table::
:header: accompanyingperiod.id, activity.id
329,993
334,1000
329,987
329,990
329,991
329,992
329,986
For each activity, a row is created and, as we count the number of non-null :code:`accompanyingperiod.id` columns, we
count one entry for each activity (actually, we count the number of activities).
So, let's use the :code:`DISTINCT` keyword to count only once the equal ids:
.. code-block::
SELECT COUNT(DISTINCT chill_person_accompanying_period.id) from chill_person_accompanying_period
JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id
WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7;
Now, it works again...
.. csv-table::
:header: COUNT
2
But, for the average duration, this won't work: the duration which are equals (because the :code:`openingdate` is the same and
:code:`closingdate` is still :code:`NULL`, for instance) will be counted only once, which will give unexpected result.
The solution is to move the condition "having an activity with activity type with id 7" in a :code:`EXISTS` clause:
.. code-block:: sql
SELECT COUNT(chill_person_accompanying_period.id) from chill_person_accompanying_period
WHERE chill_person_accompanying_period.id IN (329, 334) AND EXISTS (SELECT 1 FROM activity WHERE type_id = 7 AND accompanyingperiod_id = chill_person_accompanying_period.id);
The result is correct without :code:`DISTINCT` keyword:
.. csv-table::
:header: COUNT
2
And we can now compute the average duration without fear:
.. code-block:: sql
SELECT AVG(age(COALESCE(closingdate, CURRENT_DATE), openingdate)) from chill_person_accompanying_period
WHERE chill_person_accompanying_period.id IN (329, 334) AND EXISTS (SELECT 1 FROM activity WHERE type_id = 7 AND accompanyingperiod_id = chill_person_accompanying_period.id);
Give the result:
.. csv-table::
:header: AVG
2 years 2 mons 21 days 12 hours 0 mins 0.0 secs

View File

@ -79,7 +79,7 @@ final readonly class CreatorJobFilter implements FilterInterface
{
$builder
->add('jobs', EntityType::class, [
'choices' => $this->userJobRepository->findAllOrderedByName(),
'choices' => $this->userJobRepository->findAllActive(),
'class' => UserJob::class,
'choice_label' => fn (UserJob $s) => $this->translatableStringHelper->localize(
$s->getLabel()

View File

@ -15,6 +15,7 @@ use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User\UserScopeHistory;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@ -26,7 +27,8 @@ class CreatorScopeFilter implements FilterInterface
private const PREFIX = 'acp_act_filter_creator_scope';
public function __construct(
private readonly TranslatableStringHelper $translatableStringHelper
private readonly TranslatableStringHelper $translatableStringHelper,
private readonly ScopeRepositoryInterface $scopeRepository,
) {}
public function addRole(): ?string
@ -75,6 +77,7 @@ class CreatorScopeFilter implements FilterInterface
$builder
->add('scopes', EntityType::class, [
'class' => Scope::class,
'choices' => $this->scopeRepository->findAllActive(),
'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize(
$s->getName()
),

View File

@ -16,6 +16,7 @@ use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\QueryBuilder;
@ -27,8 +28,10 @@ class UsersJobFilter implements FilterInterface
private const PREFIX = 'act_filter_user_job';
public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper
) {}
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly UserJobRepositoryInterface $userJobRepository
) {
}
public function addRole(): ?string
{
@ -68,6 +71,7 @@ class UsersJobFilter implements FilterInterface
$builder
->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()),
'multiple' => true,
'expanded' => true,

View File

@ -95,7 +95,7 @@ class ActivityType extends AbstractType
]);
}
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod|null $accompanyingPeriod */
/** @var AccompanyingPeriod|null $accompanyingPeriod */
$accompanyingPeriod = null;
if ($options['accompanyingPeriod'] instanceof AccompanyingPeriod) {

View File

@ -242,7 +242,8 @@ final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepos
thirdparties.thirdpartyids,
persons.personids,
actions.socialactionids,
issues.socialissueids
issues.socialissueids,
a.user_id
FROM activity a
LEFT JOIN chill_main_location location ON a.location_id = location.id
@ -282,6 +283,7 @@ final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepos
->addJoinedEntityResult(ActivityPresence::class, 'activityPresence', 'a', 'attendee')
->addFieldResult('activityPresence', 'presence_id', 'id')
->addFieldResult('activityPresence', 'presence_name', 'name')
->addScalarResult('user_id', 'userId', Types::INTEGER)
// results which cannot be mapped into entity
->addScalarResult('comment_comment', 'comment', Types::TEXT)

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Service\DocGenerator;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Entity\ActivityPresence;
use Chill\ActivityBundle\Entity\ActivityType;
use Chill\ActivityBundle\Repository\ActivityACLAwareRepositoryInterface;
@ -111,7 +112,7 @@ class ListActivitiesByAccompanyingPeriodContext implements
}
/**
* @return list
* @return list<Activity>
*/
private function filterActivitiesByUser(array $activities, User $user): array
{
@ -119,6 +120,12 @@ class ListActivitiesByAccompanyingPeriodContext implements
array_filter(
$activities,
function ($activity) use ($user) {
$u = $activity['user'];
if (null !== $u && $u['username'] === $user->getUsername()) {
return true;
}
$activityUsernames = array_map(static fn ($user) => $user['username'], $activity['users'] ?? []);
return \in_array($user->getUsername(), $activityUsernames, true);
@ -128,7 +135,7 @@ class ListActivitiesByAccompanyingPeriodContext implements
}
/**
* @return list
* @return list<AccompanyingPeriod\AccompanyingPeriodWork>
*/
private function filterWorksByUser(array $works, User $user): array
{
@ -215,6 +222,15 @@ class ListActivitiesByAccompanyingPeriodContext implements
foreach ($activities as $row) {
$activity = $row[0];
$user = match (null === $row['userId']) {
false => $this->userRepository->find($row['userId']),
true => null,
};
$activity['user'] = $this->normalizer->normalize($user, 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => User::class,
]);
$activity['date'] = $this->normalizer->normalize($activity['date'], 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => \DateTime::class,
]);

View File

@ -91,6 +91,29 @@ class ActivityACLAwareRepositoryTest extends KernelTestCase
self::assertIsArray($actual);
}
/**
* @dataProvider provideDataFindByAccompanyingPeriod
*/
public function testfindByAccompanyingPeriodSimplified(AccompanyingPeriod $period, User $user, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): void
{
$security = $this->prophesize(Security::class);
$security->isGranted($role, $period)->willReturn(true);
$security->getUser()->willReturn($user);
$repository = new ActivityACLAwareRepository(
$this->authorizationHelperForCurrentUser,
$this->centerResolverManager,
$this->activityRepository,
$this->entityManager,
$security->reveal(),
$this->requestStack
);
$actual = $repository->findByAccompanyingPeriodSimplified($period);
self::assertIsArray($actual);
}
/**
* @dataProvider provideDataFindByAccompanyingPeriod
*/
@ -301,7 +324,10 @@ class ActivityACLAwareRepositoryTest extends KernelTestCase
->getQuery()
->getResult()
) {
throw new \RuntimeException('no jobs found');
$job = new UserJob();
$job->setLabel(['fr' => 'test']);
$this->entityManager->persist($job);
$this->entityManager->flush();
}
if (null === $user = $this->entityManager

View File

@ -0,0 +1,139 @@
<?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\ActivityBundle\Tests\Service\DocGenerator;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Service\DocGenerator\ListActivitiesByAccompanyingPeriodContext;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class ListActivitiesByAccompanyingPeriodContextTest extends KernelTestCase
{
private ListActivitiesByAccompanyingPeriodContext $listActivitiesByAccompanyingPeriodContext;
private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private UserRepositoryInterface $userRepository;
protected function setUp(): void
{
self::bootKernel();
$this->listActivitiesByAccompanyingPeriodContext = self::$container->get(ListActivitiesByAccompanyingPeriodContext::class);
$this->accompanyingPeriodRepository = self::$container->get(AccompanyingPeriodRepository::class);
$this->userRepository = self::$container->get(UserRepositoryInterface::class);
}
/**
* @dataProvider provideAccompanyingPeriod
*/
public function testGetDataWithoutFilteringActivityNorWorks(int $accompanyingPeriodId, int $userId): void
{
$context = $this->getContext();
$template = new DocGeneratorTemplate();
$template->setOptions([
'mainPerson' => false,
'person1' => false,
'person2' => false,
'thirdParty' => false,
]);
$data = $context->getData(
$template,
$this->accompanyingPeriodRepository->find($accompanyingPeriodId),
['myActivitiesOnly' => false, 'myWorksOnly' => false]
);
self::assertIsArray($data);
self::assertArrayHasKey('activities', $data);
self::assertIsArray($data['activities']);
self::assertGreaterThan(0, count($data['activities']));
self::assertIsArray($data['activities'][0]);
self::assertArrayHasKey('user', $data['activities'][0]);
self::assertIsArray($data['activities'][0]['user']);
}
/**
* @dataProvider provideAccompanyingPeriod
*/
public function testGetDataWithoutFilteringActivityByUser(int $accompanyingPeriodId, int $userId): void
{
$context = $this->getContext();
$template = new DocGeneratorTemplate();
$template->setOptions([
'mainPerson' => false,
'person1' => false,
'person2' => false,
'thirdParty' => false,
]);
$data = $context->getData(
$template,
$this->accompanyingPeriodRepository->find($accompanyingPeriodId),
['myActivitiesOnly' => true, 'myWorksOnly' => false, 'creator' => $this->userRepository->find($userId)]
);
self::assertIsArray($data);
self::assertArrayHasKey('activities', $data);
self::assertIsArray($data['activities']);
self::assertGreaterThan(0, count($data['activities']));
self::assertIsArray($data['activities'][0]);
self::assertArrayHasKey('user', $data['activities'][0]);
self::assertIsArray($data['activities'][0]['user']);
}
public static function provideAccompanyingPeriod(): array
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
if (null === $period = $em->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a')
->setMaxResults(1)
->getSingleResult()) {
throw new \RuntimeException('no period found');
}
if (null === $user = $em->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)
->getSingleResult()
) {
throw new \RuntimeException('no user found');
}
$activity = new Activity();
$activity
->setAccompanyingPeriod($period)
->setUser($user)
->setDate(new \DateTime());
$em->persist($activity);
$em->flush();
self::ensureKernelShutdown();
return [
[$period->getId(), $user->getId()],
];
}
private function getContext(): ListActivitiesByAccompanyingPeriodContext
{
return $this->listActivitiesByAccompanyingPeriodContext;
}
}

View File

@ -396,7 +396,7 @@ export:
by_creator_job:
job_form_label: Métiers
Filter activity by user job: Filtrer les échanges par métier du créateur de l'échange
'Filtered activity by user job: only %jobs%': "Filtré par service du créateur de l'échange: uniquement %jobs%"
'Filtered activity by user job: only %jobs%': "Filtré par métier du créateur de l'échange: uniquement %jobs%"
by_persons:
Filter activity by persons: Filtrer les échanges par usager participant
'Filtered activity by persons: only %persons%': 'Échanges filtrés par usagers participants: seulement %persons%'

View File

@ -16,6 +16,7 @@ use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\QueryBuilder;
@ -27,7 +28,8 @@ class ByUserJobFilter implements FilterInterface
private const PREFIX = 'aside_act_filter_user_job';
public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly UserJobRepositoryInterface $userJobRepository
) {}
public function addRole(): ?string
@ -68,6 +70,7 @@ class ByUserJobFilter implements FilterInterface
$builder
->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()),
'multiple' => true,
'expanded' => true,

View File

@ -15,6 +15,7 @@ use Chill\CalendarBundle\Export\Declarations;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@ -26,8 +27,10 @@ final readonly class JobFilter implements FilterInterface
private const PREFIX = 'cal_filter_job';
public function __construct(
private TranslatableStringHelper $translatableStringHelper
) {}
private TranslatableStringHelper $translatableStringHelper,
private UserJobRepositoryInterface $userJobRepository
) {
}
public function addRole(): ?string
{
@ -73,6 +76,7 @@ final readonly class JobFilter implements FilterInterface
$builder
->add('job', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize(
$j->getLabel()
),

View File

@ -15,6 +15,7 @@ use Chill\CalendarBundle\Export\Declarations;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User\UserScopeHistory;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@ -28,8 +29,10 @@ class ScopeFilter implements FilterInterface
public function __construct(
protected TranslatorInterface $translator,
private readonly TranslatableStringHelper $translatableStringHelper
) {}
private readonly TranslatableStringHelper $translatableStringHelper,
private readonly ScopeRepositoryInterface $scopeRepository
) {
}
public function addRole(): ?string
{
@ -75,6 +78,7 @@ class ScopeFilter implements FilterInterface
$builder
->add('scope', EntityType::class, [
'class' => Scope::class,
'choices' => $this->scopeRepository->findAllActive(),
'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize(
$s->getName()
),

View File

@ -32,7 +32,7 @@ final readonly class MSUserAbsenceReader implements MSUserAbsenceReaderInterface
/**
* @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft
*/
public function isUserAbsent(User $user): bool|null
public function isUserAbsent(User $user): ?bool
{
$id = $this->mapCalendarToUser->getUserId($user);

View File

@ -18,5 +18,5 @@ interface MSUserAbsenceReaderInterface
/**
* @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft
*/
public function isUserAbsent(User $user): bool|null;
public function isUserAbsent(User $user): ?bool;
}

View File

@ -16,25 +16,30 @@ use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface;
use Chill\DocGeneratorBundle\Context\Exception\ContextNotFoundException;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorInterface;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\ChillSecurity;
use Chill\MainBundle\Serializer\Model\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
// TODO à mettre dans services
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
final class DocGeneratorTemplateController extends AbstractController
{
@ -45,6 +50,7 @@ final class DocGeneratorTemplateController extends AbstractController
private readonly MessageBusInterface $messageBus,
private readonly PaginatorFactory $paginatorFactory,
private readonly EntityManagerInterface $entityManager,
private readonly ClockInterface $clock,
private readonly ChillSecurity $security
) {}
@ -170,9 +176,7 @@ final class DocGeneratorTemplateController extends AbstractController
throw new NotFoundHttpException(sprintf('Entity with classname %s and id %s is not found', $context->getEntityClass(), $entityId));
}
$contextGenerationData = [
'test_file' => null,
];
$contextGenerationData = [];
if (
$context instanceof DocGeneratorContextWithPublicFormInterface
@ -182,25 +186,39 @@ final class DocGeneratorTemplateController extends AbstractController
$builder = $this->createFormBuilder(
array_merge(
$context->getFormData($template, $entity),
$isTest ? ['test_file' => null, 'show_data' => false] : []
$isTest ? ['creator' => null, 'dump_only' => false, 'send_result_to' => ''] : []
)
);
$context->buildPublicForm($builder, $template, $entity);
} else {
$builder = $this->createFormBuilder(
['test_file' => null, 'show_data' => false]
['creator' => null, 'show_data' => false, 'send_result_to' => '']
);
}
if ($isTest) {
$builder->add('test_file', FileType::class, [
'label' => 'Template file',
$builder->add('dump_only', CheckboxType::class, [
'label' => 'docgen.Show data instead of generating',
'required' => false,
]);
$builder->add('show_data', CheckboxType::class, [
'label' => 'Show data instead of generating',
'required' => false,
$builder->add('send_result_to', EmailType::class, [
'label' => 'docgen.Send report to',
'help' => 'docgen.Send report errors to this email address',
'empty_data' => '',
'required' => true,
'constraints' => [
new NotBlank(),
new NotNull(),
],
]);
$builder->add('creator', PickUserDynamicType::class, [
'label' => 'docgen.Generate as creator',
'help' => 'docgen.The document will be generated as the given creator',
'multiple' => false,
'constraints' => [
new NotNull(),
],
]);
}
@ -211,8 +229,10 @@ final class DocGeneratorTemplateController extends AbstractController
} elseif (!$form->isSubmitted() || ($form->isSubmitted() && !$form->isValid())) {
$templatePath = '@ChillDocGenerator/Generator/basic_form.html.twig';
$templateOptions = [
'entity' => $entity, 'form' => $form->createView(),
'template' => $template, 'context' => $context,
'entity' => $entity,
'form' => $form->createView(),
'template' => $template,
'context' => $context,
];
return $this->render($templatePath, $templateOptions);
@ -225,60 +245,57 @@ final class DocGeneratorTemplateController extends AbstractController
$context->contextGenerationDataNormalize($template, $entity, $contextGenerationData)
: [];
// if is test, render the data or generate the doc
if ($isTest && isset($form) && true === $form['show_data']->getData()) {
return $this->render('@ChillDocGenerator/Generator/debug_value.html.twig', [
'datas' => json_encode($context->getData($template, $entity, $contextGenerationData), \JSON_PRETTY_PRINT),
]);
}
if ($isTest) {
$generated = $this->generator->generateDocFromTemplate(
$template,
$entityId,
$contextGenerationDataSanitized,
null,
true,
isset($form) ? $form['test_file']->getData() : null
);
return new Response(
$generated,
Response::HTTP_OK,
[
'Content-Transfer-Encoding', 'binary',
'Content-Type' => 'application/vnd.oasis.opendocument.text',
'Content-Disposition' => 'attachment; filename="generated.odt"',
'Content-Length' => \strlen($generated),
],
);
}
// this is not a test
// we prepare the object to store the document
$storedObject = (new StoredObject())
->setStatus(StoredObject::STATUS_PENDING)
;
if ($isTest) {
// document will be stored during 15 days, if generation is a test
$storedObject->setDeleteAt($this->clock->now()->add(new \DateInterval('P15D')));
}
$this->entityManager->persist($storedObject);
// we store the generated document
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
// we store the generated document (associate with the original entity, etc.)
// but only if this is not a test
if (!$isTest) {
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
}
$this->entityManager->flush();
if ($isTest) {
$creator = $contextGenerationData['creator'];
$sendResultTo = ($form ?? null)?->get('send_result_to')?->getData() ?? null;
$dumpOnly = ($form ?? null)?->get('dump_only')?->getData() ?? false;
} else {
$creator = $this->security->getUser();
if (!$creator instanceof User) {
throw new AccessDeniedHttpException('only authenticated user can request a generation');
}
$sendResultTo = null;
$dumpOnly = false;
}
$this->messageBus->dispatch(
new RequestGenerationMessage(
$this->security->getUser(),
$creator,
$template,
$entityId,
$storedObject,
$contextGenerationDataSanitized,
$isTest,
$sendResultTo,
$dumpOnly,
)
);

View File

@ -69,7 +69,7 @@ class DocGeneratorTemplate
*
* @Serializer\Groups({"read"})
*/
private int $id;
private ?int $id = null;
/**
* @ORM\Column(type="json")

View File

@ -14,10 +14,9 @@ namespace Chill\DocGeneratorBundle\Repository;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final class DocGeneratorTemplateRepository implements ObjectRepository
final class DocGeneratorTemplateRepository implements DocGeneratorTemplateRepositoryInterface
{
private readonly EntityRepository $repository;

View File

@ -0,0 +1,23 @@
<?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\DocGeneratorBundle\Repository;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Doctrine\Persistence\ObjectRepository;
/**
* @extends ObjectRepository<DocGeneratorTemplate>
*/
interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository
{
public function countByEntity(string $entity): int;
}

View File

@ -1,36 +1,62 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block table_entities_thead_tr %}
<th></th>
<th>{{ 'Title'|trans }}</th>
<th>{{ 'docgen.Context'|trans }}</th>
<th>{{ 'docgen.test generate'|trans }}</th>
<th>{{ 'Edit'|trans }}</th>
{% endblock %}
{% block table_entities_tbody %}
{% for entity in entities %}
<tr>
<td>{{ entity.id }}</td>
<td>{{ entity.name|localize_translatable_string}}</td>
<td>{{ contextManager.getContextByKey(entity.context).name|trans }}</td>
<td>
<form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}">
<input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', '/')|e('html_attr') }}" />
<input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" />
<input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" />
<input type="text" name="entityId" />
{% if entities|length == 0 %}
<p class="chill-no-data-statement">{{ 'docgen.Any template configured'|trans }}</p>
{% else %}
<div class="flex-table">
{% for entity in entities %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="flex-basis:100%;">
<h2>{{ entity.name|localize_translatable_string }}</h2>
</div>
</div>
<div class="item-row">
<p><span class="badge bg-chill-green-dark">{{ contextManager.getContextByKey(entity.context).name|trans }}</span></p>
</div>
<div class="item-row">
<div class="item-col"></div>
<ul class="record_actions item-col flex-shrink-1">
<li>
<form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}">
<input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', app.request.uri)|e('html_attr') }}" />
<input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" />
<input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" />
<input type="text" name="entityId" placeholder="{{ 'docgen.entity_id_placeholder'|trans }}" required />
<button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button>
</form>
</li>
<li>
{{ entity.file|chill_document_button_group('Template file', true) }}
</li>
<li>
<a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
</ul>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button>
</form>
</td>
<td>
<a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</td>
</tr>
{% endfor %}
{% endblock %}
{% block actions_before %}

View File

@ -6,18 +6,20 @@
<div class="col-md-10 col-xxl">
<h1>{{ block('title') }}</h1>
<div class="container">
<div class="container overflow-hidden">
{% for key, context in contexts %}
<div class="row">
<div class="col-md-4">
<div class="row g-3" style="margin-top: 1rem;">
<div class="col-4 offset-1 text-center">
<a
href="{{ path('chill_crud_docgen_template_new', { 'context': key }) }}"
class="btn btn-outline-chill-green-dark">
{{ context.name|trans }}
</a>
</div>
<div class="col-md-8">
{{ context.description|trans|nl2br }}
<div class="col">
<div>
{{ context.description|trans|nl2br }}
</div>
</div>
</div>
{% endfor %}

View File

@ -1,6 +1,6 @@
{{ creator.label }},
{% if creator is not same as null %}{{ creator.label }},{% endif %}
{{ 'docgen.failure_email.The generation of the document {template_name} failed'|trans({'{template_name}': template.name|localize_translatable_string}) }}
{{ 'docgen.failure_email.The generation of the document %template_name% failed'|trans({'%template_name%': template.name|localize_translatable_string}) }}
{{ 'docgen.failure_email.Forward this email to your administrator for solving'|trans }}

View File

@ -0,0 +1,7 @@
{{ 'docgen.data_dump_email.Dear'|trans }}
{{ 'docgen.data_dump_email.data_dump_ready_and_link'|trans }}
{{ link }}
{{ 'docgen.data_dump_email.link_valid_until'|trans({validity: validity}) }}

View File

@ -17,52 +17,88 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface;
use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Yaml\Yaml;
class Generator implements GeneratorInterface
{
private const LOG_PREFIX = '[docgen generator] ';
public function __construct(private readonly ContextManagerInterface $contextManager, private readonly DriverInterface $driver, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, private readonly StoredObjectManagerInterface $storedObjectManager) {}
public function __construct(
private readonly ContextManagerInterface $contextManager,
private readonly DriverInterface $driver,
private readonly ManagerRegistry $objectManagerRegistry,
private readonly LoggerInterface $logger,
private readonly StoredObjectManagerInterface $storedObjectManager
) {
}
public function generateDataDump(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject {
return $this->generateFromTemplate(
$template,
$entityId,
$contextGenerationDataNormalized,
$destinationStoredObject,
$creator,
$clearEntityManagerDuringProcess,
true,
);
}
/**
* @template T of File|null
* @template B of bool
*
* @param B $isTest
* @param (B is true ? T : null) $testFile
*
* @psalm-return (B is true ? string : null)
*
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null,
?User $creator = null
): ?string {
if ($destinationStoredObject instanceof StoredObject && StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) {
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject {
return $this->generateFromTemplate(
$template,
$entityId,
$contextGenerationDataNormalized,
$destinationStoredObject,
$creator,
$clearEntityManagerDuringProcess,
false,
);
}
private function generateFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
bool $generateDumpOnly = false,
): StoredObject {
if (StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) {
$this->logger->info(self::LOG_PREFIX.'Aborting generation of an already generated document');
throw new ObjectReadyException();
}
$this->logger->info(self::LOG_PREFIX.'Starting generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => null === $destinationStoredObject ? null : $destinationStoredObject->getId(),
'destination_stored_object' => $destinationStoredObject->getId(),
]);
$context = $this->contextManager->getContextByDocGeneratorTemplate($template);
$entity = $this
->entityManager
->objectManagerRegistry
->getManagerForClass($context->getEntityClass())
->find($context->getEntityClass(), $entityId)
;
@ -80,17 +116,47 @@ class Generator implements GeneratorInterface
$data = $context->getData($template, $entity, $contextGenerationDataNormalized);
$destinationStoredObjectId = $destinationStoredObject instanceof StoredObject ? $destinationStoredObject->getId() : null;
$this->entityManager->clear();
gc_collect_cycles();
if (null !== $destinationStoredObjectId) {
$destinationStoredObject = $this->entityManager->find(StoredObject::class, $destinationStoredObjectId);
$destinationStoredObjectId = $destinationStoredObject->getId();
if ($clearEntityManagerDuringProcess) {
// we clean the entity manager
$this->objectManagerRegistry->getManagerForClass($context->getEntityClass())?->clear();
// this will force php to clean the memory
gc_collect_cycles();
}
if ($isTest && ($testFile instanceof File)) {
$templateDecrypted = file_get_contents($testFile->getPathname());
} else {
// as we potentially deleted the storedObject from memory, we have to restore it
$destinationStoredObject = $this->objectManagerRegistry
->getManagerForClass(StoredObject::class)
->find(StoredObject::class, $destinationStoredObjectId);
if ($generateDumpOnly) {
$content = Yaml::dump($data, 6);
/* @var StoredObject $destinationStoredObject */
$destinationStoredObject
->setType('application/yaml')
->setFilename(sprintf('%s_yaml', uniqid('doc_', true)))
->setStatus(StoredObject::STATUS_READY)
;
try {
$this->storedObjectManager->write($destinationStoredObject, $content);
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
throw new GeneratorException([$e->getMessage()], $e);
}
return $destinationStoredObject;
}
try {
$templateDecrypted = $this->storedObjectManager->read($template->getFile());
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
throw new GeneratorException([$e->getMessage()], $e);
}
try {
@ -103,19 +169,10 @@ class Generator implements GeneratorInterface
$template->getFile()->getFilename()
);
} catch (TemplateException $e) {
$destinationStoredObject->addGenerationErrors(implode("\n", $e->getErrors()));
throw new GeneratorException($e->getErrors(), $e);
}
if (true === $isTest) {
$this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'is_test' => true,
'entity_id' => $entityId,
'destination_stored_object' => null === $destinationStoredObject ? null : $destinationStoredObject->getId(),
]);
return $generatedResource;
}
/* @var StoredObject $destinationStoredObject */
$destinationStoredObject
->setType($template->getFile()->getType())
@ -123,15 +180,19 @@ class Generator implements GeneratorInterface
->setStatus(StoredObject::STATUS_READY)
;
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
try {
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
$this->entityManager->flush();
throw new GeneratorException([$e->getMessage()], $e);
}
$this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => $destinationStoredObject->getId(),
]);
return null;
return $destinationStoredObject;
}
}

View File

@ -13,29 +13,48 @@ namespace Chill\DocGeneratorBundle\Service\Generator;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\MainBundle\Entity\User;
use Symfony\Component\HttpFoundation\File\File;
interface GeneratorInterface
{
/**
* @template T of File|null
* @template B of bool
* Generate a document and store the document on disk.
*
* @param B $isTest
* @param (B is true ? T : null) $testFile
* The given $destinationStoredObject will be updated with filename, status, and eventually errors will be stored
* into the object. The number of generation trial will also be incremented.
*
* @psalm-return (B is true ? string : null)
* This process requires a huge amount of data. For this reason, the entity manager will be cleaned during the process,
* unless the paarameter `$clearEntityManagerDuringProcess` is set on false.
*
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
* As the entity manager might be cleaned, the new instance of the stored object will be returned by this method.
*
* Ensure to store change in the database after each generation trial (call `EntityManagerInterface::flush`).
*
* @phpstan-impure
*
* @param StoredObject $destinationStoredObject will be update with filename, status and incremented of generation trials
*
* @throws StoredObjectManagerException if unable to decrypt the template or store the document
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null,
?User $creator = null
): ?string;
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject;
/**
* Generate a data dump, and store it within the `$destinationStoredObject`.
*/
public function generateDataDump(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject;
}

View File

@ -0,0 +1,64 @@
<?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\DocGeneratorBundle\Service\Messenger;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
/**
* The OnAfterMessageHandledClearStoredObjectCache class is an event subscriber that clears the stored object cache
* after a specific message is handled or fails.
*/
final readonly class OnAfterMessageHandledClearStoredObjectCache implements EventSubscriberInterface
{
public function __construct(
private StoredObjectManagerInterface $storedObjectManager,
private LoggerInterface $logger,
) {
}
public static function getSubscribedEvents()
{
return [
WorkerMessageHandledEvent::class => [
['afterHandling', 0],
],
WorkerMessageFailedEvent::class => [
['afterFails', 0],
],
];
}
public function afterHandling(WorkerMessageHandledEvent $event): void
{
if ($event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) {
$this->clearStoredObjectCache();
}
}
public function afterFails(WorkerMessageFailedEvent $event): void
{
if ($event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) {
$this->clearStoredObjectCache();
}
}
private function clearStoredObjectCache(): void
{
$this->logger->debug('clear the cache after generation of a document');
$this->storedObjectManager->clearCache();
}
}

View File

@ -11,10 +11,11 @@ declare(strict_types=1);
namespace Chill\DocGeneratorBundle\Service\Messenger;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorException;
use Chill\DocGeneratorBundle\tests\Service\Messenger\OnGenerationFailsTest;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@ -24,11 +25,23 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see OnGenerationFailsTest for test suite
*/
final readonly class OnGenerationFails implements EventSubscriberInterface
{
public const LOG_PREFIX = '[docgen failed] ';
public function __construct(private DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private EntityManagerInterface $entityManager, private LoggerInterface $logger, private MailerInterface $mailer, private StoredObjectRepository $storedObjectRepository, private TranslatorInterface $translator, private UserRepositoryInterface $userRepository) {}
public function __construct(
private DocGeneratorTemplateRepositoryInterface $docGeneratorTemplateRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private MailerInterface $mailer,
private StoredObjectRepositoryInterface $storedObjectRepository,
private TranslatorInterface $translator,
private UserRepositoryInterface $userRepository
) {
}
public static function getSubscribedEvents()
{
@ -43,13 +56,12 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
return;
}
if (!$event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) {
$message = $event->getEnvelope()->getMessage();
if (!$message instanceof RequestGenerationMessage) {
return;
}
/** @var RequestGenerationMessage $message */
$message = $event->getEnvelope()->getMessage();
$this->logger->error(self::LOG_PREFIX.'Docgen failed', [
'stored_object_id' => $message->getDestinationStoredObjectId(),
'entity_id' => $message->getEntityId(),
@ -77,16 +89,8 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
private function warnCreator(RequestGenerationMessage $message, WorkerMessageFailedEvent $event): void
{
$creatorId = $message->getCreatorId();
if (null === $creator = $this->userRepository->find($creatorId)) {
$this->logger->error(self::LOG_PREFIX.'Creator not found with given id', ['creator_id', $creatorId]);
return;
}
if (null === $creator->getEmail() || '' === $creator->getEmail()) {
$this->logger->info(self::LOG_PREFIX.'Creator does not have any email', ['user' => $creator->getUsernameCanonical()]);
if (null === $message->getSendResultToEmail() || '' === $message->getSendResultToEmail()) {
$this->logger->info(self::LOG_PREFIX.'No email associated with this request generation');
return;
}
@ -94,7 +98,7 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
// if the exception is not a GeneratorException, we try the previous one...
$throwable = $event->getThrowable();
if (!$throwable instanceof GeneratorException) {
$throwable = $throwable->getPrevious();
$throwable = $throwable->getPrevious() ?? $throwable;
}
if ($throwable instanceof GeneratorException) {
@ -109,8 +113,14 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
return;
}
if (null === $creator = $this->userRepository->find($message->getCreatorId())) {
$this->logger->error(self::LOG_PREFIX.'Creator not found');
return;
}
$email = (new TemplatedEmail())
->to($creator->getEmail())
->to($message->getSendResultToEmail())
->subject($this->translator->trans('docgen.failure_email.The generation of a document failed'))
->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig')
->context([

View File

@ -11,15 +11,21 @@ declare(strict_types=1);
namespace Chill\DocGeneratorBundle\Service\Messenger;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\Generator;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Handle the request of document generation.
@ -30,7 +36,18 @@ class RequestGenerationHandler implements MessageHandlerInterface
private const LOG_PREFIX = '[docgen message handler] ';
public function __construct(private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private readonly EntityManagerInterface $entityManager, private readonly Generator $generator, private readonly LoggerInterface $logger, private readonly StoredObjectRepository $storedObjectRepository, private readonly UserRepositoryInterface $userRepository) {}
public function __construct(
private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Generator $generator,
private readonly LoggerInterface $logger,
private readonly StoredObjectRepository $storedObjectRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly MailerInterface $mailer,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
private readonly TranslatorInterface $translator,
) {
}
public function __invoke(RequestGenerationMessage $message)
{
@ -43,25 +60,59 @@ class RequestGenerationHandler implements MessageHandlerInterface
}
if ($destinationStoredObject->getGenerationTrialsCounter() >= self::AUTHORIZED_TRIALS) {
$this->logger->error(self::LOG_PREFIX.'Request generation abandoned: maximum number of retry reached', [
'template_id' => $message->getTemplateId(),
'destination_stored_object' => $message->getDestinationStoredObjectId(),
'trial' => $destinationStoredObject->getGenerationTrialsCounter(),
]);
throw new UnrecoverableMessageHandlingException('maximum number of retry reached');
}
$creator = $this->userRepository->find($message->getCreatorId());
// we increase the number of generation trial in the object, and, in the same time, update the counter
// on the database side. This ensure that, if the script fails for any reason (memory limit reached), the
// counter is inscreased
$destinationStoredObject->addGenerationTrial();
$this->entityManager->createQuery('UPDATE '.StoredObject::class.' s SET s.generationTrialsCounter = s.generationTrialsCounter + 1 WHERE s.id = :id')
->setParameter('id', $destinationStoredObject->getId())
->execute();
$this->generator->generateDocFromTemplate(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
false,
null,
$creator
);
try {
if ($message->isDumpOnly()) {
$destinationStoredObject = $this->generator->generateDataDump(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
$creator
);
$this->sendDataDump($destinationStoredObject, $message);
} else {
$destinationStoredObject = $this->generator->generateDocFromTemplate(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
$creator
);
}
} catch (StoredObjectManagerException|GeneratorException $e) {
$this->entityManager->flush();
$this->logger->error(self::LOG_PREFIX.'Request generation failed', [
'template_id' => $message->getTemplateId(),
'destination_stored_object' => $message->getDestinationStoredObjectId(),
'trial' => $destinationStoredObject->getGenerationTrialsCounter(),
'error' => $e->getTraceAsString(),
]);
throw $e;
}
$this->entityManager->flush();
$this->logger->info(self::LOG_PREFIX.'Request generation finished', [
'template_id' => $message->getTemplateId(),
@ -69,4 +120,23 @@ class RequestGenerationHandler implements MessageHandlerInterface
'duration_int' => (new \DateTimeImmutable('now'))->getTimestamp() - $message->getCreatedAt()->getTimestamp(),
]);
}
private function sendDataDump(StoredObject $destinationStoredObject, RequestGenerationMessage $message): void
{
$url = $this->tempUrlGenerator->generate('GET', $destinationStoredObject->getFilename(), 3600);
$parts = [];
parse_str(parse_url((string) $url->url)['query'], $parts);
$validity = \DateTimeImmutable::createFromFormat('U', $parts['temp_url_expires']);
$email = (new TemplatedEmail())
->to($message->getSendResultToEmail())
->textTemplate('@ChillDocGenerator/Email/send_data_dump_to_admin.txt.twig')
->context([
'link' => $url->url,
'validity' => $validity,
])
->subject($this->translator->trans('docgen.data_dump_email.subject'));
$this->mailer->send($email);
}
}

View File

@ -15,27 +15,33 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
class RequestGenerationMessage
final readonly class RequestGenerationMessage
{
private readonly int $creatorId;
private int $creatorId;
private readonly int $templateId;
private int $templateId;
private readonly int $destinationStoredObjectId;
private int $destinationStoredObjectId;
private readonly \DateTimeImmutable $createdAt;
private \DateTimeImmutable $createdAt;
private ?string $sendResultToEmail;
public function __construct(
User $creator,
DocGeneratorTemplate $template,
private readonly int $entityId,
private int $entityId,
StoredObject $destinationStoredObject,
private readonly array $contextGenerationData
private array $contextGenerationData,
private bool $isTest = false,
?string $sendResultToEmail = null,
private bool $dumpOnly = false,
) {
$this->creatorId = $creator->getId();
$this->templateId = $template->getId();
$this->destinationStoredObjectId = $destinationStoredObject->getId();
$this->createdAt = new \DateTimeImmutable('now');
$this->sendResultToEmail = $sendResultToEmail ?? $creator->getEmail();
}
public function getCreatorId(): int
@ -67,4 +73,19 @@ class RequestGenerationMessage
{
return $this->createdAt;
}
public function isTest(): bool
{
return $this->isTest;
}
public function getSendResultToEmail(): ?string
{
return $this->sendResultToEmail;
}
public function isDumpOnly(): bool
{
return $this->dumpOnly;
}
}

View File

@ -20,7 +20,9 @@ use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException;
use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
@ -66,7 +68,11 @@ class GeneratorTest extends TestCase
$entityManager->find('DummyClass', Argument::type('int'))
->willReturn($entity);
$entityManager->clear()->shouldBeCalled();
$entityManager->flush()->shouldBeCalled();
$entityManager->flush()->shouldNotBeCalled();
$managerRegistry = $this->prophesize(ManagerRegistry::class);
$managerRegistry->getManagerForClass('DummyClass')->willReturn($entityManager->reveal());
$managerRegistry->getManagerForClass(StoredObject::class)->willReturn($entityManager->reveal());
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($templateStoredObject)->willReturn('template');
@ -75,7 +81,7 @@ class GeneratorTest extends TestCase
$generator = new Generator(
$contextManagerInterface->reveal(),
$driver->reveal(),
$entityManager->reveal(),
$managerRegistry->reveal(),
new NullLogger(),
$storedObjectManager->reveal()
);
@ -84,7 +90,8 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject
$destinationStoredObject,
new User()
);
}
@ -95,7 +102,7 @@ class GeneratorTest extends TestCase
$generator = new Generator(
$this->prophesize(ContextManagerInterface::class)->reveal(),
$this->prophesize(DriverInterface::class)->reveal(),
$this->prophesize(EntityManagerInterface::class)->reveal(),
$this->prophesize(ManagerRegistry::class)->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
@ -108,7 +115,8 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject
$destinationStoredObject,
new User()
);
}
@ -136,10 +144,14 @@ class GeneratorTest extends TestCase
$entityManager->find(Argument::type('string'), Argument::type('int'))
->willReturn(null);
$managerRegistry = $this->prophesize(ManagerRegistry::class);
$managerRegistry->getManagerForClass('DummyClass')->willReturn($entityManager->reveal());
$managerRegistry->getManagerForClass(StoredObject::class)->willReturn($entityManager->reveal());
$generator = new Generator(
$contextManagerInterface->reveal(),
$this->prophesize(DriverInterface::class)->reveal(),
$entityManager->reveal(),
$managerRegistry->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
@ -148,7 +160,8 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject
$destinationStoredObject,
new User()
);
}
}

View File

@ -0,0 +1,107 @@
<?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\DocGeneratorBundle\tests\Service\Messenger;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Service\Messenger\OnAfterMessageHandledClearStoredObjectCache;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
/**
* @internal
*
* @coversNothing
*/
class OnAfterMessageHandledClearStoredObjectCacheTest extends TestCase
{
use ProphecyTrait;
public function testThatNotGenerationMessageDoesNotCallAClearCache(): void
{
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->clearCache()->shouldNotBeCalled();
$eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal());
$eventSubscriber->afterHandling($this->buildEventSuccess(new \stdClass()));
$eventSubscriber->afterFails($this->buildEventFailed(new \stdClass()));
}
public function testThatConcernedEventCallAClearCache(): void
{
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->clearCache()->shouldBeCalledTimes(2);
$eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal());
$eventSubscriber->afterHandling($this->buildEventSuccess($this->buildRequestGenerationMessage()));
$eventSubscriber->afterFails($this->buildEventFailed($this->buildRequestGenerationMessage()));
}
private function buildRequestGenerationMessage(
): RequestGenerationMessage {
$creator = new User();
$creator->setEmail('fake@example.com');
$class = new \ReflectionClass($creator);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($creator, 1);
$template ??= new DocGeneratorTemplate();
$class = new \ReflectionClass($template);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($template, 2);
$destinationStoredObject = new StoredObject();
$class = new \ReflectionClass($destinationStoredObject);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($destinationStoredObject, 3);
return new RequestGenerationMessage(
$creator,
$template,
1,
$destinationStoredObject,
[],
);
}
private function buildEventSubscriber(StoredObjectManagerInterface $storedObjectManager): OnAfterMessageHandledClearStoredObjectCache
{
return new OnAfterMessageHandledClearStoredObjectCache($storedObjectManager, new NullLogger());
}
private function buildEventFailed(object $message): WorkerMessageFailedEvent
{
$envelope = new Envelope($message);
return new WorkerMessageFailedEvent($envelope, 'testing', new \RuntimeException());
}
private function buildEventSuccess(object $message): WorkerMessageHandledEvent
{
$envelope = new Envelope($message);
return new WorkerMessageHandledEvent($envelope, 'test_receiver');
}
}

View File

@ -0,0 +1,226 @@
<?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\DocGeneratorBundle\tests\Service\Messenger;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\DocGeneratorBundle\Service\Messenger\OnGenerationFails;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class OnGenerationFailsTest extends TestCase
{
use ProphecyTrait;
public function testNotConcernedMessageAreNotHandled(): void
{
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldNotBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send()->shouldNotBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal()
);
$event = $this->buildEvent(new \stdClass());
$eventSubscriber->onMessageFailed($event);
}
public function testMessageThatWillBeRetriedAreNotHandled(): void
{
$storedObject = new StoredObject();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldNotBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send()->shouldNotBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal()
);
$event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject));
$event->setForRetry();
$eventSubscriber->onMessageFailed($event);
}
public function testThatANotRetriyableEventWillMarkObjectAsFailed(): void
{
$storedObject = new StoredObject();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send(Argument::type(RawMessage::class), Argument::any())->shouldBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal(),
storedObject: $storedObject
);
$event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject));
$eventSubscriber->onMessageFailed($event);
self::assertEquals(StoredObject::STATUS_FAILURE, $storedObject->getStatus());
}
public function testThatANonRetryableEventSendAnEmail(): void
{
$storedObject = new StoredObject();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send(
Argument::that(function ($arg): bool {
if (!$arg instanceof Email) {
return false;
}
foreach ($arg->getTo() as $to) {
if ('test@test.com' === $to->getAddress()) {
return true;
}
}
return false;
}),
Argument::any()
)
->shouldBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal(),
storedObject: $storedObject
);
$event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject, sendResultToEmail: 'test@test.com'));
$eventSubscriber->onMessageFailed($event);
}
private function buildRequestGenerationMessage(
StoredObject $destinationStoredObject,
?User $creator = null,
?DocGeneratorTemplate $template = null,
array $contextGenerationData = [],
bool $isTest = false,
?string $sendResultToEmail = null,
): RequestGenerationMessage {
if (null === $creator) {
$creator = new User();
$creator->setEmail('fake@example.com');
}
if (null === $creator->getId()) {
$class = new \ReflectionClass($creator);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($creator, 1);
}
$template ??= new DocGeneratorTemplate();
$class = new \ReflectionClass($template);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($template, 2);
$class = new \ReflectionClass($destinationStoredObject);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($destinationStoredObject, 3);
return new RequestGenerationMessage(
$creator,
$template,
1,
$destinationStoredObject,
$contextGenerationData,
$isTest,
$sendResultToEmail
);
}
private function buildOnGenerationFailsEventSubscriber(
?StoredObject $storedObject = null,
?EntityManagerInterface $entityManager = null,
?MailerInterface $mailer = null,
): OnGenerationFails {
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$storedObjectRepository->find(Argument::type('int'))->willReturn($storedObject ?? new StoredObject());
if (null === $entityManager) {
$entityManagerProphecy = $this->prophesize(EntityManagerInterface::class);
}
if (null === $mailer) {
$mailerProphecy = $this->prophesize(MailerInterface::class);
}
$translator = $this->prophesize(TranslatorInterface::class);
$translator->trans(Argument::type('string'))->will(fn ($args) => $args[0]);
$userRepository = $this->prophesize(UserRepositoryInterface::class);
$userRepository->find(Argument::type('int'))->willReturn(new User());
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$docGeneratorTemplateRepository->find(Argument::type('int'))->willReturn(new DocGeneratorTemplate());
return new OnGenerationFails(
$docGeneratorTemplateRepository->reveal(),
$entityManager ?? $entityManagerProphecy->reveal(),
new NullLogger(),
$mailer ?? $mailerProphecy->reveal(),
$storedObjectRepository->reveal(),
$translator->reveal(),
$userRepository->reveal()
);
}
private function buildEvent(object $message): WorkerMessageFailedEvent
{
$envelope = new Envelope($message);
return new WorkerMessageFailedEvent($envelope, 'testing', new \RuntimeException());
}
}

View File

@ -0,0 +1,4 @@
docgen:
data_dump_email:
link_valid_until: >-
Ce lien est valide jusqu'au {validity, date, full}, {validity, time, medium}

View File

@ -14,13 +14,31 @@ docgen:
Doc generation is pending: La génération de ce document est en cours
Come back later: Revenir plus tard
Send report to: Envoyer le rapport à
Send report errors to this email address: Les rapports d'erreurs seront envoyés à l'adresse email indiquée
Generate as creator: Générer en tant que
The document will be generated as the given creator: Le document sera généré à la place de l'utilisateur indiqué
Show data instead of generating: Montrer les données au lieu de générer le document
Any template configured: Aucun gabarit de document configuré
entity_id_placeholder: Identifiant de l'entité
failure_email:
The generation of a document failed: La génération d'un document a échoué
The generation of the document {template_name} failed: La génération d'un document à partir du modèle {{ template_name }} a échoué.
The generation of the document %template_name% failed: La génération d'un document à partir du modèle {{ template_name }} a échoué.
The following errors were encoutered: Les erreurs suivantes ont été rencontrées
Forward this email to your administrator for solving: Faites suivre ce message vers votre administrateur pour la résolution du problème.
References: Références
data_dump_email:
subject: Contenu des données de génération de document disponible
Dear: Cher
data_dump_ready_and_link: >-
Le contenu des données est disponible. Vous pouvez le télécharger à l'aide du lien suivant:
crud:
docgen_template:
index:
@ -28,5 +46,4 @@ crud:
add_new: Créer
Show data instead of generating: Montrer les données au lieu de générer le document
Template file: Fichier modèle

View File

@ -24,6 +24,11 @@ use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Represent a document stored in an object store.
*
* StoredObjects 's content should be read and written using the @see{StoredObjectManagerInterface}.
*
* The property `$deleteAt` allow a deletion of the document after the given date. But this property should
* be set before the document is actually written by the StoredObjectManager.
*
* @ORM\Entity
*
* @ORM\Table("chill_doc.stored_object")
@ -116,6 +121,16 @@ class StoredObject implements Document, TrackCreationInterface
*/
private int $generationTrialsCounter = 0;
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": null})
*/
private ?\DateTimeImmutable $deleteAt = null;
/**
* @ORM\Column(type="text", nullable=false, options={"default": ""})
*/
private string $generationErrors = '';
/**
* @param StoredObject::STATUS_* $status
*/
@ -143,6 +158,11 @@ class StoredObject implements Document, TrackCreationInterface
*/
public function getCreationDate(): \DateTime
{
if (null === $this->createdAt) {
// this scenario will quite never happens
return new \DateTime('now');
}
return \DateTime::createFromImmutable($this->createdAt);
}
@ -302,4 +322,37 @@ class StoredObject implements Document, TrackCreationInterface
{
return self::STATUS_FAILURE === $this->getStatus();
}
public function getDeleteAt(): ?\DateTimeImmutable
{
return $this->deleteAt;
}
public function setDeleteAt(?\DateTimeImmutable $deleteAt): StoredObject
{
$this->deleteAt = $deleteAt;
return $this;
}
public function getGenerationErrors(): string
{
return $this->generationErrors;
}
/**
* Adds generation errors to the stored object.
*
* The existing generation errors are not removed
*
* @param string $generationErrors the generation errors to be added
*
* @return StoredObject the modified StoredObject instance
*/
public function addGenerationErrors(string $generationErrors): StoredObject
{
$this->generationErrors = $this->generationErrors.$generationErrors."\n";
return $this;
}
}

View File

@ -14,11 +14,10 @@ namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
final class StoredObjectRepository implements ObjectRepository
final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface
{
private readonly EntityRepository $repository;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{

View File

@ -0,0 +1,22 @@
<?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\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\Persistence\ObjectRepository;
/**
* @extends ObjectRepository<StoredObject>
*/
interface StoredObjectRepositoryInterface extends ObjectRepository
{
}

View File

@ -105,6 +105,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
)
: $clearContent;
$headers = [];
if (null !== $document->getDeleteAt()) {
$headers['X-Delete-At'] = $document->getDeleteAt()->getTimestamp();
}
try {
$response = $this
->client
@ -119,6 +125,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->url,
[
'body' => $encryptedContent,
'headers' => $headers,
]
);
} catch (TransportExceptionInterface $exception) {
@ -130,6 +137,11 @@ final class StoredObjectManager implements StoredObjectManagerInterface
}
}
public function clearCache(): void
{
$this->inMemory = [];
}
private function extractLastModifiedFromResponse(ResponseInterface $response): \DateTimeImmutable
{
$lastModifiedString = (($response->getHeaders()['last-modified'] ?? [])[0] ?? '');

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
interface StoredObjectManagerInterface
{
@ -23,6 +24,8 @@ interface StoredObjectManagerInterface
* @param StoredObject $document the document
*
* @return string the retrieved content in clear
*
* @throws StoredObjectManagerException if unable to read or decrypt the content
*/
public function read(StoredObject $document): string;
@ -31,6 +34,10 @@ interface StoredObjectManagerInterface
*
* @param StoredObject $document the document
* @param $clearContent The content to store in clear
*
* @throws StoredObjectManagerException
*/
public function write(StoredObject $document, string $clearContent): void;
public function clearCache(): void;
}

View File

@ -9,7 +9,7 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests;
namespace Chill\DocStoreBundle\Tests\Service;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
@ -118,6 +118,41 @@ final class StoredObjectManagerTest extends TestCase
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
}
public function testWriteWithDeleteAt()
{
$storedObject = new StoredObject();
$expectedRequests = [
function ($method, $url, $options): MockResponse {
self::assertEquals('PUT', $method);
self::assertArrayHasKey('headers', $options);
self::assertIsArray($options['headers']);
self::assertCount(0, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At')));
return new MockResponse('', ['http_code' => 201]);
},
function ($method, $url, $options): MockResponse {
self::assertEquals('PUT', $method);
self::assertArrayHasKey('headers', $options);
self::assertIsArray($options['headers']);
self::assertCount(1, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At')));
self::assertContains('X-Delete-At: 1711014260', $options['headers']);
return new MockResponse('', ['http_code' => 201]);
},
];
$client = new MockHttpClient($expectedRequests);
$manager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject));
$manager->write($storedObject, 'ok');
// with a deletedAt date
$storedObject->setDeleteAt(\DateTimeImmutable::createFromFormat('U', '1711014260'));
$manager->write($storedObject, 'ok');
}
private function getHttpClient(string $encodedContent): HttpClientInterface
{
$callback = static function ($method, $url, $options) use ($encodedContent) {

View File

@ -0,0 +1,36 @@
<?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\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240322100107 extends AbstractMigration
{
public function getDescription(): string
{
return 'StoredObject: add deleteAt and generationErrors columns';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object ADD deleteAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD generationErrors TEXT DEFAULT \'\' NOT NULL');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object.deleteAt IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object DROP deleteAt');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP generationErrors');
}
}

View File

@ -433,6 +433,7 @@ final class EventController extends AbstractController
$builder->add('event_id', HiddenType::class, [
'data' => $event->getId(),
]);
dump($event->getId());
return $builder->getForm();
}

View File

@ -197,7 +197,7 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
return $this->id;
}
public function getModerator(): User|null
public function getModerator(): ?User
{
return $this->moderator;
}

View File

@ -91,7 +91,7 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get event.
*/
public function getEvent(): Event|null
public function getEvent(): ?Event
{
return $this->event;
}
@ -127,7 +127,7 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get role.
*/
public function getRole(): Role|null
public function getRole(): ?Role
{
return $this->role;
}
@ -147,7 +147,7 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get status.
*/
public function getStatus(): Status|null
public function getStatus(): ?Status
{
return $this->status;
}

View File

@ -12,11 +12,12 @@ declare(strict_types=1);
namespace Chill\MainBundle\Command;
use Chill\MainBundle\Entity\Language;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Intl\Languages;
/*
@ -39,7 +40,7 @@ class LoadAndUpdateLanguagesCommand extends Command
/**
* LoadCountriesCommand constructor.
*/
public function __construct(private readonly EntityManager $entityManager, private $availableLanguages)
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ParameterBagInterface $parameterBag)
{
parent::__construct();
}
@ -79,7 +80,7 @@ class LoadAndUpdateLanguagesCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$em = $this->entityManager;
$chillAvailableLanguages = $this->availableLanguages;
$chillAvailableLanguages = $this->parameterBag->get('chill_main.available_languages');
$languages = [];
foreach ($chillAvailableLanguages as $avLang) {
@ -113,7 +114,7 @@ class LoadAndUpdateLanguagesCommand extends Command
$avLangNames = [];
foreach ($chillAvailableLanguages as $avLang) {
$avLangNames[$avLang] = Languages::getName($code, $avLang);
$avLangNames[$avLang] = ucfirst(Languages::getName($code, $avLang));
}
$languageDB->setName($avLangNames);

View File

@ -47,4 +47,12 @@ class AdminController extends AbstractController
{
return $this->render('@ChillMain/Admin/indexUser.html.twig');
}
/**
* @Route("/{_locale}/admin/dashboard", name="chill_main_dashboard_admin")
*/
public function indexDashboardAction()
{
return $this->render('@ChillMain/Admin/indexDashboard.html.twig');
}
}

View File

@ -0,0 +1,52 @@
<?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\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NewsItemRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
final readonly class DashboardApiController
{
public function __construct(
private NewsItemRepository $newsItemRepository,
) {
}
/**
* Get user dashboard config (not yet based on user id and still hardcoded for now).
*
* @Route("/api/1.0/main/dashboard-config-item.json", methods={"get"})
*/
public function getDashboardConfiguration(): JsonResponse
{
$data = [];
if (0 < $this->newsItemRepository->countCurrentNews()) {
// show news only if we have news
// NOTE: maybe this should be done in the frontend...
$data[] =
[
'position' => 'top-left',
'id' => 1,
'type' => 'news',
'metadata' => [
// arbitrary data that will be store "some time"
'only_unread' => false,
],
];
}
return new JsonResponse($data, JsonResponse::HTTP_OK, []);
}
}

View File

@ -0,0 +1,53 @@
<?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\Controller;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NewsItemRepository;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
class NewsItemApiController
{
public function __construct(
private readonly NewsItemRepository $newsItemRepository,
private readonly SerializerInterface $serializer,
private readonly PaginatorFactory $paginatorFactory
) {
}
/**
* Get list of news items filtered on start and end date.
*
* @Route("/api/1.0/main/news/current.json", methods={"get"})
*/
public function listCurrentNewsItems(): JsonResponse
{
$total = $this->newsItemRepository->countCurrentNews();
$paginator = $this->paginatorFactory->create($total);
$newsItems = $this->newsItemRepository->findCurrentNews(
$paginator->getItemsPerPage(),
$paginator->getCurrentPage()->getFirstItemNumber()
);
return new JsonResponse($this->serializer->serialize(
new Collection(array_values($newsItems), $paginator),
'json',
[
AbstractNormalizer::GROUPS => ['read'],
]
), JsonResponse::HTTP_OK, [], true);
}
}

View File

@ -0,0 +1,27 @@
<?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\Controller;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Symfony\Component\HttpFoundation\Request;
class NewsItemController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.startDate', 'DESC');
$query->addOrderBy('e.id', 'DESC');
return parent::orderQuery($action, $query, $request, $paginator);
}
}

View File

@ -0,0 +1,73 @@
<?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\Controller;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NewsItemRepository;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class NewsItemHistoryController
{
public function __construct(
private readonly NewsItemRepository $newsItemRepository,
private readonly PaginatorFactory $paginatorFactory,
private readonly FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
private readonly Environment $environment,
) {
}
/**
* @Route("/{_locale}/news-items/history", name="chill_main_news_items_history")
*/
public function list(): Response
{
$filter = $this->buildFilterOrder();
$total = $this->newsItemRepository->countAllFilteredBySearchTerm($filter->getQueryString());
$newsItems = $this->newsItemRepository->findAllFilteredBySearchTerm($filter->getQueryString());
$pagination = $this->paginatorFactory->create($total);
return new Response($this->environment->render('@ChillMain/NewsItem/news_items_history.html.twig', [
'entities' => $newsItems,
'paginator' => $pagination,
'filter_order' => $filter,
]));
}
/**
* @Route("/{_locale}/news-items/{id}", name="chill_main_single_news_item")
*/
public function showSingleItem(NewsItem $newsItem, Request $request): Response
{
return new Response($this->environment->render(
'@ChillMain/NewsItem/show.html.twig',
[
'entity' => $newsItem,
]
));
}
private function buildFilterOrder(): FilterOrderHelper
{
$filterBuilder = $this->filterOrderHelperFactory
->create(self::class)
->addSearchBox();
return $filterBuilder->build();
}
}

View File

@ -28,5 +28,5 @@ interface CronJobInterface
*
* @return array|null optionally return an array with the same data than the previous execution
*/
public function run(array $lastExecutionData): array|null;
public function run(array $lastExecutionData): ?array;
}

View File

@ -19,6 +19,7 @@ use Chill\MainBundle\Controller\CountryController;
use Chill\MainBundle\Controller\LanguageController;
use Chill\MainBundle\Controller\LocationController;
use Chill\MainBundle\Controller\LocationTypeController;
use Chill\MainBundle\Controller\NewsItemController;
use Chill\MainBundle\Controller\RegroupmentController;
use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserJobApiController;
@ -53,6 +54,7 @@ use Chill\MainBundle\Entity\GeographicalUnitLayer;
use Chill\MainBundle\Entity\Language;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
@ -62,6 +64,7 @@ use Chill\MainBundle\Form\CountryType;
use Chill\MainBundle\Form\LanguageType;
use Chill\MainBundle\Form\LocationFormType;
use Chill\MainBundle\Form\LocationTypeType;
use Chill\MainBundle\Form\NewsItemType;
use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType;
@ -544,6 +547,35 @@ class ChillMainExtension extends Extension implements
],
],
],
[
'class' => NewsItem::class,
'name' => 'news_item',
'base_path' => '/admin/news_item',
'form_class' => NewsItemType::class,
'controller' => NewsItemController::class,
'actions' => [
'index' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/index.html.twig',
],
'new' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/new.html.twig',
],
'view' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/view_admin.html.twig',
],
'edit' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/edit.html.twig',
],
'delete' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/delete.html.twig',
],
],
],
],
'apis' => [
[

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -40,15 +39,15 @@ class Age extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->value1 = $parser->SimpleArithmeticExpression();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->value2 = $parser->SimpleArithmeticExpression();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -14,7 +14,6 @@ namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\DateDiffFunction;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\PathExpression;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -45,17 +44,17 @@ class Extract extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$this->field = $parser->getLexer()->token['value'];
$parser->match(Lexer::T_FROM);
$parser->match(\Doctrine\ORM\Query\TokenType::T_FROM);
// $this->value = $parser->ScalarExpression();
$this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -33,11 +32,11 @@ class GetJsonFieldByKey extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->expr1 = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->expr2 = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -13,7 +13,6 @@ namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -41,15 +40,15 @@ class Greatest extends FunctionNode
$this->exprs = [];
$lexer = $parser->getLexer();
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->exprs[] = $parser->ArithmeticPrimary();
while (Lexer::T_COMMA === $lexer->lookahead['type']) {
$parser->match(Lexer::T_COMMA);
while (\Doctrine\ORM\Query\TokenType::T_COMMA === $lexer->lookahead['type']) {
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->exprs[] = $parser->ArithmeticPrimary();
}
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -33,9 +32,9 @@ class JsonAggregate extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->expr = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -13,7 +13,6 @@ namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -38,16 +37,16 @@ class JsonBuildObject extends FunctionNode
public function parse(Parser $parser)
{
$lexer = $parser->getLexer();
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->exprs[] = $parser->ArithmeticPrimary();
while (Lexer::T_COMMA === $lexer->lookahead['type']) {
$parser->match(Lexer::T_COMMA);
while (\Doctrine\ORM\Query\TokenType::T_COMMA === $lexer->lookahead['type']) {
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->exprs[] = $parser->ArithmeticPrimary();
}
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -29,15 +28,15 @@ class JsonExtract extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->element = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->keyToExtract = $parser->ArithmeticExpression();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -33,9 +32,9 @@ class JsonbArrayLength extends FunctionNode
public function parse(Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->expr1 = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -33,11 +32,11 @@ class JsonbExistsInArray extends FunctionNode
public function parse(Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->expr1 = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->expr2 = $parser->InputParameter();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -13,7 +13,6 @@ namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -41,15 +40,15 @@ class Least extends FunctionNode
$this->exprs = [];
$lexer = $parser->getLexer();
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->exprs[] = $parser->ArithmeticPrimary();
while (Lexer::T_COMMA === $lexer->lookahead['type']) {
$parser->match(Lexer::T_COMMA);
while (\Doctrine\ORM\Query\TokenType::T_COMMA === $lexer->lookahead['type']) {
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->exprs[] = $parser->ArithmeticPrimary();
}
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -13,7 +13,6 @@ namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\PathExpression;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
/**
@ -45,29 +44,29 @@ class OverlapsI extends FunctionNode
public function parse(Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->firstPeriodStart = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->firstPeriodEnd = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->secondPeriodStart = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->secondPeriodEnd = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
protected function makeCase($sqlWalker, $part, string $position): string

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
class Replace extends FunctionNode
{
@ -35,19 +34,19 @@ class Replace extends FunctionNode
public function parse(\Doctrine\ORM\Query\Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->string = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->from = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->to = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
/**
* Geometry function 'ST_CONTAINS', added by postgis.
@ -31,15 +30,15 @@ class STContains extends FunctionNode
public function parse(\Doctrine\ORM\Query\Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->firstPart = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->secondPart = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -27,11 +26,11 @@ class STX extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->field = $parser->ArithmeticExpression();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -27,11 +26,11 @@ class STY extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->field = $parser->ArithmeticExpression();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
class Similarity extends FunctionNode
{
@ -28,15 +27,15 @@ class Similarity extends FunctionNode
public function parse(\Doctrine\ORM\Query\Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->firstPart = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->secondPart = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -29,15 +28,15 @@ class StrictWordSimilarityOPS extends \Doctrine\ORM\Query\AST\Functions\Function
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->firstPart = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->secondPart = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
@ -36,11 +35,11 @@ class ToChar extends FunctionNode
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->datetime = $parser->ArithmeticExpression();
$parser->match(Lexer::T_COMMA);
$parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
$this->fmt = $parser->StringExpression();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
/**
* Unaccent string using postgresql extension unaccent :
@ -31,11 +30,11 @@ class Unaccent extends FunctionNode
public function parse(\Doctrine\ORM\Query\Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
$this->string = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@ -0,0 +1,112 @@
<?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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*
* @ORM\Table(name="chill_main_dashboard_config_item")
*/
class DashboardConfigItem
{
/**
* @ORM\Id
*
* @ORM\GeneratedValue
*
* @ORM\Column(type="integer")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="string")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*
* @Assert\NotNull
*/
private string $type = '';
/**
* @ORM\Column(type="string")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*
* @Assert\NotNull
*/
private string $position = '';
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private ?User $user = null;
/**
* @ORM\Column(type="json", options={"default": "[]", "jsonb": true})
*
* @Serializer\Groups({"dashboardConfigItem:read"})
*/
private array $metadata = [];
public function getId(): ?int
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getPosition(): string
{
return $this->position;
}
public function setPosition(string $position): void
{
$this->position = $position;
}
public function getUser(): User
{
return $this->user;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function getMetadata(): array
{
return $this->metadata;
}
public function setMetadata(array $metadata): void
{
$this->metadata = $metadata;
}
}

View File

@ -0,0 +1,128 @@
<?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\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*
* @ORM\Table(name="chill_main_news")
*/
class NewsItem implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Id
*
* @ORM\GeneratedValue
*
* @ORM\Column(type="integer")
*
* @Groups({"read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="text")
*
* @Groups({"read"})
*
* @Assert\NotBlank
*
* @Assert\NotNull
*/
private string $title = '';
/**
* @ORM\Column(type="text")
*
* @Groups({"read"})
*
* @Assert\NotBlank
*
* @Assert\NotNull
*/
private string $content = '';
/**
* @ORM\Column(type="date_immutable", nullable=false)
*
* @Assert\NotNull
*
* @Groups({"read"})
*/
private ?\DateTimeImmutable $startDate = null;
/**
* @ORM\Column(type="date_immutable", nullable=true, options={"default": null})
*
* @Assert\GreaterThanOrEqual(propertyPath="startDate")
*
* @Groups({"read"})
*/
private ?\DateTimeImmutable $endDate = null;
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function getStartDate(): ?\DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(?\DateTimeImmutable $startDate): void
{
$this->startDate = $startDate;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?\DateTimeImmutable $endDate): void
{
$this->endDate = $endDate;
}
public function getId(): ?int
{
return $this->id;
}
}

View File

@ -550,7 +550,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
$this->scopeHistories[] = $newScope;
$criteria = new Criteria();
$criteria->orderBy(['startDate' => Criteria::ASC, 'id' => Criteria::ASC]);
$criteria->orderBy(['startDate' => \Doctrine\Common\Collections\Order::Ascending, 'id' => \Doctrine\Common\Collections\Order::Ascending]);
/** @var \Iterator $scopes */
$scopes = $this->scopeHistories->matching($criteria)->getIterator();
@ -606,7 +606,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
$this->jobHistories[] = $newJob;
$criteria = new Criteria();
$criteria->orderBy(['startDate' => Criteria::ASC, 'id' => Criteria::ASC]);
$criteria->orderBy(['startDate' => \Doctrine\Common\Collections\Order::Ascending, 'id' => \Doctrine\Common\Collections\Order::Ascending]);
/** @var \Iterator $jobs */
$jobs = $this->jobHistories->matching($criteria)->getIterator();

View File

@ -0,0 +1,56 @@
<?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\Form;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NewsItemType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TextType::class, [
'required' => true,
])
->add('content', ChillTextareaType::class, [
'required' => false,
])
->add(
'startDate',
ChillDateType::class,
[
'required' => true,
'input' => 'datetime_immutable',
'label' => 'news.startDate',
]
)
->add('endDate', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'news.endDate',
]);
}
/**
* @return void
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', NewsItem::class);
}
}

View File

@ -0,0 +1,145 @@
<?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\Repository;
use Chill\MainBundle\Entity\NewsItem;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\Clock\ClockInterface;
class NewsItemRepository implements ObjectRepository
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager, private readonly ClockInterface $clock)
{
$this->repository = $entityManager->getRepository(NewsItem::class);
}
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder
{
return $this->repository->createQueryBuilder($alias, $indexBy);
}
public function find($id)
{
return $this->repository->find($id);
}
public function findAll()
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return NewsItem::class;
}
private function buildBaseQuery(
?string $pattern = null
): QueryBuilder {
$qb = $this->createQueryBuilder('n');
$qb->where('n.startDate <= :now');
$qb->setParameter('now', $this->clock->now());
if (null !== $pattern && '' !== $pattern) {
$qb->andWhere($qb->expr()->like('LOWER(UNACCENT(n.title))', 'LOWER(UNACCENT(:pattern))'))
->orWhere($qb->expr()->like('LOWER(UNACCENT(n.content))', 'LOWER(UNACCENT(:pattern))'))
->setParameter('pattern', '%'.$pattern.'%');
}
return $qb;
}
public function findAllFilteredBySearchTerm(?string $pattern = null)
{
$qb = $this->buildBaseQuery($pattern);
$qb
->addOrderBy('n.startDate', 'DESC')
->addOrderBy('n.id', 'DESC');
return $qb->getQuery()->getResult();
}
/**
* @return list<NewsItem>
*/
public function findCurrentNews(?int $limit = null, ?int $offset = null): array
{
$qb = $this->buildQueryCurrentNews();
$qb->addOrderBy('n.startDate', 'DESC');
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
return $qb
->getQuery()
->getResult();
}
public function countAllFilteredBySearchTerm(?string $pattern = null)
{
$qb = $this->buildBaseQuery($pattern);
return $qb
->select('COUNT(n)')
->getQuery()
->getSingleScalarResult();
}
public function countCurrentNews()
{
return $this->buildQueryCurrentNews()
->select('COUNT(n)')
->getQuery()
->getSingleScalarResult();
}
private function buildQueryCurrentNews(): QueryBuilder
{
$now = $this->clock->now();
$qb = $this->createQueryBuilder('n');
$qb
->where(
$qb->expr()->andX(
$qb->expr()->lte('n.startDate', ':now'),
$qb->expr()->orX(
$qb->expr()->gt('n.endDate', ':now'),
$qb->expr()->isNull('n.endDate')
)
)
)
->setParameter('now', $now);
return $qb;
}
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
@ -20,7 +21,7 @@ final class ScopeRepository implements ScopeRepositoryInterface
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(EntityManagerInterface $entityManager, private readonly TranslatableStringHelperInterface $translatableStringHelper)
{
$this->repository = $entityManager->getRepository(Scope::class);
}
@ -45,11 +46,11 @@ final class ScopeRepository implements ScopeRepositoryInterface
public function findAllActive(): array
{
$qb = $this->repository->createQueryBuilder('s');
$scopes = $this->repository->findBy(['active' => true]);
$qb->where('s.active = \'TRUE\'');
usort($scopes, fn (Scope $a, Scope $b) => $this->translatableStringHelper->localize($a->getName()) <=> $this->translatableStringHelper->localize($b->getName()));
return $qb->getQuery()->getResult();
return $scopes;
}
/**

View File

@ -40,7 +40,11 @@ readonly class UserJobRepository implements UserJobRepositoryInterface
public function findAllActive(): array
{
return $this->repository->findBy(['active' => true]);
$jobs = $this->repository->findBy(['active' => true]);
usort($jobs, fn (UserJob $a, UserJob $b) => $this->translatableStringHelper->localize($a->getLabel()) <=> $this->translatableStringHelper->localize($b->getLabel()));
return $jobs;
}
public function findAllOrderedByName(): array

View File

@ -106,8 +106,8 @@ final readonly class UserRepository implements UserRepositoryInterface
FROM users u
LEFT JOIN chill_main_civility civility ON u.civility_id = civility.id
LEFT JOIN centers mainCenter ON u.maincenter_id = mainCenter.id
LEFT JOIN chill_main_user_job_history userJobHistory ON u.id = userJobHistory.user_id
LEFT JOIN chill_main_user_job userJob ON userJobHistory.job_id = userJob.id AND tstzrange(userJobHistory.startdate, userJobHistory.enddate) @> NOW()
LEFT JOIN chill_main_user_job_history userJobHistory ON u.id = userJobHistory.user_id AND tstzrange(userJobHistory.startdate, userJobHistory.enddate) @> NOW()
LEFT JOIN chill_main_user_job userJob ON userJobHistory.job_id = userJob.id
LEFT JOIN chill_main_user_scope_history userScopeHistory ON u.id = userScopeHistory.user_id AND tstzrange(userScopeHistory.startdate, userScopeHistory.enddate) @> NOW()
LEFT JOIN scopes mainScope ON userScopeHistory.scope_id = mainScope.id
LEFT JOIN chill_main_location currentLocation ON u.currentlocation_id = currentLocation.id

View File

@ -0,0 +1 @@
import './index.scss';

View File

@ -0,0 +1,7 @@
div.flex-table {
.news-content {
p {
margin-top: 1rem;
}
}
}

View File

@ -160,3 +160,11 @@ export interface LocationType {
contactData: "optional" | "required";
title: TranslatableString;
}
export interface NewsItemType {
id: number;
title: string;
content: string;
startDate: DateTime;
endDate: DateTime | null;
}

View File

@ -97,6 +97,8 @@ import MyNotifications from './MyNotifications';
import MyWorkflows from './MyWorkflows.vue';
import TabCounter from './TabCounter';
import { mapState } from "vuex";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export default {
name: "App",
@ -112,7 +114,7 @@ export default {
},
data() {
return {
activeTab: 'MyCustoms'
activeTab: 'MyCustoms',
}
},
computed: {
@ -126,8 +128,11 @@ export default {
},
methods: {
selectTab(tab) {
this.$store.dispatch('getByTab', { tab: tab });
if (tab !== 'MyCustoms') {
this.$store.dispatch('getByTab', { tab: tab });
}
this.activeTab = tab;
console.log(this.activeTab)
}
},
mounted() {

View File

@ -0,0 +1,47 @@
<template>
<div>
<h1>{{ $t('widget.news.title') }}</h1>
<ul v-if="newsItems.length > 0" class="scrollable">
<NewsItem v-for="item in newsItems" :item="item" :key="item.id" />
</ul>
<p v-if="newsItems.length === 0 " class="chill-no-data-statement">{{ $t('widget.news.none') }}</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchResults } from '../../../lib/api/apiMethods';
import Modal from '../../_components/Modal.vue';
import { NewsItemType } from '../../../types';
import NewsItem from './NewsItem.vue';
const newsItems = ref<NewsItemType[]>([])
onMounted(() => {
fetchResults<NewsItemType>('/api/1.0/main/news/current.json')
.then((news): Promise<void> => {
// console.log('news articles', response.results)
newsItems.value = news;
return Promise.resolve();
})
.catch((error: string) => {
console.error('Error fetching news items', error);
})
})
</script>
<style scoped>
ul {
list-style: none;
padding: 0;
}
h1 {
text-align: center;
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<li>
<h2>{{ props.item.title }}</h2>
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time>
<div class="content" v-if="shouldTruncate(item.content)">
<div v-html="prepareContent(item.content)"></div>
<div class="float-end">
<button class="btn btn-sm btn-show read-more" @click="() => openModal(item)">{{ $t('widget.news.readMore') }}</button>
</div>
</div>
<div class="content" v-else>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</div>
<modal v-if="showModal" @close="closeModal">
<template #header>
<p class="news-title">{{ item.title }}</p>
</template>
<template #body>
<p class="news-date">
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time>
</p>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</template>
</modal>
</li>
</template>
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { DateTime, NewsItemType } from "../../../types";
import type { PropType } from 'vue'
import { ref } from "vue";
import {ISOToDatetime} from '../../../chill/js/date';
const props = defineProps({
item: {
type: Object as PropType<NewsItemType>,
required: true
},
maxLength: {
type: Number,
required: false,
default: 350,
},
maxLines: {
type: Number,
required: false,
default: 3
}
})
const selectedArticle = ref<NewsItemType | null>(null);
const showModal = ref(false);
const openModal = (item: NewsItemType) => {
selectedArticle.value = item;
showModal.value = true;
};
const closeModal = () => {
selectedArticle.value = null;
showModal.value = false;
};
const shouldTruncate = (content: string): boolean => {
const lines = content.split('\n');
// Check if any line exceeds the maximum length
const tooManyLines = lines.length > props.maxLines;
return content.length > props.maxLength || tooManyLines;
};
const truncateContent = (content: string): string => {
let truncatedContent = content.slice(0, props.maxLength);
let linkDepth = 0;
let linkStartIndex = -1;
const lines = content.split('\n');
// Truncate if amount of lines are too many
if (lines.length > props.maxLines && content.length < props.maxLength) {
const truncatedContent = lines.slice(0, props.maxLines).join('\n').trim();
return truncatedContent + '...';
}
for (let i = 0; i < truncatedContent.length; i++) {
const char = truncatedContent[i];
if (char === '[') {
linkDepth++;
if (linkDepth === 1) {
linkStartIndex = i;
}
} else if (char === ']') {
linkDepth = Math.max(0, linkDepth - 1);
} else if (char === '(' && linkDepth === 0) {
truncatedContent = truncatedContent.slice(0, i);
break;
}
}
while (linkDepth > 0) {
truncatedContent += ']';
linkDepth--;
}
// If a link was found, append the URL inside the parentheses
if (linkStartIndex !== -1) {
const linkEndIndex = content.indexOf(')', linkStartIndex);
const url = content.slice(linkStartIndex + 1, linkEndIndex);
truncatedContent = truncatedContent.slice(0, linkStartIndex) + `(${url})`;
}
truncatedContent += '...';
return truncatedContent;
};
const preprocess = (markdown: string): string => {
return markdown;
}
const postprocess = (html: string): string => {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if ('target' in node) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
node.setAttribute('xlink:show', 'new');
}
})
return DOMPurify.sanitize(html);
}
const convertMarkdownToHtml = (markdown: string): string => {
marked.use({'hooks': {postprocess, preprocess}});
const rawHtml = marked(markdown);
return rawHtml;
};
const prepareContent = (content: string): string => {
const htmlContent = convertMarkdownToHtml(content);
return truncateContent(htmlContent);
};
const newsItemStartDate = (): null|Date => {
return ISOToDatetime(props.item?.startDate.datetime);
}
</script>
<style scoped>
li {
margin-bottom: 20px;
overflow: hidden;
padding: .8rem;
background-color: #fbfbfb;
border-radius: 4px;
}
h2 {
font-size: 1rem !important;
text-transform: uppercase;
}
.content {
overflow: hidden;
font-size: .9rem;
position: relative;
}
.news-title {
font-weight: bold;
}
</style>

View File

@ -1,76 +1,73 @@
<template>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_dashboard') }}</span>
<div v-else id="dashboards" class="row g-3" data-masonry='{"percentPosition": true }'>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom1">
<ul class="list-unstyled">
<li v-if="counter.notifications > 0">
<i18n-t keypath="counter.unread_notifications" tag="span" :class="counterClass" :plural="counter.notifications">
<template v-slot:n><span>{{ counter.notifications }}</span></template>
</i18n-t>
</li>
<li v-if="counter.accompanyingCourses > 0">
<i18n-t keypath="counter.assignated_courses" tag="span" :class="counterClass" :plural="counter.accompanyingCourses">
<template v-slot:n><span>{{ counter.accompanyingCourses }}</span></template>
</i18n-t>
</li>
<li v-if="counter.works > 0">
<i18n-t keypath="counter.assignated_actions" tag="span" :class="counterClass" :plural="counter.works">
<template v-slot:n><span>{{ counter.works }}</span></template>
</i18n-t>
</li>
<li v-if="counter.evaluations > 0">
<i18n-t keypath="counter.assignated_evaluations" tag="span" :class="counterClass" :plural="counter.evaluations">
<template v-slot:n><span>{{ counter.evaluations }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksAlert > 0">
<i18n-t keypath="counter.alert_tasks" tag="span" :class="counterClass" :plural="counter.tasksAlert">
<template v-slot:n><span>{{ counter.tasksAlert }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksWarning > 0">
<i18n-t keypath="counter.warning_tasks" tag="span" :class="counterClass" :plural="counter.tasksWarning">
<template v-slot:n><span>{{ counter.tasksWarning }}</span></template>
</i18n-t>
</li>
</ul>
</div>
</div>
<!--
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom2">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom3">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom4">
Mon dashboard personnalisé
</div>
</div>
-->
<div v-else id="dashboards" class="container g-3">
<div class="row">
<div class="mbloc col-xs-12 col-sm-4">
<div class="custom1">
<ul class="list-unstyled">
<li v-if="counter.notifications > 0">
<i18n-t keypath="counter.unread_notifications" tag="span" :class="counterClass" :plural="counter.notifications">
<template v-slot:n><span>{{ counter.notifications }}</span></template>
</i18n-t>
</li>
<li v-if="counter.accompanyingCourses > 0">
<i18n-t keypath="counter.assignated_courses" tag="span" :class="counterClass" :plural="counter.accompanyingCourses">
<template v-slot:n><span>{{ counter.accompanyingCourses }}</span></template>
</i18n-t>
</li>
<li v-if="counter.works > 0">
<i18n-t keypath="counter.assignated_actions" tag="span" :class="counterClass" :plural="counter.works">
<template v-slot:n><span>{{ counter.works }}</span></template>
</i18n-t>
</li>
<li v-if="counter.evaluations > 0">
<i18n-t keypath="counter.assignated_evaluations" tag="span" :class="counterClass" :plural="counter.evaluations">
<template v-slot:n><span>{{ counter.evaluations }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksAlert > 0">
<i18n-t keypath="counter.alert_tasks" tag="span" :class="counterClass" :plural="counter.tasksAlert">
<template v-slot:n><span>{{ counter.tasksAlert }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksWarning > 0">
<i18n-t keypath="counter.warning_tasks" tag="span" :class="counterClass" :plural="counter.tasksWarning">
<template v-slot:n><span>{{ counter.tasksWarning }}</span></template>
</i18n-t>
</li>
</ul>
</div>
</div>
<template v-if="this.hasDashboardItems">
<template v-for="dashboardItem in this.dashboardItems">
<div class="mbloc col-xs-12 col-sm-8 news" v-if="dashboardItem.type === 'news'">
<News />
</div>
</template>
</template>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import Masonry from 'masonry-layout/masonry';
import {makeFetch} from "ChillMainAssets/lib/api/apiMethods";
import News from './DashboardWidgets/News.vue';
export default {
name: "MyCustoms",
components: {
News
},
data() {
return {
counterClass: {
counter: true //hack to pass class 'counter' in i18n-t
}
},
dashboardItems: [],
masonry: null,
}
},
computed: {
@ -78,11 +75,19 @@ export default {
noResults() {
return false
},
hasDashboardItems() {
return this.dashboardItems.length > 0;
}
},
mounted() {
const elem = document.querySelector('#dashboards');
const masonry = new Masonry(elem, {});
}
makeFetch('GET', '/api/1.0/main/dashboard-config-item.json')
.then((response) => {
this.dashboardItems = response;
})
.catch((error) => {
throw error
});
},
}
</script>
@ -98,4 +103,10 @@ span.counter {
background-color: unset;
}
}
</style>
div.news {
max-height: 22rem;
overflow: hidden;
overflow-y: scroll;
}
</style>

View File

@ -63,7 +63,15 @@ const appMessages = {
},
emergency: "Urgent",
confidential: "Confidentiel",
automatic_notification: "Notification automatique"
automatic_notification: "Notification automatique",
widget: {
news: {
title: "Actualités",
readMore: "Lire la suite",
date: "Date",
none: "Aucune actualité"
}
}
}
};

View File

@ -96,13 +96,11 @@ const store = createStore({
},
catchError(state, error) {
state.errorMsg.push(error);
}
},
},
actions: {
getByTab({ commit, getters }, { tab, param }) {
switch (tab) {
case 'MyCustoms':
break;
// case 'MyWorks':
// if (!getters.isWorksLoaded) {
// commit('setLoading', true);
@ -221,8 +219,8 @@ const store = createStore({
default:
throw 'tab '+ tab;
}
}
},
},
});
export { store };
export { store };

View File

@ -15,18 +15,20 @@ import AddressModal from "./AddressModal.vue";
export interface AddressModalContentProps {
address_id: number;
address_ref_status: AddressRefStatus | null;
address_ref_status: AddressRefStatus;
}
const data = reactive<{
loading: boolean,
working_address: Address | null,
working_ref_status: AddressRefStatus | null,
}>({
interface AddressModalData {
loading: boolean,
working_address: Address | null,
working_ref_status: AddressRefStatus | null,
}
const data: AddressModalData = reactive({
loading: false,
working_address: null,
working_ref_status: null,
});
} as AddressModalData);
const props = defineProps<AddressModalContentProps>();

View File

@ -51,7 +51,7 @@ const messages = {
years_old: "1 an | {n} an | {n} ans",
residential_address: "Adresse de résidence",
located_at: "réside chez"
}
},
}
};

View File

@ -0,0 +1,13 @@
{% extends "@ChillMain/Admin/layoutWithVerticalMenu.html.twig" %}
{% block vertical_menu_content %}
{{ chill_menu('admin_news_item', {
'layout': '@ChillMain/Admin/menu_admin_section.html.twig',
}) }}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}<!-- block content empty -->
<h1>{{ 'admin.dashboard.description' | trans }}</h1>
{% endblock %}
{% endblock %}

View File

@ -1 +1 @@
{{ 'crud.%crud_name%.title_view'|trans({'%crud_name%' : crud_name }) }}
{{ ('crud.' ~ crud_name ~ '.title_view')|trans({'%crud_name%' : crud_name }) }}

Some files were not shown because too many files have changed in this diff Show More