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

This commit is contained in:
Julien Fastré 2024-09-16 11:51:33 +02:00
commit 45323e9136
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
46 changed files with 1787 additions and 628 deletions

View File

@ -1,4 +1,4 @@
## v2.23.0 - 2024-07-23 ## v2.23.0 - 2024-07-23 & 2024-07-19
### Feature ### Feature
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi) * Add job bundle (module emploi)
@ -6,6 +6,25 @@
* Upgrade CKEditor and refactor configuration with use of typescript * Upgrade CKEditor and refactor configuration with use of typescript
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed ### Fixed
* Fix resolving of centers for an household, which will fix in turn the access control * Fix resolving of centers for an household, which will fix in turn the access control
* Resolved type hinting error in activity list export * Resolved type hinting error in activity list export
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.

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

@ -0,0 +1,3 @@
## v2.24.0 - 2024-09-11
### Feature
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.

3
.changes/v3.1.0.md Normal file
View File

@ -0,0 +1,3 @@
## v3.1.0 - 2024-08-30
### Feature
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.

View File

@ -6,14 +6,43 @@ 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).
## v3.1.0 - 2024-08-30
### Feature
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.
## v3.0.0 - 2024-08-26 ## v3.0.0 - 2024-08-26
### Fixed ### Fixed
* Fix delete action for accompanying periods in draft state * Fix delete action for accompanying periods in draft state
* Fix connection to azure when making an calendar event in chill * Fix connection to azure when making an calendar event in chill
* CollectionType js fixes for remove button and adding multiple entries * CollectionType js fixes for remove button and adding multiple entries
## v2.23.0 - 2024-07-23 ## v2.24.0 - 2024-09-11
### Feature ### Feature
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
## v2.23.0 - 2024-07-19 & 2024-07-23
### Feature
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi) * Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address * Upgrade import of address list to the last version of compiled addresses of belgian-best-address

View File

@ -43,6 +43,7 @@
"symfony/dom-crawler": "^5.4", "symfony/dom-crawler": "^5.4",
"symfony/error-handler": "^5.4", "symfony/error-handler": "^5.4",
"symfony/event-dispatcher": "^5.4", "symfony/event-dispatcher": "^5.4",
"symfony/event-dispatcher-contracts": "^2.4",
"symfony/expression-language": "^5.4", "symfony/expression-language": "^5.4",
"symfony/filesystem": "^5.4", "symfony/filesystem": "^5.4",
"symfony/finder": "^5.4", "symfony/finder": "^5.4",

View File

@ -39,9 +39,12 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
use Chill\MainBundle\Entity\CronJobExecution; use Chill\MainBundle\Entity\CronJobExecution;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
class MyCronJob implements CronJobInterface class MyCronJob implements CronJobInterface
{ {
function __construct(private ClockInterface $clock) {}
public function canRun(?CronJobExecution $cronJobExecution): bool public function canRun(?CronJobExecution $cronJobExecution): bool
{ {
// the parameter $cronJobExecution contains data about the last execution of the cronjob // the parameter $cronJobExecution contains data about the last execution of the cronjob
@ -56,7 +59,7 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
// this cron job should be executed if the last execution is greater than one day, but only during the night // this cron job should be executed if the last execution is greater than one day, but only during the night
$now = new DateTimeImmutable('now'); $now = $clock->now();
return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D')) return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D'))
&& in_array($now->format('H'), self::ACCEPTED_HOURS, true) && in_array($now->format('H'), self::ACCEPTED_HOURS, true)
@ -69,10 +72,15 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
return 'arbitrary-and-unique-key'; return 'arbitrary-and-unique-key';
} }
public function run(): void public function run(array $lastExecutionData): void
{ {
// here, we execute the command // here, we execute the command
}
// we return execution data, which will be served for next execution
// this data should be easily serializable in a json column: it should contains
// only int, string, etc. Avoid storing object
return ['last-execution-id' => 0];
}
} }
How are cron job scheduled ? How are cron job scheduled ?

View File

@ -0,0 +1,99 @@
<?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\Export\Aggregator\PersonAggregators;
use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Repository\Household\HouseholdRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class HouseholdAggregator implements AggregatorInterface
{
public function __construct(private HouseholdRepository $householdRepository) {}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, mixed $data)
{
return function (int|string|null $value): string|int {
if ('_header' === $value) {
return 'export.aggregator.person.by_household.household';
}
if ('' === $value || null === $value || null === $household = $this->householdRepository->find($value)) {
return '';
}
return $household->getId();
};
}
public function getQueryKeys($data)
{
return ['activity_household_agg'];
}
public function getTitle()
{
return 'export.aggregator.person.by_household.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$qb->join(
HouseholdMember::class,
'activity_household_agg_household_member',
Join::WITH,
$qb->expr()->andX(
$qb->expr()->eq('activity_household_agg_household_member.person', 'activity.person'),
$qb->expr()->lte('activity_household_agg_household_member.startDate', 'activity.date'),
$qb->expr()->orX(
$qb->expr()->gte('activity_household_agg_household_member.endDate', 'activity.date'),
$qb->expr()->isNull('activity_household_agg_household_member.endDate')
)
)
);
$qb->join(
Household::class,
'activity_household_agg_household',
Join::WITH,
$qb->expr()->eq('activity_household_agg_household_member.household', 'activity_household_agg_household')
);
$qb
->addSelect('activity_household_agg_household.id AS activity_household_agg')
->addGroupBy('activity_household_agg');
}
public function applyOn()
{
return Declarations::ACTIVITY_PERSON;
}
}

View File

@ -19,6 +19,7 @@ use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface; use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Export\ListInterface; use Chill\MainBundle\Export\ListInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Chill\PersonBundle\Export\Declarations as PersonDeclarations;
use Doctrine\DBAL\Exception\InvalidArgumentException; use Doctrine\DBAL\Exception\InvalidArgumentException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -44,6 +45,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
'person_firstname', 'person_firstname',
'person_lastname', 'person_lastname',
'person_id', 'person_id',
'household_id',
]; ];
private readonly bool $filterStatsByCenters; private readonly bool $filterStatsByCenters;
@ -189,19 +191,26 @@ class ListActivity implements ListInterface, GroupedExportInterface
{ {
$centers = array_map(static fn ($el) => $el['center'], $acl); $centers = array_map(static fn ($el) => $el['center'], $acl);
// throw an error if any fields are present // throw an error if no fields are present
if (!\array_key_exists('fields', $data)) { if (!\array_key_exists('fields', $data)) {
throw new InvalidArgumentException('Any fields have been checked.'); throw new InvalidArgumentException('No fields have been checked.');
} }
$qb = $this->entityManager->createQueryBuilder(); $qb = $this->entityManager->createQueryBuilder();
$qb $qb
->from('ChillActivityBundle:Activity', 'activity') ->from('ChillActivityBundle:Activity', 'activity')
->join('activity.person', 'actperson'); ->join('activity.person', 'person')
->join(
HouseholdMember::class,
'householdmember',
Query\Expr\Join::WITH,
'person = householdmember.person AND householdmember.startDate <= activity.date AND (householdmember.endDate IS NULL OR householdmember.endDate > activity.date)'
)
->join('householdmember.household', 'household');
if ($this->filterStatsByCenters) { if ($this->filterStatsByCenters) {
$qb->join('actperson.centerHistory', 'centerHistory'); $qb->join('person.centerHistory', 'centerHistory');
$qb->where( $qb->where(
$qb->expr()->andX( $qb->expr()->andX(
$qb->expr()->lte('centerHistory.startDate', 'activity.date'), $qb->expr()->lte('centerHistory.startDate', 'activity.date'),
@ -224,17 +233,22 @@ class ListActivity implements ListInterface, GroupedExportInterface
break; break;
case 'person_firstname': case 'person_firstname':
$qb->addSelect('actperson.firstName AS person_firstname'); $qb->addSelect('person.firstName AS person_firstname');
break; break;
case 'person_lastname': case 'person_lastname':
$qb->addSelect('actperson.lastName AS person_lastname'); $qb->addSelect('person.lastName AS person_lastname');
break; break;
case 'person_id': case 'person_id':
$qb->addSelect('actperson.id AS person_id'); $qb->addSelect('person.id AS person_id');
break;
case 'household_id':
$qb->addSelect('household.id AS household_id');
break; break;
@ -284,7 +298,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
return ActivityStatsVoter::LISTS; return ActivityStatsVoter::LISTS;
} }
public function supportsModifiers() public function supportsModifiers(): array
{ {
return [ return [
Declarations::ACTIVITY, Declarations::ACTIVITY,

View File

@ -73,7 +73,7 @@ final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInt
$qb->andWhere( $qb->andWhere(
$qb->expr()->exists( $qb->expr()->exists(
'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod" 'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = acp"
) )
); );

View File

@ -39,7 +39,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
return null; return null;
} }
public function alterQuery(QueryBuilder $qb, $data) public function alterQuery(QueryBuilder $qb, $data): void
{ {
// create a subquery for activity // create a subquery for activity
$sqb = $qb->getEntityManager()->createQueryBuilder(); $sqb = $qb->getEntityManager()->createQueryBuilder();
@ -121,7 +121,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
]; ];
} }
public function describeAction($data, $format = 'string') public function describeAction($data, $format = 'string'): array
{ {
return [ return [
[] === $data['reasons'] ? [] === $data['reasons'] ?
@ -141,7 +141,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
]; ];
} }
public function getTitle() public function getTitle(): string
{ {
return 'export.filter.activity.person_between_dates.title'; return 'export.filter.activity.person_between_dates.title';
} }

View File

@ -243,3 +243,7 @@ services:
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator: Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator:
tags: tags:
- { name: chill.export_aggregator, alias: activity_person_agg } - { name: chill.export_aggregator, alias: activity_person_agg }
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\HouseholdAggregator:
tags:
- { name: chill.export_aggregator, alias: activity_household_agg }

View File

@ -428,6 +428,9 @@ export:
by_person: by_person:
title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré) title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré)
person: Usager person: Usager
by_household:
title: Grouper les échanges par ménage
household: Identifiant ménage
acp: acp:
by_activity_type: by_activity_type:
title: Grouper les parcours par type d'échange title: Grouper les parcours par type d'échange

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<label class="form-label">{{ $t('created_availabilities') }}</label> <label class="form-label">{{ $t("created_availabilities") }}</label>
<vue-multiselect <vue-multiselect
v-model="pickedLocation" v-model="pickedLocation"
:options="locations" :options="locations"
@ -14,10 +14,15 @@
></vue-multiselect> ></vue-multiselect>
</div> </div>
</div> </div>
<div class="display-options row justify-content-between" style="margin-top: 1rem;"> <div
class="display-options row justify-content-between"
style="margin-top: 1rem"
>
<div class="col-sm-9 col-xs-12"> <div class="col-sm-9 col-xs-12">
<div class="input-group mb-3"> <div class="input-group mb-3">
<label class="input-group-text" for="slotDuration">Durée des créneaux</label> <label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select v-model="slotDuration" id="slotDuration" class="form-select"> <select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option> <option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
@ -58,13 +63,20 @@
</select> </select>
</div> </div>
</div> </div>
<div class="col-sm-3 col-xs-12"> <div class="col-xs-12 col-sm-3">
<div class="float-end"> <div class="float-end">
<div class="form-check input-group"> <div class="form-check input-group">
<span class="input-group-text"> <span class="input-group-text">
<input id="showHideWE" class="mt-0" type="checkbox" v-model="showWeekends"> <input
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="showWeekends"
/>
</span> </span>
<label for="showHideWE" class="form-check-label input-group-text">Week-ends</label> <label for="showHideWE" class="form-check-label input-group-text"
>Week-ends</label
>
</div> </div>
</div> </div>
</div> </div>
@ -72,39 +84,86 @@
<FullCalendar :options="calendarOptions" ref="calendarRef"> <FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="arg: EventApi"> <template v-slot:eventContent="arg: EventApi">
<span :class="eventClasses(arg.event)"> <span :class="eventClasses(arg.event)">
<b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b> <b v-if="arg.event.extendedProps.is === 'remote'">{{
<b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b> arg.event.title
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b> }}</b>
<b v-else >no 'is'</b> <b v-else-if="arg.event.extendedProps.is === 'range'"
<a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete" >{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b
@click.prevent="onClickDelete(arg.event)"> >
</a> <b v-else-if="arg.event.extendedProps.is === 'local'">{{
</span> arg.event.title
}}</b>
<b v-else>no 'is'</b>
<a
v-if="arg.event.extendedProps.is === 'range'"
class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)"
>
</a>
</span>
</template> </template>
</FullCalendar> </FullCalendar>
<div id="copy-widget"> <div id="copy-widget">
<div class="container"> <div class="container mt-2 mb-2">
<div class="row align-items-center">
<div class="col-sm-4 col-xs-12"> <div class="row justify-content-between align-items-center mb-4">
<h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6> <div class="col-xs-12 col-sm-3 col-md-2">
</div> <h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
<div class="col-sm-3 col-xs-12"> </div>
<input class="form-control" type="date" v-model="copyFrom" /> <div class="col-xs-12 col-sm-9 col-md-2">
</div> <select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
<div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;"> <option value="day">{{ $t("from_day_to_day") }}</option>
<i class="fa fa-angle-double-right"></i> <option value="week">{{ $t("from_week_to_week") }}</option>
</div> </select>
<div class="col-sm-3 col-xs-12" > </div>
<input class="form-control" type="date" v-model="copyTo" /> <template v-if="dayOrWeek === 'day'">
</div> <div class="col-xs-12 col-sm-3 col-md-3">
<div class="col-sm-1"> <input class="form-control" type="date" v-model="copyFrom" />
<button class="btn btn-action" @click="copyDay"> </div>
{{ $t('copy_range') }} <div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
</button> <i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyTo" />
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyDay">
{{ $t("copy_range") }}
</button>
</div>
</template>
<template v-else>
<div class="col-xs-12 col-sm-3 col-md-3">
<select
v-model="copyFromWeek"
id="copyFromWeek"
class="form-select"
>
<option v-for="w in lastWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<select v-model="copyToWeek" id="copyToWeek" class="form-select">
<option v-for="w in nextWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyWeek">
{{ $t("copy_range") }}
</button>
</div>
</template>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- not directly seen, but include in a modal --> <!-- not directly seen, but include in a modal -->
@ -112,42 +171,95 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { import type {
CalendarOptions, CalendarOptions,
DatesSetArg, DatesSetArg,
EventInput EventInput,
} from '@fullcalendar/core'; } from "@fullcalendar/core";
import {reactive, computed, ref} from "vue"; import { reactive, computed, ref, onMounted } from "vue";
import {useStore} from "vuex"; import { useStore } from "vuex";
import {key} from './store'; import { key } from "./store";
import FullCalendar from '@fullcalendar/vue3'; import FullCalendar from "@fullcalendar/vue3";
import frLocale from '@fullcalendar/core/locales/fr'; import frLocale from "@fullcalendar/core/locales/fr";
import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction"; import interactionPlugin, {
DropArg,
EventResizeDoneArg,
} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core"; import {
import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date"; EventApi,
DateSelectArg,
EventDropArg,
EventClickArg,
} from "@fullcalendar/core";
import {
dateToISO,
ISOToDate,
} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import VueMultiselect from "vue-multiselect"; import VueMultiselect from "vue-multiselect";
import {Location} from "../../../../../ChillMainBundle/Resources/public/types"; import { Location } from "../../../../../ChillMainBundle/Resources/public/types";
import EditLocation from "./Components/EditLocation.vue"; import EditLocation from "./Components/EditLocation.vue";
import {useI18n} from "vue-i18n"; import { useI18n } from "vue-i18n";
const store = useStore(key); const store = useStore(key);
const {t} = useI18n(); const { t } = useI18n();
const showWeekends = ref(false); const showWeekends = ref(false);
const slotDuration = ref('00:05:00'); const slotDuration = ref("00:15:00");
const slotMinTime = ref('09:00:00'); const slotMinTime = ref("09:00:00");
const slotMaxTime = ref('18:00:00'); const slotMaxTime = ref("18:00:00");
const copyFrom = ref<string | null>(null); const copyFrom = ref<string | null>(null);
const copyTo = ref<string | null>(null); const copyTo = ref<string | null>(null);
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null) const editLocation = ref<InstanceType<typeof EditLocation> | null>(null);
const dayOrWeek = ref("day");
const copyFromWeek = ref<string | null>(null);
const copyToWeek = ref<string | null>(null);
interface Weeks {
value: string | null;
text: string;
}
const getMonday = (week: number): Date => {
const lastMonday = new Date();
lastMonday.setDate(
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7
);
return lastMonday;
};
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const lastWeeks = computed((): Weeks[] =>
Array.from(Array(30).keys()).map((w) => {
const lastMonday = getMonday(15-w);
return {
value: dateToISO(lastMonday),
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
})
);
const nextWeeks = computed((): Weeks[] =>
Array.from(Array(52).keys()).map((w) => {
const nextMonday = getMonday(w + 1);
return {
value: dateToISO(nextMonday),
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
})
);
const baseOptions = ref<CalendarOptions>({ const baseOptions = ref<CalendarOptions>({
locale: frLocale, locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin], plugins: [interactionPlugin, timeGridPlugin],
initialView: 'timeGridWeek', initialView: "timeGridWeek",
initialDate: new Date(), initialDate: new Date(),
scrollTimeReset: false, scrollTimeReset: false,
selectable: true, selectable: true,
@ -164,9 +276,9 @@ const baseOptions = ref<CalendarOptions>({
selectMirror: false, selectMirror: false,
editable: true, editable: true,
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: "prev,next today",
center: 'title', center: "title",
right: 'timeGridWeek,timeGridDay' right: "timeGridWeek,timeGridDay",
}, },
}); });
@ -180,20 +292,23 @@ const locations = computed<Location[]>(() => {
const pickedLocation = computed<Location | null>({ const pickedLocation = computed<Location | null>({
get(): Location | null { get(): Location | null {
return store.state.locations.locationPicked || store.state.locations.currentLocation; return (
store.state.locations.locationPicked ||
store.state.locations.currentLocation
);
}, },
set(newLocation: Location | null): void { set(newLocation: Location | null): void {
store.commit('locations/setLocationPicked', newLocation, {root: true}); store.commit("locations/setLocationPicked", newLocation, { root: true });
} },
}) });
/** /**
* return the show classes for the event * return the show classes for the event
* @param arg * @param arg
*/ */
const eventClasses = function(arg: EventApi): object { const eventClasses = function (arg: EventApi): object {
return {'calendarRangeItems': true}; return { calendarRangeItems: true };
} };
/* /*
// currently, all events are stored into calendarRanges, due to reactivity bug // currently, all events are stored into calendarRanges, due to reactivity bug
@ -230,51 +345,60 @@ const calendarOptions = computed((): CalendarOptions => {
* launched when the calendar range date change * launched when the calendar range date change
*/ */
function onDatesSet(event: DatesSetArg): void { function onDatesSet(event: DatesSetArg): void {
store.dispatch('fullCalendar/setCurrentDatesView', {start: event.start, end: event.end}); store.dispatch("fullCalendar/setCurrentDatesView", {
start: event.start,
end: event.end,
});
} }
function onDateSelect(event: DateSelectArg): void { function onDateSelect(event: DateSelectArg): void {
if (null === pickedLocation.value) { if (null === pickedLocation.value) {
window.alert("Indiquez une localisation avant de créer une période de disponibilité."); window.alert(
"Indiquez une localisation avant de créer une période de disponibilité."
);
return; return;
} }
store.dispatch('calendarRanges/createRange', {start: event.start, end: event.end, location: pickedLocation.value}); store.dispatch("calendarRanges/createRange", {
start: event.start,
end: event.end,
location: pickedLocation.value,
});
} }
/** /**
* When a calendar range is deleted * When a calendar range is deleted
*/ */
function onClickDelete(event: EventApi): void { function onClickDelete(event: EventApi): void {
console.log('onClickDelete', event); if (event.extendedProps.is !== "range") {
if (event.extendedProps.is !== 'range') {
return; return;
} }
store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId); store.dispatch(
"calendarRanges/deleteRange",
event.extendedProps.calendarRangeId
);
} }
function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) { function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) {
if (payload.event.extendedProps.is !== 'range') { if (payload.event.extendedProps.is !== "range") {
return; return;
} }
const changedEvent = payload.event; const changedEvent = payload.event;
store.dispatch('calendarRanges/patchRangeTime', { store.dispatch("calendarRanges/patchRangeTime", {
calendarRangeId: payload.event.extendedProps.calendarRangeId, calendarRangeId: payload.event.extendedProps.calendarRangeId,
start: payload.event.start, start: payload.event.start,
end: payload.event.end, end: payload.event.end,
}); });
}; }
function onEventClick(payload: EventClickArg): void { function onEventClick(payload: EventClickArg): void {
// @ts-ignore TS does not recognize the target. But it does exists. // @ts-ignore TS does not recognize the target. But it does exists.
if (payload.jsEvent.target.classList.contains('delete')) { if (payload.jsEvent.target.classList.contains("delete")) {
return; return;
} }
if (payload.event.extendedProps.is !== 'range') { if (payload.event.extendedProps.is !== "range") {
return; return;
} }
@ -285,10 +409,26 @@ function copyDay() {
if (null === copyFrom.value || null === copyTo.value) { if (null === copyFrom.value || null === copyTo.value) {
return; return;
} }
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)}) from: ISOToDate(copyFrom.value),
to: ISOToDate(copyTo.value),
});
} }
function copyWeek() {
if (null === copyFromWeek.value || null === copyToWeek.value) {
return;
}
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
fromMonday: ISOToDate(copyFromWeek.value),
toMonday: ISOToDate(copyToWeek.value),
});
}
onMounted(() => {
copyFromWeek.value = dateToISO(getMonday(0));
copyToWeek.value = dateToISO(getMonday(1));
});
</script> </script>
<style scoped> <style scoped>
@ -299,4 +439,9 @@ function copyDay() {
z-index: 9999999999; z-index: 9999999999;
padding: 0.25rem 0 0.25rem; padding: 0.25rem 0 0.25rem;
} }
div.copy-chevron {
text-align: center;
font-size: x-large;
width: 2rem;
}
</style> </style>

View File

@ -5,11 +5,9 @@ const appMessages = {
show_my_calendar: "Afficher mon calendrier", show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends", show_weekends: "Afficher les week-ends",
copy_range: "Copier", copy_range: "Copier",
copy_range_from_to: "Copier les plages d'un jour à l'autre", copy_range_from_to: "Copier les plages",
copy_range_to_next_day: "Copier les plages du jour au jour suivant", from_day_to_day: "d'un jour à l'autre",
copy_range_from_day: "Copier les plages du ", from_week_to_week: "d'une semaine à l'autre",
to_the_next_day: " au jour suivant",
copy_range_to_next_week: "Copier les plages de la semaine à la semaine suivante",
copy_range_how_to: "Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.", copy_range_how_to: "Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.",
new_range_to_save: "Nouvelles plages à enregistrer", new_range_to_save: "Nouvelles plages à enregistrer",
update_range_to_save: "Plages à modifier", update_range_to_save: "Plages à modifier",

View File

@ -52,6 +52,23 @@ export default <Module<CalendarRangesState, State>>{
} }
} }
return founds;
},
getRangesOnWeek: (state: CalendarRangesState) => (mondayDate: Date): EventInputCalendarRange[] => {
const founds = [];
for (let d of Array.from(Array(7).keys())) {
const dateOfWeek = new Date(mondayDate);
dateOfWeek.setDate(mondayDate.getDate() + d);
const dateStr = <string>dateToISO(dateOfWeek);
for (let range of state.ranges) {
if (isEventInputCalendarRange(range)
&& range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
}
return founds; return founds;
}, },
}, },
@ -238,7 +255,7 @@ export default <Module<CalendarRangesState, State>>{
for (let r of rangesToCopy) { for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start)); let start = new Date(<Date>ISOToDatetime(r.start));
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()) start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let end = new Date(<Date>ISOToDatetime(r.end)); let end = new Date(<Date>ISOToDatetime(r.end));
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let location = ctx.rootGetters['locations/getLocationById'](r.locationId); let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
@ -246,6 +263,23 @@ export default <Module<CalendarRangesState, State>>{
promises.push(ctx.dispatch('createRange', {start, end, location})); promises.push(ctx.dispatch('createRange', {start, end, location}));
} }
return Promise.all(promises).then(_ => Promise.resolve(null));
},
copyFromWeekToAnotherWeek(ctx, {fromMonday, toMonday}: {fromMonday: Date, toMonday: Date}): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnWeek'](fromMonday);
const promises = [];
const diffTime = toMonday.getTime() - fromMonday.getTime();
for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start));
let end = new Date(<Date>ISOToDatetime(r.end));
start.setTime(start.getTime() + diffTime);
end.setTime(end.getTime() + diffTime);
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
promises.push(ctx.dispatch('createRange', {start, end, location}));
}
return Promise.all(promises).then(_ => Promise.resolve(null)); return Promise.all(promises).then(_ => Promise.resolve(null));
} }
} }

View File

@ -16,6 +16,7 @@ const emit = defineEmits<{
const is_dragging: Ref<boolean> = ref(false); const is_dragging: Ref<boolean> = ref(false);
const uploading: Ref<boolean> = ref(false); const uploading: Ref<boolean> = ref(false);
const display_filename: Ref<string|null> = ref(null);
const has_existing_doc = computed<boolean>(() => { const has_existing_doc = computed<boolean>(() => {
return props.existingDoc !== undefined && props.existingDoc !== null; return props.existingDoc !== undefined && props.existingDoc !== null;
@ -77,6 +78,7 @@ const onFileChange = async (event: Event): Promise<void> => {
const handleFile = async (file: File): Promise<void> => { const handleFile = async (file: File): Promise<void> => {
uploading.value = true; uploading.value = true;
display_filename.value = file.name;
const type = file.type; const type = file.type;
// create a stored_object if not exists // create a stored_object if not exists
@ -108,7 +110,7 @@ const handleFile = async (file: File): Promise<void> => {
<template> <template>
<div class="drop-file"> <div class="drop-file">
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop"> <div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<p v-if="has_existing_doc"> <p v-if="has_existing_doc" class="file-icon">
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i> <i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i> <i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i> <i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
@ -120,6 +122,8 @@ const handleFile = async (file: File): Promise<void> => {
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i> <i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i> <i class="fa fa-file-code-o" v-else ></i>
</p> </p>
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
<!-- todo i18n --> <!-- todo i18n -->
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p> <p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p> <p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
@ -135,9 +139,18 @@ const handleFile = async (file: File): Promise<void> => {
.drop-file { .drop-file {
width: 100%; width: 100%;
.file-icon {
font-size: xx-large;
}
.display-filename {
font-variant: small-caps;
font-weight: 200;
}
& > .area, & > .waiting { & > .area, & > .waiting {
width: 100%; width: 100%;
height: 8rem; height: 10rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -158,4 +171,5 @@ const handleFile = async (file: File): Promise<void> => {
} }
} }
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<a :class="props.classes" @click="download_and_open($event)"> <a :class="props.classes" @click="download_and_open($event)" ref="btn">
<i class="fa fa-file-pdf-o"></i> <i class="fa fa-file-pdf-o"></i>
Télécharger en pdf Télécharger en pdf
</a> </a>
@ -9,7 +9,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers"; import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime"; import mime from "mime";
import {reactive} from "vue"; import {reactive, ref} from "vue";
import {StoredObject} from "../../types"; import {StoredObject} from "../../types";
interface ConvertButtonConfig { interface ConvertButtonConfig {
@ -24,6 +24,7 @@ interface DownloadButtonState {
const props = defineProps<ConvertButtonConfig>(); const props = defineProps<ConvertButtonConfig>();
const state: DownloadButtonState = reactive({content: null}); const state: DownloadButtonState = reactive({content: null});
const btn = ref<HTMLAnchorElement | null>(null);
async function download_and_open(event: Event): Promise<void> { async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement; const button = event.target as HTMLAnchorElement;
@ -41,6 +42,14 @@ async function download_and_open(event: Event): Promise<void> {
} }
button.click(); button.click();
const reset_pending = setTimeout(reset_state, 45000);
}
function reset_state(): void {
state.content = null;
btn.value?.removeAttribute('download');
btn.value?.removeAttribute('href');
btn.value?.removeAttribute('type');
} }
</script> </script>

View File

@ -76,6 +76,15 @@ async function download_and_open(event: Event): Promise<void> {
await nextTick(); await nextTick();
open_button.value?.click(); open_button.value?.click();
console.log('open button should have been clicked');
const timer = setTimeout(reset_state, 45000);
}
function reset_state(): void {
state.href_url = '#';
state.is_ready = false;
state.is_running = false;
} }
</script> </script>

View File

@ -94,4 +94,38 @@ class NotificationApiController
return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false); return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false);
} }
/**
* @Route("/mark/allread", name="chill_api_main_notification_mark_allread", methods={"POST"})
*/
public function markAllRead(): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$modifiedNotificationIds = $this->notificationRepository->markAllNotificationAsReadForUser($user);
return new JsonResponse($modifiedNotificationIds);
}
/**
* @Route("/mark/undoallread", name="chill_api_main_notification_mark_undoallread", methods={"POST"})
*/
public function undoAllRead(Request $request): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$ids = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$touchedIds = $this->notificationRepository->markAllNotificationAsUnreadForUser($user, $ids);
return new JsonResponse($touchedIds);
}
} }

View File

@ -169,7 +169,7 @@ class NotificationController extends AbstractController
#[Route(path: '/inbox', name: 'chill_main_notification_my')] #[Route(path: '/inbox', name: 'chill_main_notification_my')]
public function inboxAction(): Response public function inboxAction(): Response
{ {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('ROLE_USER');
$currentUser = $this->security->getUser(); $currentUser = $this->security->getUser();
$notificationsNbr = $this->notificationRepository->countAllForAttendee($currentUser); $notificationsNbr = $this->notificationRepository->countAllForAttendee($currentUser);
@ -177,8 +177,8 @@ class NotificationController extends AbstractController
$notifications = $this->notificationRepository->findAllForAttendee( $notifications = $this->notificationRepository->findAllForAttendee(
$currentUser, $currentUser,
$limit = $paginator->getItemsPerPage(), $paginator->getItemsPerPage(),
$offset = $paginator->getCurrentPage()->getFirstItemNumber() $paginator->getCurrentPage()->getFirstItemNumber()
); );
return $this->render('@ChillMain/Notification/list.html.twig', [ return $this->render('@ChillMain/Notification/list.html.twig', [

View File

@ -278,7 +278,7 @@ final class PasswordController extends AbstractController
} }
/** /**
* @return \Symfony\Component\Form\Form * @return \Symfony\Component\Form\FormInterface
*/ */
private function passwordForm(User $user) private function passwordForm(User $user)
{ {

View File

@ -214,7 +214,7 @@ class UserController extends CRUDController
return $this->redirect( return $this->redirect(
$request->query->has('returnPath') ? $request->query->get('returnPath') : $request->query->has('returnPath') ? $request->query->get('returnPath') :
$this->generateUrl('chill_main_homepage') $this->generateUrl('chill_main_homepage')
); );
} }
@ -249,7 +249,7 @@ class UserController extends CRUDController
return $this->redirect( return $this->redirect(
$request->query->has('returnPath') ? $request->query->get('returnPath') : $request->query->has('returnPath') ? $request->query->get('returnPath') :
$this->generateUrl('chill_crud_admin_user_edit', ['id' => $user->getId()]) $this->generateUrl('chill_crud_admin_user_edit', ['id' => $user->getId()])
); );
} }
@ -264,6 +264,7 @@ class UserController extends CRUDController
return $this->getFilterOrderHelperFactory() return $this->getFilterOrderHelperFactory()
->create(self::class) ->create(self::class)
->addSearchBox(['label']) ->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build(); ->build();
} }
@ -273,11 +274,7 @@ class UserController extends CRUDController
return parent::countEntities($action, $request, $filterOrder); return parent::countEntities($action, $request, $filterOrder);
} }
if (null === $filterOrder->getQueryString()) { return $this->userRepository->countFilteredUsers($filterOrder->getQueryString(), $filterOrder->getCheckboxData('activeFilter'));
return parent::countEntities($action, $request, $filterOrder);
}
return $this->userRepository->countByUsernameOrEmail($filterOrder->getQueryString());
} }
protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface
@ -334,16 +331,13 @@ class UserController extends CRUDController
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder); return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
} }
if (null === $filterOrder->getQueryString()) { $queryString = $filterOrder->getQueryString();
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder); $activeFilter = $filterOrder->getCheckboxData('activeFilter');
} $nb = $this->userRepository->countFilteredUsers($queryString, $activeFilter);
return $this->userRepository->findByUsernameOrEmail( $paginator = $this->getPaginatorFactory()->create($nb);
$filterOrder->getQueryString(),
['usernameCanonical' => 'ASC'], return $this->userRepository->findFilteredUsers($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
} }
protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request) protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request)
@ -374,10 +368,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : []; $returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder() return $this->createFormBuilder()
->setAction($this->generateUrl( ->setAction(
'admin_user_add_groupcenter', $this->generateUrl(
array_merge($returnPathParams, ['uid' => $user->getId()]) 'admin_user_add_groupcenter',
)) array_merge($returnPathParams, ['uid' => $user->getId()])
)
)
->setMethod('POST') ->setMethod('POST')
->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class) ->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class)
->add('submit', SubmitType::class, ['label' => 'Add a new groupCenter']) ->add('submit', SubmitType::class, ['label' => 'Add a new groupCenter'])
@ -392,10 +388,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : []; $returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder() return $this->createFormBuilder()
->setAction($this->generateUrl( ->setAction(
'admin_user_delete_groupcenter', $this->generateUrl(
array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()]) 'admin_user_delete_groupcenter',
)) array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()])
)
)
->setMethod('DELETE') ->setMethod('DELETE')
->add('submit', SubmitType::class, ['label' => 'Delete']) ->add('submit', SubmitType::class, ['label' => 'Delete'])
->getForm(); ->getForm();

View File

@ -24,9 +24,9 @@ interface CronJobInterface
* *
* If data is returned, this data is passed as argument on the next execution * If data is returned, this data is passed as argument on the next execution
* *
* @param array $lastExecutionData the data which was returned from the previous execution * @param array<string|int, int|float|string|bool|array<int|string, int|float|string|bool>> $lastExecutionData the data which was returned from the previous execution
* *
* @return array|null optionally return an array with the same data than the previous execution * @return array<string|int, int|float|string|bool|array<int|string, int|float|string|bool>>|null optionally return an array with the same data than the previous execution
*/ */
public function run(array $lastExecutionData): ?array; public function run(array $lastExecutionData): ?array;
} }

View File

@ -13,6 +13,8 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Statement; use Doctrine\DBAL\Statement;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -81,10 +83,7 @@ final class NotificationRepository implements ObjectRepository
$results->free(); $results->free();
} else { } else {
$wheres = []; $wheres = [];
foreach ([ foreach ([['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId], ...$more] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) {
['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId],
...$more,
] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) {
$wheres[] = "(relatedEntityClass = :relatedEntityClass_{$k} AND relatedEntityId = :relatedEntityId_{$k})"; $wheres[] = "(relatedEntityClass = :relatedEntityClass_{$k} AND relatedEntityId = :relatedEntityId_{$k})";
$sqlParams["relatedEntityClass_{$k}"] = $relClass; $sqlParams["relatedEntityClass_{$k}"] = $relClass;
$sqlParams["relatedEntityId_{$k}"] = $relId; $sqlParams["relatedEntityId_{$k}"] = $relId;
@ -228,11 +227,11 @@ final class NotificationRepository implements ObjectRepository
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn'); $rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '. $sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
'FROM chill_main_notification cmn '. 'FROM chill_main_notification cmn '.
'WHERE '. 'WHERE '.
'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) '. 'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) '.
'ORDER BY cmn.date DESC '. 'ORDER BY cmn.date DESC '.
'LIMIT :limit OFFSET :offset'; 'LIMIT :limit OFFSET :offset';
$nq = $this->em->createNativeQuery($sql, $rsm) $nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId()) ->setParameter('userId', $user->getId())
@ -255,10 +254,12 @@ final class NotificationRepository implements ObjectRepository
$qb = $this->repository->createQueryBuilder('n'); $qb = $this->repository->createQueryBuilder('n');
// add condition for related entity (in main arguments, and in more) // add condition for related entity (in main arguments, and in more)
$or = $qb->expr()->orX($qb->expr()->andX( $or = $qb->expr()->orX(
$qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'), $qb->expr()->andX(
$qb->expr()->eq('n.relatedEntityId', ':relatedEntityId') $qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'),
)); $qb->expr()->eq('n.relatedEntityId', ':relatedEntityId')
)
);
$qb $qb
->setParameter('relatedEntityClass', $relatedEntityClass) ->setParameter('relatedEntityClass', $relatedEntityClass)
->setParameter('relatedEntityId', $relatedEntityId); ->setParameter('relatedEntityId', $relatedEntityId);
@ -310,4 +311,86 @@ final class NotificationRepository implements ObjectRepository
return $qb; return $qb;
} }
/**
* @return list<int> the ids of the notifications marked as unread
*/
public function markAllNotificationAsReadForUser(User $user): array
{
// Get the database connection from the entity manager
$connection = $this->em->getConnection();
/** @var Result $results */
$results = $connection->transactional(function (Connection $connection) use ($user) {
// Define the SQL query
$sql = <<<'SQL'
DELETE FROM chill_main_notification_addresses_unread
WHERE user_id = :user_id
RETURNING notification_id
SQL;
return $connection->executeQuery($sql, ['user_id' => $user->getId()]);
});
$notificationIdsTouched = [];
foreach ($results->iterateAssociative() as $row) {
$notificationIdsTouched[] = $row['notification_id'];
}
return array_values($notificationIdsTouched);
}
/**
* @param list<int> $notificationIds
*/
public function markAllNotificationAsUnreadForUser(User $user, array $notificationIds): array
{
// Get the database connection from the entity manager
$connection = $this->em->getConnection();
/** @var Result $results */
$results = $connection->transactional(function (Connection $connection) use ($user, $notificationIds) {
// This query double-check that the user is one of the addresses of the notification or the sender,
// if the notification is already marked as unread, this query does not fails.
// this query return the list of notification id which are affected
$sql = <<<'SQL'
INSERT INTO chill_main_notification_addresses_unread (user_id, notification_id)
SELECT ?, chill_main_notification_addresses_user.notification_id
FROM chill_main_notification_addresses_user JOIN chill_main_notification ON chill_main_notification_addresses_user.notification_id = chill_main_notification.id
WHERE (chill_main_notification_addresses_user.user_id = ? OR chill_main_notification.sender_id = ?)
AND chill_main_notification_addresses_user.notification_id IN ({ notification_ids })
ON CONFLICT (user_id, notification_id) DO NOTHING
RETURNING notification_id
SQL;
$params = [$user->getId(), $user->getId(), $user->getId(), ...array_values($notificationIds)];
$sql = strtr($sql, ['{ notification_ids }' => implode(', ', array_fill(0, count($notificationIds), '?'))]);
return $connection->executeQuery($sql, $params);
});
$notificationIdsTouched = [];
foreach ($results->iterateAssociative() as $row) {
$notificationIdsTouched[] = $row['notification_id'];
}
return array_values($notificationIdsTouched);
}
public function findAllUnreadByUser(User $user): array
{
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
'FROM chill_main_notification cmn '.
'WHERE '.
'EXISTS (SELECT 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId AND cmnau.notification_id = cmn.id) '.
'ORDER BY cmn.date DESC';
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId());
return $nq->getResult();
}
} }

View File

@ -17,6 +17,7 @@ use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException; use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\Query\ResultSetMappingBuilder;
@ -26,9 +27,25 @@ final readonly class UserRepository implements UserRepositoryInterface
{ {
private EntityRepository $repository; private EntityRepository $repository;
private const FIELDS = ['id', 'email', 'enabled', 'civility_id', 'civility_abbreviation', 'civility_name', 'label', 'mainCenter_id', private const FIELDS = [
'mainCenter_name', 'mainScope_id', 'mainScope_name', 'userJob_id', 'userJob_name', 'currentLocation_id', 'currentLocation_name', 'id',
'mainLocation_id', 'mainLocation_name']; 'email',
'enabled',
'civility_id',
'civility_abbreviation',
'civility_name',
'label',
'mainCenter_id',
'mainCenter_name',
'mainScope_id',
'mainScope_name',
'userJob_id',
'userJob_name',
'currentLocation_id',
'currentLocation_name',
'mainLocation_id',
'mainLocation_name',
];
public function __construct(private EntityManagerInterface $entityManager, private Connection $connection) public function __construct(private EntityManagerInterface $entityManager, private Connection $connection)
{ {
@ -296,6 +313,25 @@ final readonly class UserRepository implements UserRepositoryInterface
return User::class; return User::class;
} }
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('u');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('u.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByUsernameOrEmail(string $pattern): QueryBuilder private function queryByUsernameOrEmail(string $pattern): QueryBuilder
{ {
$qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u'); $qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u');
@ -312,4 +348,49 @@ final readonly class UserRepository implements UserRepositoryInterface
return $qb; return $qb;
} }
public function buildFilterBaseQuery(?string $queryString, array $isActive)
{
if (null !== $queryString) {
$qb = $this->queryByUsernameOrEmail($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u');
}
// Add condition based on active/inactive status
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere('u.enabled = true');
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere('u.enabled = false');
}
return $qb;
}
public function findFilteredUsers(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['username' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredUsers(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(u)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
} }

View File

@ -1,14 +1,16 @@
import {createApp} from "vue"; import { createApp } from "vue";
import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue"; import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue";
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n"; import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
import NotificationReadAllToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadAllToggle.vue";
const i18n = _createI18n({}); const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) { window.addEventListener("DOMContentLoaded", function (e) {
document.querySelectorAll('.notification_toggle_read_status') document
.forEach(function (el, i) { .querySelectorAll(".notification_toggle_read_status")
createApp({ .forEach(function (el, i) {
template: `<notification-read-toggle createApp({
template: `<notification-read-toggle
:notificationId="notificationId" :notificationId="notificationId"
:buttonClass="buttonClass" :buttonClass="buttonClass"
:buttonNoText="buttonNoText" :buttonNoText="buttonNoText"
@ -17,40 +19,45 @@ window.addEventListener('DOMContentLoaded', function (e) {
@markRead="onMarkRead" @markRead="onMarkRead"
@markUnread="onMarkUnread"> @markUnread="onMarkUnread">
</notification-read-toggle>`, </notification-read-toggle>`,
components: { components: {
NotificationReadToggle, NotificationReadToggle,
}, },
data() { data() {
return { return {
notificationId: el.dataset.notificationId, notificationId: parseInt(el.dataset.notificationId),
buttonClass: el.dataset.buttonClass, buttonClass: el.dataset.buttonClass,
buttonNoText: 'false' === el.dataset.buttonText, buttonNoText: "false" === el.dataset.buttonText,
showUrl: el.dataset.showButtonUrl, showUrl: el.dataset.showButtonUrl,
isRead: 1 === Number.parseInt(el.dataset.notificationCurrentIsRead), isRead: 1 === Number.parseInt(el.dataset.notificationCurrentIsRead),
container: el.dataset.container container: el.dataset.container,
} };
}, },
computed: { computed: {
getContainer() { getContainer() {
return document.querySelectorAll(`div.${this.container}`); return document.querySelectorAll(`div.${this.container}`);
} },
}, },
methods: { methods: {
onMarkRead() { onMarkRead() {
if (typeof this.getContainer[i] !== 'undefined') { if (typeof this.getContainer[i] !== "undefined") {
this.getContainer[i].classList.replace('read', 'unread'); this.getContainer[i].classList.replace("read", "unread");
} else { throw 'data-container attribute is missing' } } else {
this.isRead = false; throw "data-container attribute is missing";
}, }
onMarkUnread() { this.isRead = false;
if (typeof this.getContainer[i] !== 'undefined') { },
this.getContainer[i].classList.replace('unread', 'read'); onMarkUnread() {
} else { throw 'data-container attribute is missing' } if (typeof this.getContainer[i] !== "undefined") {
this.isRead = true; this.getContainer[i].classList.replace("unread", "read");
}, } else {
} throw "data-container attribute is missing";
}) }
.use(i18n) this.isRead = true;
.mount(el); },
}); },
})
.use(i18n)
.mount(el);
});
}); });

View File

@ -0,0 +1,39 @@
import { createApp } from "vue";
import { _createI18n } from "../../vuejs/_js/i18n";
import NotificationReadAllToggle from "../../vuejs/_components/Notification/NotificationReadAllToggle.vue";
const i18n = _createI18n({});
document.addEventListener("DOMContentLoaded", function () {
const elements = document.querySelectorAll(".notification_all_read");
elements.forEach((element) => {
console.log('launch');
createApp({
template: `<notification-read-all-toggle @markAsRead="markAsRead" @markAsUnRead="markAsUnread"></notification-read-all-toggle>`,
components: {
NotificationReadAllToggle,
},
methods: {
markAsRead(id: number) {
const el = document.querySelector<HTMLDivElement>(`div.notification-status[data-notification-id="${id}"]`);
if (el === null) {
return;
}
el.classList.add('read');
el.classList.remove('unread');
},
markAsUnread(id: number) {
const el = document.querySelector<HTMLDivElement>(`div.notification-status[data-notification-id="${id}"]`);
if (el === null) {
return;
}
el.classList.remove('read');
el.classList.add('unread');
},
}
})
.use(i18n)
.mount(element);
});
});

View File

@ -0,0 +1,50 @@
<template>
<div>
<button v-if="idsMarkedAsRead.length === 0"
class="btn btn-primary"
type="button"
@click="markAllRead"
>
<i class="fa fa-sm fa-envelope-open-o"></i> Marquer tout comme lu
</button>
<button v-else
class="btn btn-primary"
type="button"
@click="undo"
>
<i class="fa fa-sm fa-envelope-open-o"></i> Annuler
</button>
</div>
</template>
<script lang="ts" setup>
import { makeFetch } from "../../../lib/api/apiMethods";
import { ref } from "vue";
const emit = defineEmits<{
(e: 'markAsRead', id: number): void,
(e: 'markAsUnRead', id: number): void,
}>();
const idsMarkedAsRead = ref([] as number[]);
async function markAllRead() {
const ids: number[] = await makeFetch("POST", `/api/1.0/main/notification/mark/allread`, null);
for (let i of ids) {
idsMarkedAsRead.value.push(i);
emit('markAsRead', i);
}
}
async function undo() {
const touched: number[] = await makeFetch("POST", `/api/1.0/main/notification/mark/undoallread`, idsMarkedAsRead.value);
while (idsMarkedAsRead.value.length > 0) {
idsMarkedAsRead.value.pop();
}
for (let t of touched) {
emit('markAsUnRead', t);
}
};
</script>
<style lang="scss" scoped></style>

View File

@ -1,47 +1,66 @@
<template> <template>
<div :class="{'btn-group btn-group-sm float-end': isButtonGroup }" <div
role="group" aria-label="Notification actions"> :class="{ 'btn-group btn-group-sm float-end': isButtonGroup }"
role="group"
<button v-if="isRead" aria-label="Notification actions"
class="btn" >
:class="overrideClass" <button
type="button" v-if="isRead"
:title="$t('markAsUnread')" class="btn"
@click="markAsUnread" :class="overrideClass"
> type="button"
:title="$t('markAsUnread')"
@click="markAsUnread"
>
<i class="fa fa-sm fa-envelope-o"></i> <i class="fa fa-sm fa-envelope-o"></i>
<span v-if="!buttonNoText" class="ps-2"> <span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsUnread') }} {{ $t("markAsUnread") }}
</span> </span>
</button> </button>
<button v-if="!isRead" <button
class="btn" v-if="!isRead"
:class="overrideClass" class="btn"
type="button" :class="overrideClass"
:title="$t('markAsRead')" type="button"
@click="markAsRead" :title="$t('markAsRead')"
> @click="markAsRead"
>
<i class="fa fa-sm fa-envelope-open-o"></i> <i class="fa fa-sm fa-envelope-open-o"></i>
<span v-if="!buttonNoText" class="ps-2"> <span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsRead') }} {{ $t("markAsRead") }}
</span> </span>
</button> </button>
<a v-if="isButtonGroup" <a
v-if="isButtonGroup"
type="button" type="button"
class="btn btn-outline-primary" class="btn btn-outline-primary"
:href="showUrl" :href="showUrl"
:title="$t('action.show')" :title="$t('action.show')"
> >
<i class="fa fa-sm fa-comment-o"></i> <i class="fa fa-sm fa-comment-o"></i>
</a> </a>
<!-- "Mark All Read" button -->
<button
v-if="showMarkAllButton"
class="btn"
:class="overrideClass"
type="button"
:title="$t('markAllRead')"
@click="markAllRead"
>
<i class="fa fa-sm fa-envelope-o"></i>
<span v-if="!buttonNoText" class="ps-2">
{{ $t("markAllRead") }}
</span>
</button>
</div> </div>
</template> </template>
<script> <script>
import { makeFetch } from 'ChillMainAssets/lib/api/apiMethods.ts'; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods.ts";
export default { export default {
name: "NotificationReadToggle", name: "NotificationReadToggle",
@ -57,7 +76,7 @@ export default {
// Optional // Optional
buttonClass: { buttonClass: {
required: false, required: false,
type: String type: String,
}, },
buttonNoText: { buttonNoText: {
required: false, required: false,
@ -65,14 +84,14 @@ export default {
}, },
showUrl: { showUrl: {
required: false, required: false,
type: String type: String,
} },
}, },
emits: ['markRead', 'markUnread'], emits: ["markRead", "markUnread"],
computed: { computed: {
/// [Option] override default button appearance (btn-misc) /// [Option] override default button appearance (btn-misc)
overrideClass() { overrideClass() {
return this.buttonClass ? this.buttonClass : 'btn-misc' return this.buttonClass ? this.buttonClass : "btn-misc";
}, },
/// [Option] don't display text on button /// [Option] don't display text on button
buttonHideText() { buttonHideText() {
@ -82,31 +101,48 @@ export default {
// When passed, the component return a button-group with 2 buttons. // When passed, the component return a button-group with 2 buttons.
isButtonGroup() { isButtonGroup() {
return this.showUrl; return this.showUrl;
} },
}, },
methods: { methods: {
markAsUnread() { markAsUnread() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/unread`, []).then(response => { makeFetch(
this.$emit('markRead', { notificationId: this.notificationId }); "POST",
}) `/api/1.0/main/notification/${this.notificationId}/mark/unread`,
[]
).then((response) => {
this.$emit("markRead", {notificationId: this.notificationId});
});
}, },
markAsRead() { markAsRead() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/read`, []).then(response => { makeFetch(
this.$emit('markUnread', { notificationId: this.notificationId }); "POST",
}) `/api/1.0/main/notification/${this.notificationId}/mark/read`,
[]
).then((response) => {
this.$emit("markUnread", {
notificationId: this.notificationId,
});
});
},
markAllRead() {
makeFetch(
"POST",
`/api/1.0/main/notification/markallread`,
[]
).then((response) => {
this.$emit("markAllRead");
});
}, },
}, },
i18n: { i18n: {
messages: { messages: {
fr: { fr: {
markAsUnread: 'Marquer comme non-lu', markAsUnread: "Marquer comme non-lu",
markAsRead: 'Marquer comme lu' markAsRead: "Marquer comme lu",
} },
} },
} },
} };
</script> </script>
<style lang="scss"> <style lang="scss"></style>
</style>

View File

@ -1,30 +1,33 @@
{% macro title(c) %} {% macro title(c) %}
<div class="item-row title"> <div class="item-row title">
<h2 class="notification-title"> <h2 class="notification-title">
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}"> <a
href="{{ chill_path_add_return_path('chill_main_notification_show', {
id: c.notification.id
}) }}"
>
{{ c.notification.title }} {{ c.notification.title }}
</a> </a>
</h2> </h2>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro header(c) %} {% macro header(c) %}
<div class="item-row notification-header mt-2"> <div class="item-row notification-header mt-2">
<div class="item-col"> <div class="item-col">
<ul class="small_in_title"> <ul class="small_in_title">
{% if c.step is not defined or c.step == 'inbox' %} {% if c.step is not defined or c.step == 'inbox' %}
<li class="notification-from"> <li class="notification-from">
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.received_from'|trans }}"> <abbr title="{{ 'notification.received_from' | trans }}">
{{ 'notification.from'|trans }} : {{ "notification.from" | trans }} :
</abbr> </abbr>
</span> </span>
{% if not c.notification.isSystem %} {% if not c.notification.isSystem %}
<span class="badge-user"> <span class="badge-user">
{{ c.notification.sender|chill_entity_render_string({'at_date': c.notification.date}) }} {{ c.notification.sender | chill_entity_render_string({'at_date': c.notification.date}) }}
</span> </span>
{% else %} {% else %}
<span class="badge-user system">{{ 'notification.is_system'|trans }}</span> <span class="badge-user system">{{ "notification.is_system" | trans }}</span>
{% endif %} {% endif %}
</li> </li>
{% endif %} {% endif %}
@ -32,34 +35,37 @@
<li class="notification-to"> <li class="notification-to">
{% if c.notification_cc is defined %} {% if c.notification_cc is defined %}
{% if c.notification_cc %} {% if c.notification_cc %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_cc'|trans }}"> <abbr title="{{ 'notification.sent_cc' | trans }}">
{{ 'notification.cc'|trans }} : {{ "notification.cc" | trans }} :
</abbr> </abbr>
</span> </span>
{% else %} {% else %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_to'|trans }}"> <abbr title="{{ 'notification.sent_to' | trans }}">
{{ 'notification.to'|trans }} : {{ "notification.to" | trans }} :
</abbr> </abbr>
</span> </span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_to'|trans }}"> <abbr title="{{ 'notification.sent_to' | trans }}">
{{ 'notification.to'|trans }} : {{ "notification.to" | trans }} :
</abbr> </abbr>
</span> </span>
{% endif %} {% endif %}
{% for a in c.notification.addressees %} {% for a in c.notification.addressees %}
<span class="badge-user"> <span class="badge-user">
{{ a|chill_entity_render_string({'at_date': c.notification.date}) }} {{ a | chill_entity_render_string({'at_date': c.notification.date}) }}
</span> </span>
{% endfor %} {% endfor %}
{% for a in c.notification.addressesEmails %} {% for a in c.notification.addressesEmails %}
<span class="badge-user" title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"> <span
{{ a }} class="badge-user"
</span> title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"
>
{{ a }}
</span>
{% endfor %} {% endfor %}
</li> </li>
{% endif %} {% endif %}
@ -70,7 +76,6 @@
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro content(c) %} {% macro content(c) %}
<div class="item-row separator"> <div class="item-row separator">
{% if c.data is defined %} {% if c.data is defined %}
@ -83,60 +88,77 @@
<div class="notification-content"> <div class="notification-content">
{% if c.full_content is defined and c.full_content == true %} {% if c.full_content is defined and c.full_content == true %}
{% if c.notification.message is not empty %} {% if c.notification.message is not empty %}
{{ c.notification.message|chill_markdown_to_html }} {{ c.notification.message | chill_markdown_to_html }}
{% else %} {% else %}
<p class="chill-no-data-statement">{{ 'Any comment'|trans }}</p> <p class="chill-no-data-statement">{{ "Any comment" | trans }}</p>
{% endif %} {% endif %}
{% else %} {% else %}
{% if c.notification.message is not empty %} {% if c.notification.message is not empty %}
{{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }} {{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
<p class="read-more"><a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">{{ 'Read more'|trans }}</a></p> <p class="read-more">
<a
href="{{ chill_path_add_return_path('chill_main_notification_show', {
id: c.notification.id
}) }}"
>{{ "Read more" | trans }}</a>
</p>
{% else %} {% else %}
<p class="chill-no-data-statement">{{ 'Any comment'|trans }}</p> <p class="chill-no-data-statement">{{ "Any comment" | trans }}</p>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro actions(c) %} {% macro actions(c) %}
{% if c.action_button is not defined or c.action_button != false %} {% if c.action_button is not defined or c.action_button != false %}
<div class="item-row separator"> <div class="item-row separator">
<div class="item-col item-meta"> <div class="item-col item-meta">
{% if c.notification.comments|length > 0 %} {% if c.notification.comments|length > 0 %}
<div class="comment-counter"> <div class="comment-counter">
<span class="counter"> <span class="counter">
{{ 'notification.counter comments'|trans({'nb': c.notification.comments|length }) }} {{ 'notification.counter comments'|trans({'nb': c.notification.comments|length }) }}
</span> </span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="item-col"> <div class="item-col">
<ul class="record_actions"> <ul class="record_actions">
<li> <li>
{# Vue component #} {# Vue component #}
<span class="notification_toggle_read_status" <span
data-notification-id="{{ c.notification.id }}" class="notification_toggle_read_status"
data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}" data-notification-id="{{ c.notification.id }}"
data-container="notification-status" data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}"
data-container="notification-status"
></span> ></span>
</li> </li>
{% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %} {% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %}
<li> <li>
<a href="{{ chill_path_add_return_path('chill_main_notification_edit', {'id': c.notification.id}) }}" <a
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a> href="{{ chill_path_add_return_path(
'chill_main_notification_edit',
{ id: c.notification.id }
) }}"
class="btn btn-edit"
title="{{ 'Edit' | trans }}"
></a>
</li> </li>
{% endif %} {% endif %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', c.notification) %} {% if is_granted('CHILL_MAIN_NOTIFICATION_SEE',
c.notification) %}
<li> <li>
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}" <a
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}" title="{{ 'notification.see_comments_thread'|trans }}"> href="{{ chill_path_add_return_path(
'chill_main_notification_show',
{ id: c.notification.id }
) }}"
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}"
title="{{ 'notification.see_comments_thread' | trans }}"
>
{% if not c.notification.isSystem() %} {% if not c.notification.isSystem() %}
<i class="fa fa-comment"></i> <i class="fa fa-comment"></i>
{% else %} {% else %}
{{ 'Read more'|trans }} {{ "Read more" | trans }}
{% endif %} {% endif %}
</a> </a>
</li> </li>
@ -147,24 +169,30 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
<div class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}"> <div
class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}"
data-notification-id="{{ notification.id|escape('html_attr') }}"
>
{% if fold_item is defined and fold_item != false %} {% if fold_item is defined and fold_item != false %}
<div class="accordion-header" id="flush-heading-{{ notification.id }}"> <div class="accordion-header" id="flush-heading-{{ notification.id }}">
<button type="button" class="accordion-button collapsed" <button
data-bs-toggle="collapse" data-bs-target="#flush-collapse-{{ notification.id }}" type="button"
aria-expanded="false" aria-controls="flush-collapse-{{ notification.id }}"> class="accordion-button collapsed"
data-bs-toggle="collapse"
data-bs-target="#flush-collapse-{{ notification.id }}"
aria-expanded="false"
aria-controls="flush-collapse-{{ notification.id }}"
>
{{ _self.title(_context) }} {{ _self.title(_context) }}
</button> </button>
{{ _self.header(_context) }} {{ _self.header(_context) }}
</div> </div>
<div id="flush-collapse-{{ notification.id }}" <div
id="flush-collapse-{{ notification.id }}"
class="accordion-collapse collapse" class="accordion-collapse collapse"
aria-labelledby="flush-heading-{{ notification.id }}" aria-labelledby="flush-heading-{{ notification.id }}"
data-bs-parent="#notification-fold"> data-bs-parent="#notification-fold"
>
{{ _self.content(_context) }} {{ _self.content(_context) }}
</div> </div>
{{ _self.actions(_context) }} {{ _self.actions(_context) }}
@ -174,5 +202,4 @@
{{ _self.content(_context) }} {{ _self.content(_context) }}
{{ _self.actions(_context) }} {{ _self.actions(_context) }}
{% endif %} {% endif %}
</div> </div>

View File

@ -1,62 +1,78 @@
{% extends "@ChillMain/layout.html.twig" %} {% extends "@ChillMain/layout.html.twig" %}
{% block title 'notification.My own notifications'|trans %} {% block title 'notification.My own notifications'|trans %}
{% block js %} {% block js %}
{{ parent() }} {{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }} {{ encore_entry_script_tags("mod_notification_toggle_read_status") }}
{{ encore_entry_script_tags("mod_notification_toggle_read_all_status") }}
{% endblock %} {% endblock %}
{% block css %} {% block css %}
{{ parent() }} {{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }} {{ encore_entry_link_tags("mod_notification_toggle_read_status") }}
{{ encore_entry_link_tags("mod_notification_toggle_read_all_status") }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="col-10 notification notification-list"> <div class="col-10 notification notification-list">
<h1>{{ block('title') }}</h1> <h1>{{ block("title") }}</h1>
<ul class="nav nav-pills justify-content-center">
<li class="nav-item">
<a
class="nav-link {% if step == 'inbox' %}active{% endif %}"
href="{{ path('chill_main_notification_my') }}"
>
{{ "notification.Notifications received" | trans }}
{% if unreads['inbox'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads["inbox"] }}
</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a
class="nav-link {% if step == 'sent' %}active{% endif %}"
href="{{ path('chill_main_notification_sent') }}"
>
{{ "notification.Notifications sent" | trans }}
{% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads["sent"] }}
</span>
{% endif %}
</a>
</li>
</ul>
<ul class="nav nav-pills justify-content-center"> {% if datas|length == 0 %} {% if step == 'inbox' %}
<li class="nav-item"> <p class="chill-no-data-statement">
<a class="nav-link {% if step == 'inbox' %}active{% endif %}" href="{{ path('chill_main_notification_my') }}"> {{ "notification.Any notification received" | trans }}
{{ 'notification.Notifications received'|trans }} </p>
{% if unreads['inbox'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['inbox'] }}
</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if step == 'sent' %}active{% endif %}" href="{{ path('chill_main_notification_sent') }}">
{{ 'notification.Notifications sent'|trans }}
{% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['sent'] }}
</span>
{% endif %}
</a>
</li>
</ul>
{% if datas|length == 0 %}
{% if step == 'inbox' %}
<p class="chill-no-data-statement">{{ 'notification.Any notification received'|trans }}</p>
{% else %} {% else %}
<p class="chill-no-data-statement">{{ 'notification.Any notification sent'|trans }}</p> <p class="chill-no-data-statement">
{{ "notification.Any notification sent" | trans }}
</p>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="flex-table accordion accordion-flush" id="notification-fold"> <div class="flex-table accordion accordion-flush" id="notification-fold">
{% for data in datas %} {% for data in datas %}
{% set notification = data.notification %} {% set notification = data.notification %}
{% include '@ChillMain/Notification/_list_item.html.twig' with { {% include '@ChillMain/Notification/_list_item.html.twig' with {
'fold_item': true, 'fold_item': true, 'notification_cc': data.template_data.notificationCc
'notification_cc': data.template_data.notificationCc is defined ? data.template_data.notificationCc : false is defined ? data.template_data.notificationCc : false } %}
} %} {% endfor %}
{% endfor %} </div>
</div>
{{ chill_pagination(paginator) }}
{% endif %}
<ul class="record_actions sticky-form-buttons justify-content-end">
<li class="ml-auto d-flex align-items-center gap-2">
<span class="notification_all_read"></span>
</li>
</ul>
</div>
{{ chill_pagination(paginator) }}
{% endif %}
</div>
{% endblock content %} {% endblock content %}

View File

@ -65,7 +65,7 @@ class PasswordRecoverLocker
if (0 === $this->chillRedis->exists($key)) { if (0 === $this->chillRedis->exists($key)) {
$this->chillRedis->set($key, 1); $this->chillRedis->set($key, 1);
$this->chillRedis->setTimeout($key, $ttl); $this->chillRedis->expire($key, $ttl);
break; break;
} }

View File

@ -15,6 +15,12 @@ use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Statement; use Doctrine\DBAL\Statement;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/**
* Import addresses into the database.
*
* This importer do some optimization about the import, ensuring that adresses are inserted and reconciled with
* the existing one on a optimized way.
*/
final class AddressReferenceBaseImporter final class AddressReferenceBaseImporter
{ {
private const INSERT = <<<'SQL' private const INSERT = <<<'SQL'
@ -47,11 +53,18 @@ final class AddressReferenceBaseImporter
public function __construct(private readonly Connection $defaultConnection, private readonly LoggerInterface $logger) {} public function __construct(private readonly Connection $defaultConnection, private readonly LoggerInterface $logger) {}
public function finalize(): void /**
* Finalize the import process and make reconciliation with addresses.
*
* @param bool $allowRemoveDoubleRefId if true, allow the importer to remove automatically addresses with same refid
*
* @throws \Exception
*/
public function finalize(bool $allowRemoveDoubleRefId = false): void
{ {
$this->doInsertPending(); $this->doInsertPending();
$this->updateAddressReferenceTable(); $this->updateAddressReferenceTable($allowRemoveDoubleRefId);
$this->deleteTemporaryTable(); $this->deleteTemporaryTable();
@ -59,6 +72,11 @@ final class AddressReferenceBaseImporter
$this->isInitialized = false; $this->isInitialized = false;
} }
/**
* Do import a single address.
*
* @throws \Exception
*/
public function importAddress( public function importAddress(
string $refAddress, string $refAddress,
?string $refPostalCode, ?string $refPostalCode,
@ -167,15 +185,48 @@ final class AddressReferenceBaseImporter
$this->isInitialized = true; $this->isInitialized = true;
} }
private function updateAddressReferenceTable(): void private function updateAddressReferenceTable(bool $allowRemoveDoubleRefId): void
{ {
$this->defaultConnection->executeStatement( $this->defaultConnection->executeStatement(
'CREATE INDEX idx_ref_add_temp ON reference_address_temp (refid)' 'CREATE INDEX idx_ref_add_temp ON reference_address_temp (refid)'
); );
// 1) Add new addresses // 0) detect for doublon in current temporary table
$this->logger->info(self::LOG_PREFIX.'upsert new addresses'); $results = $this->defaultConnection->executeQuery(
$affected = $this->defaultConnection->executeStatement("INSERT INTO chill_main_address_reference 'SELECT COUNT(*) AS nb_appearance, refid FROM reference_address_temp GROUP BY refid HAVING count(*) > 1'
);
$hasDouble = false;
foreach ($results->iterateAssociative() as $result) {
$this->logger->error(self::LOG_PREFIX.'Some reference id are present more than one time', ['nb_apparearance' => $result['nb_appearance'], 'refid' => $result['refid']]);
$hasDouble = true;
}
if ($hasDouble) {
if ($allowRemoveDoubleRefId) {
$this->logger->alert(self::LOG_PREFIX.'We are going to remove the addresses which are present more than once in the table');
$this->defaultConnection->executeStatement('ALTER TABLE reference_address_temp ADD COLUMN gid SERIAL');
$removed = $this->defaultConnection->executeStatement(<<<'SQL'
WITH ordering AS (
SELECT gid, rank() over (PARTITION BY refid ORDER BY gid DESC) AS ranking
FROM reference_address_temp
WHERE refid IN (SELECT refid FROM reference_address_temp group by refid having count(*) > 1)
),
keep_last AS (
SELECT gid, ranking FROM ordering where ranking > 1
)
DELETE FROM reference_address_temp WHERE gid IN (SELECT gid FROM keep_last);
SQL);
$this->logger->alert(self::LOG_PREFIX.'addresses with same refid present twice, we removed some double', ['nb_removed', $removed]);
} else {
throw new \RuntimeException('Some addresses are present twice in the database, we cannot process them');
}
}
$this->defaultConnection->transactional(function ($connection): void {
// 1) Add new addresses
$this->logger->info(self::LOG_PREFIX.'upsert new addresses');
$affected = $connection->executeStatement("INSERT INTO chill_main_address_reference
(id, postcode_id, refid, street, streetnumber, municipalitycode, source, point, createdat, deletedat, updatedat) (id, postcode_id, refid, street, streetnumber, municipalitycode, source, point, createdat, deletedat, updatedat)
SELECT SELECT
nextval('chill_main_address_reference_id_seq'), nextval('chill_main_address_reference_id_seq'),
@ -193,16 +244,17 @@ final class AddressReferenceBaseImporter
ON CONFLICT (refid, source) DO UPDATE ON CONFLICT (refid, source) DO UPDATE
SET postcode_id = excluded.postcode_id, refid = excluded.refid, street = excluded.street, streetnumber = excluded.streetnumber, municipalitycode = excluded.municipalitycode, source = excluded.source, point = excluded.point, updatedat = NOW(), deletedAt = NULL SET postcode_id = excluded.postcode_id, refid = excluded.refid, street = excluded.street, streetnumber = excluded.streetnumber, municipalitycode = excluded.municipalitycode, source = excluded.source, point = excluded.point, updatedat = NOW(), deletedAt = NULL
"); ");
$this->logger->info(self::LOG_PREFIX.'addresses upserted', ['upserted' => $affected]); $this->logger->info(self::LOG_PREFIX.'addresses upserted', ['upserted' => $affected]);
// 3) Delete addresses // 3) Delete addresses
$this->logger->info(self::LOG_PREFIX.'soft delete adresses'); $this->logger->info(self::LOG_PREFIX.'soft delete adresses');
$affected = $this->defaultConnection->executeStatement('UPDATE chill_main_address_reference $affected = $connection->executeStatement('UPDATE chill_main_address_reference
SET deletedat = NOW() SET deletedat = NOW()
WHERE WHERE
chill_main_address_reference.refid NOT IN (SELECT refid FROM reference_address_temp WHERE source LIKE ?) chill_main_address_reference.refid NOT IN (SELECT refid FROM reference_address_temp WHERE source LIKE ?)
AND chill_main_address_reference.source LIKE ? AND chill_main_address_reference.source LIKE ?
', [$this->currentSource, $this->currentSource]); ', [$this->currentSource, $this->currentSource]);
$this->logger->info(self::LOG_PREFIX.'addresses deleted', ['deleted' => $affected]); $this->logger->info(self::LOG_PREFIX.'addresses deleted', ['deleted' => $affected]);
});
} }
} }

View File

@ -42,7 +42,8 @@ class PostalCodeBaseImporter
NOW(), NOW(),
NOW() NOW()
FROM g FROM g
ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE SET label = excluded.label, center = excluded.center, updatedAt = NOW() 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
SQL; SQL;
private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)'; private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)';

View File

@ -9,7 +9,7 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code. * the LICENSE file that was distributed with this source code.
*/ */
namespace Repository; namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\NewsItem; use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Repository\NewsItemRepository; use Chill\MainBundle\Repository\NewsItemRepository;

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NotificationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class NotificationRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private NotificationRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$this->repository = new NotificationRepository($this->entityManager);
}
public function testMarkAllNotificationAsReadForUser(): void
{
$user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)->getSingleResult();
$notification = (new Notification())
->setRelatedEntityClass('\Dummy')
->setRelatedEntityId(0)
;
$notification->addAddressee($user)->markAsUnreadBy($user);
$this->entityManager->persist($notification);
$this->entityManager->flush();
$notification->markAsUnreadBy($user);
$this->entityManager->flush();
$this->entityManager->refresh($notification);
if ($notification->isReadBy($user)) {
throw new \LogicException('Notification should not be marked as read');
}
$notificationsIds = $this->repository->markAllNotificationAsReadForUser($user);
self::assertContains($notification->getId(), $notificationsIds);
$this->entityManager->clear();
$notification = $this->entityManager->find(Notification::class, $notification->getId());
self::assertTrue($notification->isReadBy($user));
}
public function testMarkAllNotificationAsUnreadForUser(): void
{
$user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)->getSingleResult();
$notification = (new Notification())
->setRelatedEntityClass('\Dummy')
->setRelatedEntityId(0)
;
$notification->addAddressee($user); // we do not mark the notification as unread by the user
$this->entityManager->persist($notification);
$this->entityManager->flush();
$notification->markAsReadBy($user);
$this->entityManager->flush();
$this->entityManager->refresh($notification);
if (!$notification->isReadBy($user)) {
throw new \LogicException('Notification should be marked as read');
}
$notificationsIds = $this->repository->markAllNotificationAsUnreadForUser($user, [$notification->getId()]);
self::assertContains($notification->getId(), $notificationsIds);
}
}

View File

@ -0,0 +1,55 @@
<?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 ChillMainBundle\Tests\Repository;
use Chill\MainBundle\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class UserRepositoryTest extends KernelTestCase
{
private UserRepository $userRepository;
protected function setUp(): void
{
self::bootKernel();
$entityManager = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$connection = $entityManager->getConnection();
$this->userRepository = new UserRepository($entityManager, $connection);
}
public function testCountFilteredUsers(): void
{
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Active']));
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Active', 'Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Active']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Active', 'Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center'));
}
public function testFindByFilteredUsers(): void
{
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Active']));
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Active', 'Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Active']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Active', 'Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center'));
}
}

View File

@ -5,8 +5,8 @@ info:
title: "Chill api" title: "Chill api"
description: "Api documentation for chill. Currently, work in progress" description: "Api documentation for chill. Currently, work in progress"
servers: servers:
- url: "/api" - url: "/api"
description: "Your current dev server" description: "Your current dev server"
components: components:
schemas: schemas:
@ -165,7 +165,6 @@ components:
endDate: endDate:
$ref: "#/components/schemas/Date" $ref: "#/components/schemas/Date"
paths: paths:
/1.0/search.json: /1.0/search.json:
get: get:
@ -182,25 +181,25 @@ paths:
The results are ordered by relevance, from the most to the lowest relevant. The results are ordered by relevance, from the most to the lowest relevant.
parameters: parameters:
- name: q - name: q
in: query in: query
required: true required: true
description: the pattern to search description: the pattern to search
schema: schema:
type: string type: string
- name: type[] - name: type[]
in: query in: query
required: true required: true
description: the type entities amongst the search is performed description: the type entities amongst the search is performed
schema: schema:
type: array type: array
items: items:
type: string type: string
enum: enum:
- person - person
- thirdparty - thirdparty
- user - user
- household - household
responses: responses:
200: 200:
description: "OK" description: "OK"
@ -237,7 +236,7 @@ paths:
minItems: 2 minItems: 2
maxItems: 2 maxItems: 2
postcode: postcode:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
steps: steps:
type: string type: string
street: street:
@ -261,21 +260,21 @@ paths:
- address - address
summary: Return an address by id summary: Return an address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id description: The address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Address' $ref: "#/components/schemas/Address"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -285,14 +284,14 @@ paths:
- address - address
summary: patch an address summary: patch an address
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id description: The address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
requestBody: requestBody:
required: true required: true
content: content:
@ -321,7 +320,7 @@ paths:
minItems: 2 minItems: 2
maxItems: 2 maxItems: 2
postcode: postcode:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
steps: steps:
type: string type: string
street: street:
@ -344,28 +343,27 @@ paths:
400: 400:
description: "transition cannot be applyed" description: "transition cannot be applyed"
/1.0/main/address/{id}/duplicate.json: /1.0/main/address/{id}/duplicate.json:
post: post:
tags: tags:
- address - address
summary: Duplicate an existing address summary: Duplicate an existing address
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id that will be duplicated description: The address id that will be duplicated
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Address' $ref: "#/components/schemas/Address"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -377,12 +375,12 @@ paths:
- address - address
summary: Return a list of all reference addresses summary: Return a list of all reference addresses
parameters: parameters:
- in: query - in: query
name: postal_code name: postal_code
required: false required: false
schema: schema:
type: integer type: integer
description: The id of a postal code to filter the reference addresses description: The id of a postal code to filter the reference addresses
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -392,21 +390,21 @@ paths:
- address - address
summary: Return a reference address by id summary: Return a reference address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The reference address id description: The reference address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/AddressReference' $ref: "#/components/schemas/AddressReference"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -419,27 +417,27 @@ paths:
- search - search
summary: Return a reference address by id summary: Return a reference address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The reference address id description: The reference address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
- name: q - name: q
in: query in: query
required: true required: true
description: The search pattern description: The search pattern
schema: schema:
type: string type: string
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/AddressReference' $ref: "#/components/schemas/AddressReference"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -452,12 +450,12 @@ paths:
- address - address
summary: Return a list of all postal-code summary: Return a list of all postal-code
parameters: parameters:
- in: query - in: query
name: country name: country
required: false required: false
schema: schema:
type: integer type: integer
description: The id of a country to filter the postal code description: The id of a country to filter the postal code
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -477,7 +475,7 @@ paths:
code: code:
type: string type: string
country: country:
$ref: '#/components/schemas/Country' $ref: "#/components/schemas/Country"
responses: responses:
401: 401:
description: "Unauthorized" description: "Unauthorized"
@ -496,21 +494,21 @@ paths:
- address - address
summary: Return a postal code by id summary: Return a postal code by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The postal code id description: The postal code id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -523,25 +521,25 @@ paths:
- search - search
summary: Search a postal code summary: Search a postal code
parameters: parameters:
- name: q - name: q
in: query in: query
required: true required: true
description: The search pattern description: The search pattern
schema: schema:
type: string type: string
- name: country - name: country
in: query in: query
required: false required: false
description: The country id description: The country id
schema: schema:
type: integer type: integer
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
404: 404:
description: "not found" description: "not found"
400: 400:
@ -561,27 +559,26 @@ paths:
- address - address
summary: Return a country by id summary: Return a country by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The country id description: The country id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Country' $ref: "#/components/schemas/Country"
404: 404:
description: "not found" description: "not found"
401: 401:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/user.json: /1.0/main/user.json:
get: get:
tags: tags:
@ -612,21 +609,21 @@ paths:
- user - user
summary: Return a user by id summary: Return a user by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The user id description: The user id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/User' $ref: "#/components/schemas/User"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -649,14 +646,14 @@ paths:
- scope - scope
summary: return a list of scopes summary: return a list of scopes
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The scope id description: The scope id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -724,14 +721,14 @@ paths:
- location - location
summary: Return the given location summary: Return the given location
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The location id description: The location id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -784,21 +781,21 @@ paths:
id: 1 id: 1
class: 'Chill\PersonBundle\Entity\AccompanyingPeriod' class: 'Chill\PersonBundle\Entity\AccompanyingPeriod'
roles: roles:
- 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE' - "CHILL_PERSON_ACCOMPANYING_PERIOD_SEE"
/1.0/main/notification/{id}/mark/read: /1.0/main/notification/{id}/mark/read:
post: post:
tags: tags:
- notification - notification
summary: mark a notification as read summary: mark a notification as read
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The notification id description: The notification id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
202: 202:
description: "accepted" description: "accepted"
@ -810,23 +807,55 @@ paths:
- notification - notification
summary: mark a notification as unread summary: mark a notification as unread
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The notification id description: The notification id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
202: 202:
description: "accepted" description: "accepted"
403: 403:
description: "unauthorized" description: "unauthorized"
/1.0/main/notification/mark/allread:
post:
tags:
- notification
summary: Mark all notifications as read
responses:
202:
description: "accepted"
403:
description: "unauthorized"
/1.0/main/notification/mark/undoallread:
post: # Use POST method for creating resources
tags:
- notification
summary: Mark notifications as unread
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ids:
type: array
items:
type: integer
example: [1, 2, 3] # Example array of IDs
responses:
"202":
description: Notifications marked as unread successfully
"403":
description: Unauthorized
/1.0/main/civility.json: /1.0/main/civility.json:
get: get:
tags: tags:
- civility - civility
summary: Return all civility types summary: Return all civility types
responses: responses:
200: 200:
@ -844,7 +873,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserJob' $ref: "#/components/schemas/UserJob"
/1.0/main/workflow/my: /1.0/main/workflow/my:
get: get:
tags: tags:
@ -858,7 +887,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: '#/components/schemas/Workflow' $ref: "#/components/schemas/Workflow"
403: 403:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/workflow/my-cc: /1.0/main/workflow/my-cc:
@ -874,7 +903,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: '#/components/schemas/Workflow' $ref: "#/components/schemas/Workflow"
403: 403:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/dashboard-config-item.json: /1.0/main/dashboard-config-item.json:
@ -888,7 +917,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/DashboardConfigItem' $ref: "#/components/schemas/DashboardConfigItem"
403: 403:
description: "Unauthorized" description: "Unauthorized"
@ -905,6 +934,6 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: '#/components/schemas/NewsItem' $ref: "#/components/schemas/NewsItem"
403: 403:
description: "Unauthorized" description: "Unauthorized"

View File

@ -72,6 +72,7 @@ module.exports = function(encore, entries)
encore.addEntry('mod_blur', __dirname + '/Resources/public/module/blur/index.js'); encore.addEntry('mod_blur', __dirname + '/Resources/public/module/blur/index.js');
encore.addEntry('mod_input_address', __dirname + '/Resources/public/vuejs/Address/mod_input_address_index.js'); encore.addEntry('mod_input_address', __dirname + '/Resources/public/vuejs/Address/mod_input_address_index.js');
encore.addEntry('mod_notification_toggle_read_status', __dirname + '/Resources/public/module/notification/toggle_read.js'); encore.addEntry('mod_notification_toggle_read_status', __dirname + '/Resources/public/module/notification/toggle_read.js');
encore.addEntry('mod_notification_toggle_read_all_status', __dirname + '/Resources/public/module/notification/toggle_read_all.ts');
encore.addEntry('mod_pickentity_type', __dirname + '/Resources/public/module/pick-entity/index.js'); encore.addEntry('mod_pickentity_type', __dirname + '/Resources/public/module/pick-entity/index.js');
encore.addEntry('mod_entity_workflow_subscribe', __dirname + '/Resources/public/module/entity-workflow-subscribe/index.js'); encore.addEntry('mod_entity_workflow_subscribe', __dirname + '/Resources/public/module/entity-workflow-subscribe/index.js');
encore.addEntry('mod_entity_workflow_pick', __dirname + '/Resources/public/module/entity-workflow-pick/index.js'); encore.addEntry('mod_entity_workflow_pick', __dirname + '/Resources/public/module/entity-workflow-pick/index.js');

View File

@ -0,0 +1,90 @@
<?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\PersonFilters;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class WithoutParticipationBetweenDatesFilter implements FilterInterface
{
public function __construct(
private RollingDateConverterInterface $rollingDateConverter,
) {}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$p = 'without_participation_between_dates_filter';
$qb
->andWhere(
$qb->expr()->not(
$qb->expr()->exists(
'SELECT 1 FROM '.AccompanyingPeriodParticipation::class." {$p}_acp JOIN {$p}_acp.accompanyingPeriod {$p}_acpp ".
"WHERE {$p}_acp.person = person ".
"AND OVERLAPSI({$p}_acp.startDate, {$p}_acp.endDate), (:{$p}_date_after, :{$p}_date_before) = TRUE ".
"AND OVERLAPSI({$p}_acpp.openingDate, {$p}_acpp.closingDate), (:{$p}_date_after, :{$p}_date_before) = TRUE"
)
)
)
->setParameter("{$p}_date_after", $this->rollingDateConverter->convert($data['date_after']), Types::DATE_IMMUTABLE)
->setParameter("{$p}_date_before", $this->rollingDateConverter->convert($data['date_before']), Types::DATE_IMMUTABLE);
}
public function applyOn(): string
{
return Declarations::PERSON_TYPE;
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('date_after', PickRollingDateType::class, [
'label' => 'export.filter.person.without_participation_between_dates.date_after',
]);
$builder->add('date_before', PickRollingDateType::class, [
'label' => 'export.filter.person.without_participation_between_dates.date_before',
]);
}
public function getFormDefaultData(): array
{
return [
'date_after' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'date_before' => new RollingDate(RollingDate::T_TODAY),
];
}
public function describeAction($data, $format = 'string')
{
return ['exports.filter.person.without_participation_between_dates.Filtered by having no participations during period: between', [
'dateafter' => $this->rollingDateConverter->convert($data['date_after']),
'datebefore' => $this->rollingDateConverter->convert($data['date_before']),
]];
}
public function getTitle()
{
return 'export.filter.person.without_participation_between_dates.title';
}
}

View File

@ -85,11 +85,11 @@ final readonly class AccompanyingPeriodWorkEndDateBetweenDateFilter implements F
}; };
$end = match ($data['keep_null']) { $end = match ($data['keep_null']) {
true => $qb->expr()->orX( true => $qb->expr()->orX(
$qb->expr()->gt('acpw.endDate', ':'.$as), $qb->expr()->gt('COALESCE(acp.closingDate, acpw.endDate)', ':'.$as),
$qb->expr()->isNull('acpw.endDate') $qb->expr()->isNull('acpw.endDate')
), ),
false => $qb->expr()->andX( false => $qb->expr()->andX(
$qb->expr()->gt('acpw.endDate', ':'.$as), $qb->expr()->gt('COALESCE(acp.closingDate, acpw.endDate)', ':'.$as),
$qb->expr()->isNotNull('acpw.endDate') $qb->expr()->isNotNull('acpw.endDate')
), ),
default => throw new \LogicException('This value is not supported'), default => throw new \LogicException('This value is not supported'),

View File

@ -0,0 +1,62 @@
<?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 Export\Filter\PersonFilters;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Test\Export\AbstractFilterTest;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Export\Filter\PersonFilters\WithoutParticipationBetweenDatesFilter;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*
* @coversNothing
*/
final class WithoutParticipationBetweenDatesFilterTest extends AbstractFilterTest
{
private WithoutParticipationBetweenDatesFilter $filter;
protected function setUp(): void
{
self::bootKernel();
$this->filter = self::getContainer()->get(WithoutParticipationBetweenDatesFilter::class);
}
public function getFilter()
{
return $this->filter;
}
public static function getFormData(): array
{
return [
[
'date_after' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'date_before' => new RollingDate(RollingDate::T_TODAY),
],
];
}
public static function getQueryBuilders(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
return [
$em->createQueryBuilder()
->select('person.id')
->from(Person::class, 'person'),
];
}
}

View File

@ -124,6 +124,10 @@ services:
tags: tags:
- { name: chill.export_filter, alias: person_with_participation_between_dates_filter } - { name: chill.export_filter, alias: person_with_participation_between_dates_filter }
Chill\PersonBundle\Export\Filter\PersonFilters\WithoutParticipationBetweenDatesFilter:
tags:
- { name: chill.export_filter, alias: person_without_participation_between_dates_filter }
## Aggregators ## Aggregators
chill.person.export.aggregator_nationality: chill.person.export.aggregator_nationality:
class: Chill\PersonBundle\Export\Aggregator\PersonAggregators\NationalityAggregator class: Chill\PersonBundle\Export\Aggregator\PersonAggregators\NationalityAggregator

View File

@ -136,6 +136,9 @@ exports:
Filtered by person\'s geographical unit (based on address) computed at date, only units: Filtered by person\'s geographical unit (based on address) computed at date, only units:
"Filtré par zone géographique sur base de l'adresse, calculé à {datecalc, date, short}, seulement les zones suivantes: {units}" "Filtré par zone géographique sur base de l'adresse, calculé à {datecalc, date, short}, seulement les zones suivantes: {units}"
filter: filter:
person:
without_participation_between_dates:
"Filtered by having no participations during period: between": "Uniquement les usagers qui n'ont été concerné par aucun parcours entre le {dateafter, date, short} et le {datebefore, date, short}"
course: course:
not_having_address_reference: not_having_address_reference:
describe: >- describe: >-

View File

@ -1188,6 +1188,10 @@ export:
date_before: Concerné par un parcours avant le date_before: Concerné par un parcours avant le
title: Filtrer les usagers ayant été associés à un parcours ouverts un jour dans la période de temps indiquée title: Filtrer les usagers ayant été associés à un parcours ouverts un jour dans la période de temps indiquée
'Filtered by participations during period: between %dateafter% and %datebefore%': 'Filtré par personne concerné par un parcours dans la periode entre: %dateafter% et %datebefore%' 'Filtered by participations during period: between %dateafter% and %datebefore%': 'Filtré par personne concerné par un parcours dans la periode entre: %dateafter% et %datebefore%'
without_participation_between_dates:
date_after: Après le
date_before: Avant le
title: Filtrer les usagers n'ayant été associés à aucun parcours
course: course:
not_having_address_reference: not_having_address_reference: