Compare commits

...

25 Commits

Author SHA1 Message Date
e2dec28577 Release v4.14.0
- Implemented `ReferrerMainCenterAggregatorTest` to validate data transformation and query logic.
- Added data providers and query builders to ensure comprehensive test coverage.
- Verified correct handling of rolling dates and aggregator logic.
2026-03-09 12:27:00 +01:00
30385da409 Merge branch '486-user-center-filter-aggregator' into 'master'
Resolve "Create a filter/aggregator by user center for the exports"

Closes #486

See merge request Chill-Projet/chill-bundles!946
2026-03-09 11:19:38 +00:00
562fecb4aa Resolve "Create a filter/aggregator by user center for the exports" 2026-03-09 11:19:38 +00:00
8e8f459f90 Merge branch '503-reassign-ui-message' into 'master'
Resolve "Lors de la ré-assignation des parcours, l'UI ne mentionne pas qu'une opération a été réalisée"

Closes #503

See merge request Chill-Projet/chill-bundles!969
2026-03-09 09:25:08 +00:00
5de3862ec2 Resolve "Lors de la ré-assignation des parcours, l'UI ne mentionne pas qu'une opération a été réalisée" 2026-03-09 09:25:08 +00:00
26838648c8 Merge branch '502-fix-import-postal-code-removed' into 'master'
Resolve "Lors de l'import de code postaux, les codes absents de l'import depuis la même source ne sont pas supprimés"

Closes #502

See merge request Chill-Projet/chill-bundles!968
2026-02-23 20:05:05 +00:00
030553a4de Resolve "Lors de l'import de code postaux, les codes absents de l'import depuis la même source ne sont pas supprimés" 2026-02-23 20:05:04 +00:00
966f9f7e33 Release v4.13.0 2026-02-23 17:13:24 +01:00
7a5300b713 Merge branch '495-fix-quote-notification-email' into 'master'
Remove unused method `sendNotificationEmailsToAddressesEmails` from `NotificationMailer`

Closes #495

See merge request Chill-Projet/chill-bundles!967
2026-02-23 15:49:39 +00:00
dc3a585e5b Remove unused method sendNotificationEmailsToAddressesEmails from NotificationMailer 2026-02-23 15:49:39 +00:00
7712d76889 Merge branch '494-titre-toute-la-journée-tronqué-sur-la-page-mes-rendez-vous' into 'master'
Resolve "Titre 'Toute la journée' tronqué sur la page Mes Rendez-vous"

Closes #494

See merge request Chill-Projet/chill-bundles!965
2026-02-23 15:08:48 +00:00
Boris Waaub
69bb7026c9 Resolve "Titre 'Toute la journée' tronqué sur la page Mes Rendez-vous" 2026-02-23 15:08:48 +00:00
acd7240903 Merge branch '501-fix-deprecation-markdown-parser' into 'master'
Resolve "Depréciation dans le paquet de transformation markdown"

Closes #501

See merge request Chill-Projet/chill-bundles!966
2026-02-23 14:50:15 +00:00
22049558da Resolve "Depréciation dans le paquet de transformation markdown" 2026-02-23 14:50:14 +00:00
c0f2f3f3e0 Merge branch '500-limit-public-download' into 'master'
Resolve "Téléchargement des documents d'un workflow: limiter à 30 téléchargements plutôt que 100"

Closes #500

See merge request Chill-Projet/chill-bundles!964
2026-02-23 14:24:53 +00:00
bf56b3cc65 Resolve "Téléchargement des documents d'un workflow: limiter à 30 téléchargements plutôt que 100" 2026-02-23 14:24:53 +00:00
f85973f7ae Merge branch '499-fix-loading-postal-code' into 'master'
Resolve "Des codes postaux marqués comme supprimés apparaissent toujours dans la recherche d'adresse"

Closes #499

See merge request Chill-Projet/chill-bundles!963
2026-02-23 14:16:46 +00:00
f1446d7abe Resolve "Des codes postaux marqués comme supprimés apparaissent toujours dans la recherche d'adresse" 2026-02-23 14:16:45 +00:00
76d675ac02 Fixed translations of address in exports (addresse -> adresse) 2026-02-17 14:11:15 +01:00
cf0a2b7393 Merge branch '438-parcours-designer-comme-adresse-du-parcours-to-be-green' into 'master'
Resolve "Parcours - "Désigner comme adresse du parcours" to be green"

Closes #438

See merge request Chill-Projet/chill-bundles!958
2026-02-12 08:50:00 +00:00
Boris Waaub
80b05a8133 Resolve "Parcours - "Désigner comme adresse du parcours" to be green" 2026-02-12 08:50:00 +00:00
69aba8d9c9 Merge branch 'changie/add-mr-to-question' into 'master'
Changie/add mr to question

See merge request Chill-Projet/chill-bundles!960
2026-02-11 13:27:48 +00:00
a87d936828 Changie/add mr to question 2026-02-11 13:27:48 +00:00
290fa7a77c Merge branch '498-fix-workflow-initiator' into 'master'
Take workflow creator into account when granting edit permissions on documents

Closes #498

See merge request Chill-Projet/chill-bundles!959
2026-02-10 15:05:50 +00:00
0e1d233d79 Take workflow creator into account when granting edit permissions on documents 2026-02-10 15:05:49 +00:00
35 changed files with 890 additions and 115 deletions

15
.changes/v4.13.0.md Normal file
View File

@@ -0,0 +1,15 @@
## v4.13.0 - 2026-02-23
### Feature
* ([#500](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/500)) ([!964](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/964)) Limit the number of public download of stored object to 30 downloads
* ([#495](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/495)) ([!967](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/967)) Send email related to notification in both html and txt format, and render quote correctly
### Fixed
* ([#438](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/438)) Change wrong color of submit button "Désigner comme adresse du parcours"
* ([#498](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/498)) For giving edit permissions on documents, take into account the workflow creator
* Fixed mispelling of address in translations: addresse -> adresse
* ([#499](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/499)) ([!963](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/963)) Fix: some postal code appears in the UI, although they are marked as deleted
* ([#501](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/501)) ([!966](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/966)) Fix deprecation in the markdown rendering
* ([#494](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/494)) ([!965](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/965)) Remove unused all-day slot display
### DX
* ([!960](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/960)) Configure changie to ask for merge request number for a better tracking of changes

6
.changes/v4.14.0.md Normal file
View File

@@ -0,0 +1,6 @@
## v4.14.0 - 2026-03-09
### Feature
* ([#486](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/486)) ([!<no value>](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/<no value>)) Add filter and aggregator based on referrer's main center for exports of accompanying period
### Fixed
* ([#502](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/502)) ([!968](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/968)) Fix import of postal code: mark postal code as deleted if they are not present in the import any more
* ([#503](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/503)) ([!969](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/969)) Add a flash message when reassigning accompanying course (reassign list)

View File

@@ -7,7 +7,7 @@ versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}'
kindFormat: '### {{.Kind}}' kindFormat: '### {{.Kind}}'
# Note: it is possible to add a `.custom.Long` text manually into the yaml file produced by `changie new`. This will add a long description. # Note: it is possible to add a `.custom.Long` text manually into the yaml file produced by `changie new`. This will add a long description.
changeFormat: >- changeFormat: >-
* {{ if not (eq .Custom.Issue "") }}([#{{ .Custom.Issue }}](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/{{ .Custom.Issue }})) {{ end }}{{ .Body }} {{ if and .Custom.SchemaChange (ne .Custom.SchemaChange "No schema change") }} * {{ if not (eq .Custom.Issue "") }}([#{{ .Custom.Issue }}](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/{{ .Custom.Issue }})) {{ end }}{{ if not (eq .Custom.MR "") }}([!{{ .Custom.MR }}](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/{{ .Custom.MR }})) {{ end }}{{ .Body }} {{ if and .Custom.SchemaChange (ne .Custom.SchemaChange "No schema change") }}
**Schema Change**: {{ .Custom.SchemaChange }} **Schema Change**: {{ .Custom.SchemaChange }}
{{- end -}} {{- end -}}
@@ -30,6 +30,12 @@ custom:
type: int type: int
minInt: 1 minInt: 1
- key: MR
label: Merge request number (on chill-bundles repository) (optional)
optional: true
type: int
minInt: 1
body: body:
# allow multiline messages # allow multiline messages
block: true block: true

View File

@@ -238,13 +238,13 @@ The tests are run from the project's root (not from the bundle's root).
```bash ```bash
# Run all tests # Run all tests
vendor/bin/phpunit symfony composer exec phpunit
# Run a specific test file # Run a specific test file
vendor/bin/phpunit path/to/TestFile.php symfony composer exec phpunit -- path/to/TestFile.php
# Run a specific test method # Run a specific test method
vendor/bin/phpunit --filter methodName path/to/TestFile.php symfony composer exec phpunit --filter methodName path/to/TestFile.php
``` ```
When writing tests, only test specific files. Do not run all tests or the full When writing tests, only test specific files. Do not run all tests or the full

View File

@@ -6,6 +6,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.14.0 - 2026-03-09
### Feature
* ([#486](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/486)) ([!<no value>](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/<no value>)) Add filter and aggregator based on referrer's main center for exports of accompanying period
### Fixed
* ([#502](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/502)) ([!968](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/968)) Fix import of postal code: mark postal code as deleted if they are not present in the import any more
* ([#503](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/503)) ([!969](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/969)) Add a flash message when reassigning accompanying course (reassign list)
## v4.13.0 - 2026-02-23
### Feature
* ([#500](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/500)) ([!964](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/964)) Limit the number of public download of stored object to 30 downloads
* ([#495](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/495)) ([!967](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/967)) Send email related to notification in both html and txt format, and render quote correctly
### Fixed
* ([#438](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/438)) Change wrong color of submit button "Désigner comme adresse du parcours"
* ([#498](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/498)) For giving edit permissions on documents, take into account the workflow creator
* Fixed mispelling of address in translations: addresse -> adresse
* ([#499](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/499)) ([!963](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/963)) Fix: some postal code appears in the UI, although they are marked as deleted
* ([#501](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/501)) ([!966](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/966)) Fix deprecation in the markdown rendering
* ([#494](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/494)) ([!965](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/965)) Remove unused all-day slot display
### DX
* ([!960](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/960)) Configure changie to ask for merge request number for a better tracking of changes
## v4.12.1 - 2026-02-01 ## v4.12.1 - 2026-02-01
### Fixed ### Fixed
* ([#496](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/496)) Add the option to deal with duplicate address in BAN adress importer * ([#496](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/496)) Add the option to deal with duplicate address in BAN adress importer

View File

@@ -346,6 +346,7 @@ const baseOptions = ref<CalendarOptions>({
center: "title", center: "title",
right: "timeGridWeek,timeGridDay", right: "timeGridWeek,timeGridDay",
}, },
allDaySlot: false,
}); });
const ranges = computed<EventInput[]>(() => { const ranges = computed<EventInput[]>(() => {

View File

@@ -79,5 +79,7 @@ final class PostalCodeAPIController extends ApiController
$qb->andWhere('e.origin = :zero') $qb->andWhere('e.origin = :zero')
->setParameter('zero', 0); ->setParameter('zero', 0);
$qb->andWhere('e.deletedAt IS NULL');
} }
} }

View File

@@ -62,15 +62,15 @@ final readonly class WorkflowViewSendPublicController
); );
} }
if (100 < $workflowSend->getViews()->count()) { if (30 < $workflowSend->getViews()->count()) {
$this->chillLogger->info(self::LOG_PREFIX.'100 view reached, not allowed to see it again'); $this->chillLogger->info(self::LOG_PREFIX.'30 view reached, not allowed to see it again');
throw new AccessDeniedHttpException('100 views reached, not allowed to see it again'); throw new AccessDeniedHttpException('30 views reached, not allowed to see it again');
} }
try { try {
$metadata = new EntityWorkflowViewMetadataDTO( $metadata = new EntityWorkflowViewMetadataDTO(
$workflowSend->getViews()->count(), $workflowSend->getViews()->count(),
100 - $workflowSend->getViews()->count(), 30 - $workflowSend->getViews()->count(),
); );
$response = new Response( $response = new Response(
$this->entityWorkflowManager->renderPublicView($workflowSend, $metadata), $this->entityWorkflowManager->renderPublicView($workflowSend, $metadata),

View File

@@ -215,4 +215,14 @@ class PostalCode implements TrackUpdateInterface, TrackCreationInterface
return $this; return $this;
} }
public function isDeleted(): bool
{
return null !== $this->deletedAt;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
} }

View File

@@ -394,6 +394,10 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
public function isUserInvolved(User $user): bool public function isUserInvolved(User $user): bool
{ {
if ($this->getCreatedBy() === $user) {
return true;
}
foreach ($this->getSteps() as $step) { foreach ($this->getSteps() as $step) {
if ($step->getAllDestUser()->contains($user)) { if ($step->getAllDestUser()->contains($user)) {
return true; return true;

View File

@@ -59,7 +59,8 @@ readonly class NotificationMailer
$email $email
->to($dest->getEmail()) ->to($dest->getEmail())
->subject('Re: '.$comment->getNotification()->getTitle()) ->subject('Re: '.$comment->getNotification()->getTitle())
->textTemplate('@ChillMain/Notification/email_notification_comment_persist.md.twig') ->textTemplate('@ChillMain/Notification/email_notification_comment_persist.txt.twig')
->htmlTemplate('@ChillMain/Notification/email_notification_comment_persist.md.twig')
->context([ ->context([
'comment' => $comment, 'comment' => $comment,
'dest' => $dest, 'dest' => $dest,
@@ -83,7 +84,6 @@ readonly class NotificationMailer
public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void
{ {
$this->sendNotificationEmailsToAddressees($notification); $this->sendNotificationEmailsToAddressees($notification);
$this->sendNotificationEmailsToAddressesEmails($notification);
} }
private function sendNotificationEmailsToAddressees(Notification $notification): void private function sendNotificationEmailsToAddressees(Notification $notification): void
@@ -149,7 +149,8 @@ readonly class NotificationMailer
} else { } else {
$email = new TemplatedEmail(); $email = new TemplatedEmail();
$email $email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig') ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.txt.twig')
->htmlTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig')
->context([ ->context([
'notification' => $notification, 'notification' => $notification,
'dest' => $addressee, 'dest' => $addressee,
@@ -186,7 +187,8 @@ readonly class NotificationMailer
} else { } else {
$email = new TemplatedEmail(); $email = new TemplatedEmail();
$email $email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig') ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.txt.twig')
->htmlTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig')
->context([ ->context([
'notification' => $notification, 'notification' => $notification,
'dest' => $addressee, 'dest' => $addressee,
@@ -286,38 +288,4 @@ readonly class NotificationMailer
throw $e; throw $e;
} }
} }
private function sendNotificationEmailsToAddressesEmails(Notification $notification): void
{
foreach ($notification->getAddresseeUserGroups() as $userGroup) {
if (!$userGroup->hasEmail()) {
continue;
}
$emailAddress = $userGroup->getEmail();
$email = new TemplatedEmail();
$email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.md.twig')
->context([
'notification' => $notification,
'dest' => $emailAddress,
]);
$email
->subject($notification->getTitle())
->to($emailAddress);
try {
$this->mailer->send($email);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] could not send an email notification', [
'to' => $emailAddress,
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
]);
}
}
}
} }

View File

@@ -100,7 +100,9 @@ final readonly class PostalCodeRepository implements PostalCodeRepositoryInterfa
$query $query
->setFromClause('chill_main_postal_code cmpc') ->setFromClause('chill_main_postal_code cmpc')
->andWhereClause('cmpc.origin = 0'); ->andWhereClause('cmpc.origin = 0')
->andWhereClause('cmpc.deletedAt IS NULL')
;
if (null !== $country) { if (null !== $country) {
$query->andWhereClause('cmpc.country_id = ?', [$country->getId()]); $query->andWhereClause('cmpc.country_id = ?', [$country->getId()]);

View File

@@ -1,20 +1,17 @@
{% apply markdown_to_html %}
{{ dest.label }}, {{ dest.label }},
{{ notification.sender.label }} a créé une notification pour vous: {{ notification.sender.label }} a créé une notification pour vous:
> {{ notification.title }} **Titre de la notification**: {{ notification.title }}
>
> {% for line in notification.message|split("\n") %}
{%- for line in notification.message|split("\n") %}
> {{ line }} > {{ line }}
{%- if not loop.last %} {% endfor %}
>
{%- endif %}
{%- endfor %}
Vous pouvez visualiser la notification et y répondre ici: [Vous pouvez visualiser la notification et y répondre ici.]({{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': notification.id }, false)) }})
{{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': notification.id }, false)) }} -----
--
Le logiciel Chill Le logiciel Chill
{% endapply %}

View File

@@ -0,0 +1,14 @@
{{ dest.label }},
{{ notification.sender.label }} a créé une notification pour vous:
Titre de la notification: {{ notification.title }}
{% for line in notification.message|split("\n") %}
> {{ line|raw }}
{% endfor %}
Vous pouvez visualiser la notification et y répondre ici: {{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': notification.id }, false)) }}.
--
Le logiciel Chill

View File

@@ -1,20 +0,0 @@
{{ dest }},
{{ notification.sender.label }} a créé une notification pour vous:
> {{ notification.title }}
>
>
{%- for line in notification.message|split("\n") %}
> {{ line }}
{%- if not loop.last %}
>
{%- endif %}
{%- endfor %}
Vous pouvez cliquer sur ce lien pour obtenir un accès permanent à la notification:
{{ absolute_url(path('chill_main_notification_grant_access_by_access_key', {'_locale': 'fr', 'id': notification.id, 'accessKey': notification.accessKey, 'email': dest})) }}
--
Le logiciel Chill

View File

@@ -1,3 +1,4 @@
{% apply markdown_to_html %}
{{ dest.label }}, {{ dest.label }},
{{ comment.createdBy.label }} a créé un commentaire sur la notification "{{ comment.notification.title }}". {{ comment.createdBy.label }} a créé un commentaire sur la notification "{{ comment.notification.title }}".
@@ -6,14 +7,11 @@ Commentaire:
{% for line in comment.content|split("\n") %} {% for line in comment.content|split("\n") %}
> {{ line }} > {{ line }}
{%- if not loop.last %} {% endfor %}
>
{%- endif %}
{%- endfor %}
Vous pouvez visualiser la notification et y répondre ici: [Vous pouvez visualiser la notification et y répondre ici.]({{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': comment.notification.id }, false)) }})
{{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': comment.notification.id }, false)) }} ----
--
Le logiciel Chill Le logiciel Chill
{% endapply %}

View File

@@ -0,0 +1,14 @@
{{ dest.label }},
{{ comment.createdBy.label }} a créé un commentaire sur la notification "{{ comment.notification.title }}".
Commentaire:
{% for line in comment.content|split("\n") %}
> {{ line }}
{%- endfor %}
Vous pouvez visualiser la notification et y répondre ici: {{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': comment.notification.id }, false)) }}
--
Le logiciel Chill

View File

@@ -19,31 +19,66 @@ use Doctrine\DBAL\Statement;
*/ */
class PostalCodeBaseImporter class PostalCodeBaseImporter
{ {
private const QUERY = <<<'SQL' private const CREATE_TEMP_TABLE = <<<'SQL'
CREATE TEMPORARY TABLE chill_main_postal_code_temp (
countrycode VARCHAR(10),
label VARCHAR(255),
code VARCHAR(100),
refpostalcodeid VARCHAR(255),
postalcodeSource VARCHAR(255),
lon FLOAT,
lat FLOAT,
srid INT
)
SQL;
private const INSERT_TEMP = <<<'SQL'
INSERT INTO chill_main_postal_code_temp
(countrycode, label, code, refpostalcodeid, postalcodeSource, lon, lat, srid)
VALUES
{{ values }}
SQL;
private const UPSERT = <<<'SQL'
WITH g AS ( WITH g AS (
SELECT DISTINCT SELECT DISTINCT
country.id AS country_id, country.id AS country_id,
g.* temp.*
FROM (VALUES FROM chill_main_postal_code_temp temp
{{ values }} JOIN country ON country.countrycode = temp.countrycode
) AS g (countrycode, label, code, refpostalcodeid, postalcodeSource, lon, lat, srid)
JOIN country ON country.countrycode = g.countrycode
) )
INSERT INTO chill_main_postal_code (id, country_id, label, code, origin, refpostalcodeid, postalcodeSource, center, createdAt, updatedAt) INSERT INTO chill_main_postal_code (id, country_id, label, code, origin, refpostalcodeid, postalcodeSource, center, createdAt, updatedAt, deletedAt)
SELECT SELECT
nextval('chill_main_postal_code_id_seq'), nextval('chill_main_postal_code_id_seq'),
g.country_id, g.country_id,
g.label AS glabel, g.label,
g.code, g.code,
0, 0,
g.refpostalcodeid, g.refpostalcodeid,
g.postalcodeSource, g.postalcodeSource,
CASE WHEN (g.lon::float != 0.0 AND g.lat::float != 0.0) THEN ST_Transform(ST_setSrid(ST_point(g.lon::float, g.lat::float), g.srid::int), 4326) ELSE NULL END, CASE WHEN (g.lon != 0.0 AND g.lat != 0.0) THEN ST_Transform(ST_setSrid(ST_point(g.lon, g.lat), g.srid), 4326) ELSE NULL END,
NOW(), NOW(),
NOW() NOW(),
NULL
FROM g FROM g
ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE
SET label = excluded.label, center = excluded.center, updatedAt = CASE WHEN NOT st_equals(excluded.center, chill_main_postal_code.center) OR excluded.label != chill_main_postal_code.label THEN NOW() ELSE chill_main_postal_code.updatedAt END SET label = excluded.label,
center = excluded.center,
deletedAt = NULL,
updatedAt = CASE WHEN NOT st_equals(excluded.center, chill_main_postal_code.center) OR excluded.label != chill_main_postal_code.label OR chill_main_postal_code.deletedAt IS NOT NULL THEN NOW() ELSE chill_main_postal_code.updatedAt END
SQL;
private const DELETE_MISSING = <<<'SQL'
UPDATE chill_main_postal_code
SET deletedAt = NOW(), updatedAt = NOW()
WHERE postalcodeSource = ?
AND deletedAt IS NULL
AND NOT EXISTS (
SELECT 1 FROM chill_main_postal_code_temp temp
WHERE temp.code = chill_main_postal_code.code
AND temp.refpostalcodeid = chill_main_postal_code.refpostalcodeid
AND temp.postalcodeSource = chill_main_postal_code.postalcodeSource
)
SQL; SQL;
private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)'; private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)';
@@ -55,11 +90,26 @@ class PostalCodeBaseImporter
private array $waitingForInsert = []; private array $waitingForInsert = [];
private bool $isInitialized = false;
private ?string $currentSource = null;
public function __construct(private readonly Connection $defaultConnection) {} public function __construct(private readonly Connection $defaultConnection) {}
public function finalize(): void public function finalize(): void
{ {
$this->doInsertPending(); $this->doInsertPending();
if ($this->isInitialized && null !== $this->currentSource) {
$this->defaultConnection->transactional(function (Connection $connection): void {
$connection->executeStatement(self::UPSERT);
$connection->executeStatement(self::DELETE_MISSING, [$this->currentSource]);
});
$this->deleteTemporaryTable();
}
$this->isInitialized = false;
$this->currentSource = null;
} }
public function importCode( public function importCode(
@@ -72,6 +122,14 @@ class PostalCodeBaseImporter
float $centerLon, float $centerLon,
int $centerSRID, int $centerSRID,
): void { ): void {
if (!$this->isInitialized) {
$this->initialize($refPostalCodeSource);
}
if ($this->currentSource !== $refPostalCodeSource) {
throw new \LogicException('Cannot store postal codes from different sources during same import. Execute finalize to commit inserts before changing the source');
}
$this->waitingForInsert[] = [ $this->waitingForInsert[] = [
$countryCode, $countryCode,
$label, $label,
@@ -88,10 +146,32 @@ class PostalCodeBaseImporter
} }
} }
private function initialize(string $source): void
{
$this->currentSource = $source;
$this->deleteTemporaryTable();
$this->createTemporaryTable();
$this->isInitialized = true;
}
private function createTemporaryTable(): void
{
$this->defaultConnection->executeStatement(self::CREATE_TEMP_TABLE);
}
private function deleteTemporaryTable(): void
{
$this->defaultConnection->executeStatement('DROP TABLE IF EXISTS chill_main_postal_code_temp');
}
private function doInsertPending(): void private function doInsertPending(): void
{ {
if ([] == $this->waitingForInsert) {
return;
}
if (!\array_key_exists($forNumber = \count($this->waitingForInsert), $this->cachingStatements)) { if (!\array_key_exists($forNumber = \count($this->waitingForInsert), $this->cachingStatements)) {
$sql = strtr(self::QUERY, [ $sql = strtr(self::INSERT_TEMP, [
'{{ values }}' => implode( '{{ values }}' => implode(
', ', ', ',
array_fill(0, $forNumber, self::VALUE) array_fill(0, $forNumber, self::VALUE)

View File

@@ -41,6 +41,6 @@ final class ChillMarkdownRenderExtension extends AbstractExtension
public function renderMarkdownToHtml(?string $var): string public function renderMarkdownToHtml(?string $var): string
{ {
return $this->parsedown->parse((string) $var); return $this->parsedown->text((string) $var);
} }
} }

View File

@@ -17,6 +17,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Contracts\Translation\TranslatableInterface;
/** /**
* Helper to test filters. * Helper to test filters.
@@ -255,8 +256,8 @@ abstract class AbstractFilterTest extends KernelTestCase
$description = $this->getFilter()->describeAction($data, $context); $description = $this->getFilter()->describeAction($data, $context);
$this->assertTrue( $this->assertTrue(
\is_string($description) || \is_array($description), \is_string($description) || \is_array($description) || $description instanceof TranslatableInterface,
'test that the description is a string or an array' 'test that the description is a string or an array, or a TranslatableInterface'
); );
if (\is_string($description)) { if (\is_string($description)) {

View File

@@ -93,4 +93,80 @@ final class PostalCodeBaseImporterTest extends KernelTestCase
$this->assertStringStartsWith('tested with adapted pattern', $postalCodes[0]->getName()); $this->assertStringStartsWith('tested with adapted pattern', $postalCodes[0]->getName());
$this->assertEquals($previousId, $postalCodes[0]->getId()); $this->assertEquals($previousId, $postalCodes[0]->getId());
} }
public function testPostalCodeRemoval(): void
{
$source = 'removal_test_'.uniqid();
$refId1 = 'ref1_'.uniqid();
$refId2 = 'ref2_'.uniqid();
// 1. Import two postal codes
$this->importer->importCode('BE', 'Label 1', '1000', $refId1, $source, 50.0, 5.0, 4326);
$this->importer->importCode('BE', 'Label 2', '2000', $refId2, $source, 50.0, 5.0, 4326);
$this->importer->finalize();
$pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source]);
$pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source]);
$this->assertNotNull($pc1);
$this->assertNotNull($pc2);
// 2. Import only the first one
$this->importer->importCode('BE', 'Label 1 updated', '1000', $refId1, $source, 50.0, 5.0, 4326);
$this->importer->finalize();
$this->entityManager->clear();
$pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source]);
$pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source]);
$this->assertNotNull($pc1);
$this->assertEquals('Label 1 updated', $pc1->getName());
$this->assertFalse($pc1->isDeleted(), 'pc1 should NOT be marked as deleted');
// pc2 should be marked as deleted. Note: findOneBy might still find it if it doesn't filter by deletedAt
$this->assertNotNull($pc2);
$this->assertTrue($pc2->isDeleted(), 'Postal code should be marked as deleted (deletedAt is not null)');
// 3. Reactivate pc2 by re-importing it
$this->importer->importCode('BE', 'Label 2 restored', '2000', $refId2, $source, 50.0, 5.0, 4326);
$this->importer->finalize();
$this->entityManager->clear();
$pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source]);
$this->assertFalse($pc2->isDeleted(), 'Postal code should NOT be marked as deleted after restoration');
$this->assertEquals('Label 2 restored', $pc2->getName());
}
public function testNoInterferenceBetweenSources(): void
{
$source1 = 'source1_'.uniqid();
$source2 = 'source2_'.uniqid();
$refId1 = 'ref1_'.uniqid();
$refId2 = 'ref2_'.uniqid();
// 1. Import from source1
$this->importer->importCode('BE', 'Label 1', '1000', $refId1, $source1, 50.0, 5.0, 4326);
$this->importer->finalize();
$pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source1]);
$this->assertNotNull($pc1);
$this->assertFalse($pc1->isDeleted());
// 2. Import from source2
$this->importer->importCode('BE', 'Label 2', '2000', $refId2, $source2, 50.0, 5.0, 4326);
$this->importer->finalize();
$this->entityManager->clear();
$pc1 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId1, 'postalCodeSource' => $source1]);
$pc2 = $this->postalCodeRepository->findOneBy(['refPostalCodeId' => $refId2, 'postalCodeSource' => $source2]);
$this->assertNotNull($pc1);
$this->assertNotNull($pc2);
$this->assertFalse($pc1->isDeleted(), 'pc1 from source1 should NOT be deleted after import from source2');
$this->assertFalse($pc2->isDeleted(), 'pc2 from source2 should NOT be deleted');
}
} }

View File

@@ -37,7 +37,7 @@ final class ChillMarkdownRenderExtensionTest extends TestCase
MD; MD;
private const UNAUTHORIZED_HTML = <<<'HTML' private const UNAUTHORIZED_HTML = <<<'HTML'
<p>&lt;script&gt;alert(&quot;ok&quot;);&lt;/script&gt;</p> <p>&lt;script&gt;alert("ok");&lt;/script&gt;</p>
HTML; HTML;
private const UNAUTHORIZED_MARKDOWN = <<<'MD' private const UNAUTHORIZED_MARKDOWN = <<<'MD'

View File

@@ -0,0 +1,41 @@
<?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\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260223134919 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create a partial index for postal_code search_name_code, to avoid deleted records';
}
public function up(Schema $schema): void
{
$this->addSql('DROP INDEX public.search_name_code');
$this->addSql('CREATE INDEX search_name_code ON public.chill_main_postal_code USING GIN (LOWER(code) gin_trgm_ops, LOWER(label) gin_trgm_ops) WHERE deletedAt IS NULL');
$this->addSql('DROP INDEX public.chill_internal_postal_code_canonicalized');
$this->addSql('CREATE INDEX chill_internal_postal_code_canonicalized ON chill_main_postal_code USING GIST (canonical gist_trgm_ops) WHERE origin = 0 AND deletedAt IS NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX public.search_name_code');
$this->addSql('CREATE INDEX search_name_code ON chill_main_postal_code USING GIN (LOWER(code) gin_trgm_ops, LOWER(label) gin_trgm_ops)');
$this->addSql('DROP INDEX public.chill_internal_postal_code_canonicalized');
$this->addSql('CREATE INDEX chill_internal_postal_code_canonicalized ON chill_main_postal_code USING GIST (canonical gist_trgm_ops) WHERE origin = 0');
}
}

View File

@@ -1,3 +1,7 @@
common:
after: Après
until: Jusqu'à
centers: Territoires
"This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>": "Ce programme est un logiciel libre: vous pouvez le redistribuer et/ou le modifier selon les termes de la licence <strong>GNU Affero GPL</strong>" "This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>": "Ce programme est un logiciel libre: vous pouvez le redistribuer et/ou le modifier selon les termes de la licence <strong>GNU Affero GPL</strong>"
User manual: Manuel d'utilisation User manual: Manuel d'utilisation
Search: Rechercher Search: Rechercher

View File

@@ -17,7 +17,6 @@ use Chill\MainBundle\Form\Type\PickPostalCodeType;
use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\UserRepository; use Chill\MainBundle\Repository\UserRepository;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepositoryInterface; use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepositoryInterface;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository; use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
@@ -31,18 +30,29 @@ use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Component\Validator\Constraints\NotIdenticalTo; use Symfony\Component\Validator\Constraints\NotIdenticalTo;
use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\NotNull;
class ReassignAccompanyingPeriodController extends AbstractController class ReassignAccompanyingPeriodController extends AbstractController
{ {
public function __construct(private readonly AccompanyingPeriodACLAwareRepositoryInterface $accompanyingPeriodACLAwareRepository, private readonly UserRepository $userRepository, private readonly AccompanyingPeriodRepository $courseRepository, private readonly \Twig\Environment $engine, private readonly FormFactoryInterface $formFactory, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly UserRender $userRender, private readonly EntityManagerInterface $em) {} public function __construct(
private readonly AccompanyingPeriodACLAwareRepositoryInterface $accompanyingPeriodACLAwareRepository,
private readonly UserRepository $userRepository,
private readonly AccompanyingPeriodRepository $courseRepository,
private readonly \Twig\Environment $engine,
private readonly FormFactoryInterface $formFactory,
private readonly PaginatorFactory $paginatorFactory,
private readonly Security $security,
private readonly EntityManagerInterface $entityManager,
) {}
#[Route(path: '/{_locale}/person/accompanying-periods/reassign', name: 'chill_course_list_reassign')] #[Route(path: '/{_locale}/person/accompanying-periods/reassign', name: 'chill_course_list_reassign')]
public function listAction(Request $request): Response public function listAction(Request $request, Session $session): Response
{ {
if (!$this->security->isGranted(AccompanyingPeriodVoter::REASSIGN_BULK)) { if (!$this->security->isGranted(AccompanyingPeriodVoter::REASSIGN_BULK)) {
throw new AccessDeniedHttpException('no right to reassign bulk'); throw new AccessDeniedHttpException('no right to reassign bulk');
@@ -96,7 +106,8 @@ class ReassignAccompanyingPeriodController extends AbstractController
} }
} }
$this->em->flush(); $this->entityManager->flush();
$this->addFlash('success', new TranslatableMessage('period_by_user_list.successfully_re_assigned', ['count' => count($assignPeriodIds)]));
// redirect to the first page // redirect to the first page
return $this->redirectToRoute('chill_course_list_reassign', $request->query->all()); return $this->redirectToRoute('chill_course_list_reassign', $request->query->all());

View File

@@ -0,0 +1,143 @@
<?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\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Export\DataTransformerInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class ReferrerMainCenterAggregator implements AggregatorInterface, DataTransformerInterface
{
private const P = 'acp_agg_referrer_main_center';
public function __construct(
private CenterRepositoryInterface $centerRepository,
private RollingDateConverterInterface $rollingDateConverter,
) {}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void
{
$p = self::P;
$qb
->leftJoin('acp.userHistories', "{$p}_uh", Join::WITH, $qb->expr()->andX(
$qb->expr()->eq("{$p}_uh.accompanyingPeriod", 'acp.id'),
"OVERLAPSI (acp.openingDate, acp.closingDate), ({$p}_uh.startDate, {$p}_uh.endDate) = TRUE",
"OVERLAPSI (:{$p}_startDate, :{$p}_endDate), ({$p}_uh.startDate, {$p}_uh.endDate) = TRUE"
))
->leftJoin("{$p}_uh.user", "{$p}_user")
->addSelect("IDENTITY({$p}_user.mainCenter) AS {$p}_select")
->addGroupBy("{$p}_select")
->setParameter("{$p}_startDate", $this->rollingDateConverter->convert($data['start_date']))
->setParameter("{$p}_endDate", $this->rollingDateConverter->convert($data['end_date']));
}
public function applyOn(): string
{
return Declarations::ACP_TYPE;
}
public function buildForm(FormBuilderInterface $builder): void
{
$builder
->add('start_date', PickRollingDateType::class, [
'label' => 'common.after',
'required' => true,
])
->add('end_date', PickRollingDateType::class, [
'label' => 'common.until',
'required' => true,
]);
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return [
'start_date' => $formData['start_date']->normalize(),
'end_date' => $formData['end_date']->normalize(),
];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
$default = $this->getFormDefaultData();
return [
'start_date' => array_key_exists('start_date', $formData) ? RollingDate::fromNormalized($formData['start_date']) : $default['start_date'],
'end_date' => array_key_exists('end_date', $formData) ? RollingDate::fromNormalized($formData['end_date']) : $default['end_date'],
];
}
public function getFormDefaultData(): array
{
return [
'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'end_date' => new RollingDate(RollingDate::T_TODAY),
];
}
public function transformData(?array $before): array
{
$default = $this->getFormDefaultData();
if (null === $before) {
return $default;
}
return [
'start_date' => $before['start_date'] ?? $before['date_calc'] ?? $default['start_date'],
'end_date' => $before['end_date'] ?? $before['date_calc'] ?? $default['end_date'],
];
}
public function getLabels($key, array $values, $data): callable
{
return function ($value): string {
if ('_header' === $value) {
return 'person.export.period.aggregator.by_referrer_main_center.column_header';
}
if (null === $value || '' === $value) {
return '';
}
return (string) $this->centerRepository->find((int) $value)?->getName();
};
}
public function getQueryKeys($data): array
{
return [self::P.'_select'];
}
public function getTitle(): string
{
return 'person.export.period.aggregator.by_referrer_main_center.title';
}
}

View File

@@ -64,7 +64,7 @@ final readonly class CenterAggregator implements AggregatorInterface
{ {
return function (int|string|null $value) { return function (int|string|null $value) {
if (null === $value || '' === $value) { if (null === $value || '' === $value) {
return $this->translator->trans('person.export.aggregator.by_center.no_center'); return $this->translator->trans('person.export.period.aggregator.by_center.no_center');
} }
if ('_header' === $value) { if ('_header' === $value) {

View File

@@ -0,0 +1,155 @@
<?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\PersonBundle\Export\Filter\AccompanyingCourseFilters;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\UserHistory;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* Filter accompanying periods by the main center of their referrer (at a given date).
*/
final readonly class ReferrerMainCenterFilter implements FilterInterface
{
private const UH = 'acp_referrer_main_center_filter_uh';
private const DATE_PARAM_SINCE = 'acp_referrer_main_center_filter_date_since';
private const DATE_PARAM_UNTIL = 'acp_referrer_main_center_filter_date_until';
private const CENTER_PARAM = 'acp_referrer_main_center_filter_center';
public function __construct(
private RollingDateConverterInterface $rollingDateConverter,
private CenterRepositoryInterface $centerRepository,
) {}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void
{
$dql = 'SELECT 1 FROM '.UserHistory::class.' '.self::UH.'
JOIN '.self::UH.'.user '.self::UH.'_user
WHERE acp = '.self::UH.'.accompanyingPeriod
AND (
'.self::UH.'.startDate < :'.self::DATE_PARAM_UNTIL.'
AND (
'.self::UH.'.endDate IS NULL OR '.self::UH.'.endDate >= :'.self::DATE_PARAM_SINCE.'
)
)
AND '.self::UH.'_user.mainCenter IN (:'.self::CENTER_PARAM.')';
$qb->andWhere(
$qb->expr()->exists($dql)
);
$qb
->setParameter(self::DATE_PARAM_SINCE, $this->rollingDateConverter->convert($data['date_calc_since']))
->setParameter(self::DATE_PARAM_UNTIL, $this->rollingDateConverter->convert($data['date_calc_until']))
->setParameter(self::CENTER_PARAM, $data['centers']);
}
public function applyOn(): string
{
return Declarations::ACP_TYPE;
}
public function buildForm(FormBuilderInterface $builder): void
{
$builder
->add('centers', EntityType::class, [
'class' => Center::class,
'choices' => $this->centerRepository->findActive(),
'multiple' => true,
'expanded' => false,
'choice_label' => static fn (Center $c) => $c->getName(),
'required' => true,
'label' => 'common.centers',
'attr' => [
'class' => 'select2',
],
])
->add('date_calc_since', PickRollingDateType::class, [
'label' => 'person.export.period.filter.by_referrer_main_center.referrer_since',
'required' => true,
])
->add('date_calc_until', PickRollingDateType::class, [
'label' => 'person.export.period.filter.by_referrer_main_center.referrer_until',
'required' => true,
]);
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return [
'centers' => array_values(array_map(static fn (Center $c) => $c->getId(), $formData['centers'])),
'date_calc_since' => $formData['date_calc_since']->normalize(),
'date_calc_until' => $formData['date_calc_until']->normalize(),
];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return [
'centers' => array_values(array_filter(array_map(
fn (int $id) => $this->centerRepository->find($id),
$formData['centers'] ?? []
))),
'date_calc_since' => RollingDate::fromNormalized($formData['date_calc_since']),
'date_calc_until' => RollingDate::fromNormalized($formData['date_calc_until']),
];
}
public function getFormDefaultData(): array
{
return [
'centers' => [],
'date_calc_since' => new RollingDate(RollingDate::T_TODAY),
'date_calc_until' => new RollingDate(RollingDate::T_TODAY),
];
}
public function describeAction($data, ExportGenerationContext $context): TranslatableInterface
{
$names = array_map(static fn (Center $c) => $c->getName(), $data['centers']);
return new TranslatableMessage(
'person.export.period.filter.by_referrer_main_center.description',
[
'centers' => implode(', ', $names),
'date_since' => $this->rollingDateConverter->convert($data['date_calc_since']),
'date_until' => $this->rollingDateConverter->convert($data['date_calc_until']),
]
);
}
public function getTitle(): TranslatableInterface
{
return new TranslatableMessage('person.export.period.filter.by_referrer_main_center.title');
}
}

View File

@@ -27,7 +27,7 @@
<p>{{ $t("courselocation.sure_description") }}</p> <p>{{ $t("courselocation.sure_description") }}</p>
</template> </template>
<template #footer> <template #footer>
<button class="btn btn-danger" @click="assignAddress"> <button class="btn btn-submit" @click="assignAddress">
{{ $t("courselocation.ok") }} {{ $t("courselocation.ok") }}
</button> </button>
</template> </template>

View File

@@ -0,0 +1,123 @@
<?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\PersonBundle\Tests\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Test\Export\AbstractAggregatorTest;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ReferrerMainCenterAggregator;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*
* @covers \Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ReferrerMainCenterAggregator
*/
final class ReferrerMainCenterAggregatorTest extends AbstractAggregatorTest
{
private ReferrerMainCenterAggregator $aggregator;
protected function setUp(): void
{
self::bootKernel();
$this->aggregator = self::getContainer()->get('Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ReferrerMainCenterAggregator');
}
/**
* @dataProvider provideBeforeData
*/
public function testDataTransformer(?array $before, array $expected): void
{
$actual = $this->getAggregator()->transformData($before);
self::assertEqualsCanonicalizing(array_keys($expected), array_keys($actual));
foreach (['start_date', 'end_date'] as $key) {
self::assertInstanceOf(RollingDate::class, $actual[$key]);
self::assertEquals($expected[$key]->getRoll(), $actual[$key]->getRoll(), "Check that the roll is the same for {$key}");
}
}
public static function provideBeforeData(): iterable
{
yield [
['date_calc' => new RollingDate(RollingDate::T_TODAY)],
['start_date' => new RollingDate(RollingDate::T_TODAY), 'end_date' => new RollingDate(RollingDate::T_TODAY)],
];
yield [
['start_date' => new RollingDate(RollingDate::T_WEEK_CURRENT_START), 'end_date' => new RollingDate(RollingDate::T_TODAY)],
['start_date' => new RollingDate(RollingDate::T_WEEK_CURRENT_START), 'end_date' => new RollingDate(RollingDate::T_TODAY)],
];
yield [
null,
// this is the default configuration
['start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START), 'end_date' => new RollingDate(RollingDate::T_TODAY)],
];
}
public function getAggregator(): ReferrerMainCenterAggregator
{
return $this->aggregator;
}
public static function getFormData(): array
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$centers = $em->getRepository(Center::class)->findBy([], null, 1);
return [
[
'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'end_date' => new RollingDate(RollingDate::T_TODAY),
],
];
}
public static function provideGetResultsAndLabels(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$centers = $em->getRepository(Center::class)->findAll();
$qb = $em->createQueryBuilder()
->select('count(acp.id)')
->from(AccompanyingPeriod::class, 'acp');
$data = [
'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'end_date' => new RollingDate(RollingDate::T_TODAY),
];
// Yield result with each center ID and null
foreach ($centers as $center) {
yield [$qb, $data, [(string) $center->getId() => 0]];
}
yield [$qb, $data, ['' => 0]];
}
public static function getQueryBuilders(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
return [
$em->createQueryBuilder()
->select('count(acp.id)')
->from(AccompanyingPeriod::class, 'acp'),
];
}
}

View File

@@ -0,0 +1,69 @@
<?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\PersonBundle\Tests\Export\Filter\AccompanyingCourseFilters;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Test\Export\AbstractFilterTest;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\ReferrerMainCenterFilter;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*
* @coversNothing
*/
final class ReferrerMainCenterFilterTest extends AbstractFilterTest
{
private ReferrerMainCenterFilter $filter;
protected function setUp(): void
{
self::bootKernel();
$this->filter = self::getContainer()->get(ReferrerMainCenterFilter::class);
}
public function getFilter(): ReferrerMainCenterFilter
{
return $this->filter;
}
public static function getFormData(): array
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$centers = $em->getRepository(Center::class)->findAll();
if ([] === $centers) {
throw new \RuntimeException('No centers found in database');
}
return [
[
'centers' => [$centers[0]],
'date_calc_since' => new RollingDate(RollingDate::T_TODAY),
'date_calc_until' => new RollingDate(RollingDate::T_TODAY),
],
];
}
public static function getQueryBuilders(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
yield $em->createQueryBuilder()
->from(AccompanyingPeriod::class, 'acp')
->select('acp.id');
}
}

View File

@@ -104,6 +104,10 @@ services:
tags: tags:
- { name: chill.export_filter, alias: accompanyingcourse_referrer_filter_between_dates } - { name: chill.export_filter, alias: accompanyingcourse_referrer_filter_between_dates }
Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\ReferrerMainCenterFilter:
tags:
- { name: chill.export_filter, alias: accompanyingcourse_referrer_main_center_filter }
chill.person.export.filter_openbetweendates: chill.person.export.filter_openbetweendates:
class: Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\OpenBetweenDatesFilter class: Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\OpenBetweenDatesFilter
tags: tags:
@@ -270,3 +274,7 @@ services:
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\PersonParticipatingAggregator: Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\PersonParticipatingAggregator:
tags: tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_person_part_aggregator } - { name: chill.export_aggregator, alias: accompanyingcourse_person_part_aggregator }
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ReferrerMainCenterAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_referrer_main_center_aggregator }

View File

@@ -24,6 +24,14 @@ accompanying_period:
number: >- number: >-
n° {id} n° {id}
period_by_user_list:
successfully_re_assigned: >-
{count, plural,
=0 {Aucune assignation de référent effectuée}
=1 {Assignation d'un nouveau référent pour un parcours}
other {Assignation d'un nouveau référent pour # parcours}
}
person: person:
from_the: depuis le from_the: depuis le
And himself: >- And himself: >-
@@ -33,6 +41,13 @@ person:
neutral {et lui·elle-même} neutral {et lui·elle-même}
other {et lui·elle-même} other {et lui·elle-même}
} }
export:
period:
filter:
by_referrer_main_center:
description: >-
Filtre les parcours par territoire du référent, entre le {date_since, date, medium} et le {date_until, date, medium}, uniquement {centers}
household: household:
Household: Ménage Household: Ménage

View File

@@ -105,9 +105,18 @@ Administrative status: Situation administrative
person: person:
# trans key according to new conventions # trans key according to new conventions
export: export:
aggregator: period:
by_center: aggregator:
no_center: Sans territoire by_center:
no_center: Sans territoire
by_referrer_main_center:
title: Grouper les parcours par territoire du référent
column_header: Territoire du référent
filter:
by_referrer_main_center:
title: Filtrer les parcours par territoire du référent
referrer_since: Référent depuis le
referrer_until: Référent avant le
Identifiers: Identifiants Identifiers: Identifiants
@@ -453,8 +462,8 @@ Filtered by entrusted child status: Uniquement les usagers qui sont "enfant conf
Filter by nomadic status: Filtrer les usagers "gens du voyage" Filter by nomadic status: Filtrer les usagers "gens du voyage"
Filtered by nomadic status: Uniquement les usagers qui sont "gens du voyage" Filtered by nomadic status: Uniquement les usagers qui sont "gens du voyage"
"Filter by person's who have a residential address located at another user": Filtrer les usagers qui ont une addresse de résidence chez une autre usager "Filter by person's who have a residential address located at another user": Filtrer les usagers qui ont une adresse de résidence chez une autre usager
"Filtered by person's who have a residential address located at another user": Uniquement les usagers qui ont une addresse de résidence chez une autre usager "Filtered by person's who have a residential address located at another user": Uniquement les usagers qui ont une adresse de résidence chez une autre usager
Filter by person's that are alive or have deceased at a certain date: Filtrer les usagers qui sont décédés ou vivantes à une certaine date Filter by person's that are alive or have deceased at a certain date: Filtrer les usagers qui sont décédés ou vivantes à une certaine date
Filtered by person's that are alive or have deceased at a certain date: Uniquement les usagers qui sont décédés ou vivantes à une certaine date Filtered by person's that are alive or have deceased at a certain date: Uniquement les usagers qui sont décédés ou vivantes à une certaine date

View File

@@ -129,11 +129,11 @@ export:
thirdParties: Tiers intervenant thirdParties: Tiers intervenant
# exports filters/aggregators # exports filters/aggregators
Filtered by person\'s who have a residential address located at a thirdparty of type %thirparty_type%: Uniquement les usagers qui ont une addresse de résidence chez un tiers de catégorie %thirdparty_type% Filtered by person\'s who have a residential address located at a thirdparty of type %thirparty_type%: Uniquement les usagers qui ont une adresse de résidence chez un tiers de catégorie %thirdparty_type%
is thirdparty: Le demandeur est un tiers is thirdparty: Le demandeur est un tiers
Filter by person's who have a residential address located at a thirdparty of type: Filtrer les usagers qui ont une addresse de résidence chez un tiers Filter by person's who have a residential address located at a thirdparty of type: Filtrer les usagers qui ont une adresse de résidence chez un tiers
"Filtered by person's who have a residential address located at a thirdparty of type %thirdparty_type% and valid on %date_calc%": "Uniquement les usagers qui ont une addresse de résidence chez un tiers de catégorie %thirdparty_type% et valide sur la date %date_calc%" "Filtered by person's who have a residential address located at a thirdparty of type %thirdparty_type% and valid on %date_calc%": "Uniquement les usagers qui ont une adresse de résidence chez un tiers de catégorie %thirdparty_type% et valide sur la date %date_calc%"
# admin # admin
admin: admin: