Compare commits

..

6 Commits

Author SHA1 Message Date
fad7bdf235 Add event subscriber to convert docs to PDF before signature
Introduce ConvertToPdfBeforeSignatureStepEventSubscriber to convert documents to PDF when reaching a signature step in the workflow. Includes tests to ensure the conversion process only triggers when necessary.
2024-09-10 14:29:20 +02:00
8521cea46c Add StoredObjectToPdfConverter service and unit tests
Introduced the StoredObjectToPdfConverter service to handle conversion of stored objects to PDF format. Added unit tests to ensure proper functionality, including versioning and exception handling.
2024-09-10 14:29:20 +02:00
4ead7ba761 Add signer type differentiation for workflows
Added a method to determine if the signer is a 'person' or 'user'. Updated the signature template to handle both types accordingly, ensuring the correct entity type is displayed in workflow signatures.
2024-09-10 14:29:20 +02:00
9721b166eb Enhance object version removal to exclude point-in-time versions
Add a check to exclude versions associated with points in time before deleting old object versions. This ensures that such versions are not mistakenly removed, providing greater data integrity. Updated tests and repository methods accordingly.
2024-09-10 14:29:20 +02:00
1b21cd6c33 Add StoredObjectPointInTime entity and related functionality
Implemented a new StoredObjectPointInTime entity to manage snapshots of stored objects. This includes related migrations, enum for reasons, repository, and integration with StoredObjectVersion.
2024-09-10 14:29:19 +02:00
97860a9487 Add WopiConverter service and update Collabora integration tests
Introduce the WopiConverter service to handle document-to-PDF conversion using Collabora Online. Extend and update related tests in WopiConvertToPdfTest and ConvertControllerTest for better coverage and reliability. Enhance the GitLab CI configuration to exclude new test category "collabora-integration".
2024-09-10 10:44:45 +02:00
113 changed files with 998 additions and 4338 deletions

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: Show only the current referrer in the page "show" for an accompanying period
workf
time: 2024-09-16T15:18:43.017401122+02:00
custom:
Issue: "308"

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: |
Correctly compute the grouping by referrer aggregator
time: 2024-09-16T15:51:50.268336979+02:00
custom:
Issue: "309"

View File

@@ -1,4 +1,4 @@
## v2.23.0 - 2024-07-23 & 2024-07-19 ## v2.23.0 - 2024-07-23
### 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,25 +6,6 @@
* 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.

View File

@@ -1,3 +0,0 @@
## 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.

View File

@@ -1,3 +0,0 @@
## 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.

2
.env
View File

@@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$'
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
## Wopi server for editing documents online ## Wopi server for editing documents online
EDITOR_SERVER=http://collabora:9980 WOPI_SERVER=http://collabora:9980
# must be manually set in .env.local # must be manually set in .env.local
# ADMIN_PASSWORD= # ADMIN_PASSWORD=

View File

@@ -41,5 +41,3 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars
ASYNC_UPLOAD_TEMP_URL_KEY= ASYNC_UPLOAD_TEMP_URL_KEY=
ASYNC_UPLOAD_TEMP_URL_BASE_PATH= ASYNC_UPLOAD_TEMP_URL_BASE_PATH=
ASYNC_UPLOAD_TEMP_URL_CONTAINER= ASYNC_UPLOAD_TEMP_URL_CONTAINER=
EDITOR_SERVER=https://localhost:9980

View File

@@ -6,43 +6,14 @@ 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.24.0 - 2024-09-11 ## v2.23.0 - 2024-07-23
### 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,7 +43,6 @@
"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,12 +39,9 @@ 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
@@ -59,7 +56,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 = $clock->now(); $now = new DateTimeImmutable('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)
@@ -72,14 +69,9 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
return 'arbitrary-and-unique-key'; return 'arbitrary-and-unique-key';
} }
public function run(array $lastExecutionData): void public function run(): 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];
} }
} }

View File

@@ -55,7 +55,7 @@
"mime": "^4.0.0", "mime": "^4.0.0",
"pdfjs-dist": "^4.3.136", "pdfjs-dist": "^4.3.136",
"vis-network": "^9.1.0", "vis-network": "^9.1.0",
"vue": "^3.5.6", "vue": "^3.2.37",
"vue-i18n": "^9.1.6", "vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2", "vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2", "vue-toast-notification": "^3.1.2",

View File

@@ -1,99 +0,0 @@
<?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,7 +19,6 @@ 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;
@@ -45,7 +44,6 @@ 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;
@@ -191,26 +189,19 @@ 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 no fields are present // throw an error if any fields are present
if (!\array_key_exists('fields', $data)) { if (!\array_key_exists('fields', $data)) {
throw new InvalidArgumentException('No fields have been checked.'); throw new InvalidArgumentException('Any 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', 'person') ->join('activity.person', 'actperson');
->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('person.centerHistory', 'centerHistory'); $qb->join('actperson.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'),
@@ -233,22 +224,17 @@ class ListActivity implements ListInterface, GroupedExportInterface
break; break;
case 'person_firstname': case 'person_firstname':
$qb->addSelect('person.firstName AS person_firstname'); $qb->addSelect('actperson.firstName AS person_firstname');
break; break;
case 'person_lastname': case 'person_lastname':
$qb->addSelect('person.lastName AS person_lastname'); $qb->addSelect('actperson.lastName AS person_lastname');
break; break;
case 'person_id': case 'person_id':
$qb->addSelect('person.id AS person_id'); $qb->addSelect('actperson.id AS person_id');
break;
case 'household_id':
$qb->addSelect('household.id AS household_id');
break; break;
@@ -298,7 +284,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
return ActivityStatsVoter::LISTS; return ActivityStatsVoter::LISTS;
} }
public function supportsModifiers(): array public function supportsModifiers()
{ {
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 = acp" 'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod"
) )
); );

View File

@@ -39,7 +39,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
return null; return null;
} }
public function alterQuery(QueryBuilder $qb, $data): void public function alterQuery(QueryBuilder $qb, $data)
{ {
// 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'): array public function describeAction($data, $format = 'string')
{ {
return [ return [
[] === $data['reasons'] ? [] === $data['reasons'] ?
@@ -141,7 +141,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
]; ];
} }
public function getTitle(): string public function getTitle()
{ {
return 'export.filter.activity.person_between_dates.title'; return 'export.filter.activity.person_between_dates.title';
} }

View File

@@ -243,7 +243,3 @@ 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,9 +428,6 @@ 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,15 +14,10 @@
></vue-multiselect> ></vue-multiselect>
</div> </div>
</div> </div>
<div <div class="display-options row justify-content-between" style="margin-top: 1rem;">
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" <label class="input-group-text" for="slotDuration">Durée des créneaux</label>
>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>
@@ -63,20 +58,13 @@
</select> </select>
</div> </div>
</div> </div>
<div class="col-xs-12 col-sm-3"> <div class="col-sm-3 col-xs-12">
<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 <input id="showHideWE" class="mt-0" type="checkbox" v-model="showWeekends">
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="showWeekends"
/>
</span> </span>
<label for="showHideWE" class="form-check-label input-group-text" <label for="showHideWE" class="form-check-label input-group-text">Week-ends</label>
>Week-ends</label
>
</div> </div>
</div> </div>
</div> </div>
@@ -84,86 +72,39 @@
<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'">{{ <b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b>
arg.event.title <b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b>
}}</b> <b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b>
<b v-else-if="arg.event.extendedProps.is === 'range'"
>{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b
>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
arg.event.title
}}</b>
<b v-else >no 'is'</b> <b v-else >no 'is'</b>
<a <a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete"
v-if="arg.event.extendedProps.is === 'range'" @click.prevent="onClickDelete(arg.event)">
class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)"
>
</a> </a>
</span> </span>
</template> </template>
</FullCalendar> </FullCalendar>
<div id="copy-widget"> <div id="copy-widget">
<div class="container mt-2 mb-2"> <div class="container">
<div class="row align-items-center">
<div class="row justify-content-between align-items-center mb-4"> <div class="col-sm-4 col-xs-12">
<div class="col-xs-12 col-sm-3 col-md-2"> <h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6>
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
</div> </div>
<div class="col-xs-12 col-sm-9 col-md-2"> <div class="col-sm-3 col-xs-12">
<select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
<option value="day">{{ $t("from_day_to_day") }}</option>
<option value="week">{{ $t("from_week_to_week") }}</option>
</select>
</div>
<template v-if="dayOrWeek === 'day'">
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyFrom" /> <input class="form-control" type="date" v-model="copyFrom" />
</div> </div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron"> <div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;">
<i class="fa fa-angle-double-right"></i> <i class="fa fa-angle-double-right"></i>
</div> </div>
<div class="col-xs-12 col-sm-3 col-md-3"> <div class="col-sm-3 col-xs-12" >
<input class="form-control" type="date" v-model="copyTo" /> <input class="form-control" type="date" v-model="copyTo" />
</div> </div>
<div class="col-xs-12 col-sm-5 col-md-1"> <div class="col-sm-1">
<button class="btn btn-action float-end" @click="copyDay"> <button class="btn btn-action" @click="copyDay">
{{ $t("copy_range") }} {{ $t('copy_range') }}
</button> </button>
</div> </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>
<!-- not directly seen, but include in a modal --> <!-- not directly seen, but include in a modal -->
@@ -171,31 +112,21 @@
</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, onMounted } from "vue"; import {reactive, computed, ref} 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, { import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction";
DropArg,
EventResizeDoneArg,
} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import { import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core";
EventApi, import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
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";
@@ -206,60 +137,17 @@ const store = useStore(key);
const {t} = useI18n(); const {t} = useI18n();
const showWeekends = ref(false); const showWeekends = ref(false);
const slotDuration = ref("00:15:00"); const slotDuration = ref('00:05: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,
@@ -276,9 +164,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'
}, },
}); });
@@ -292,23 +180,20 @@ const locations = computed<Location[]>(() => {
const pickedLocation = computed<Location | null>({ const pickedLocation = computed<Location | null>({
get(): Location | null { get(): Location | null {
return ( return store.state.locations.locationPicked || store.state.locations.currentLocation;
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
@@ -345,60 +230,51 @@ 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", { store.dispatch('fullCalendar/setCurrentDatesView', {start: event.start, end: event.end});
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( window.alert("Indiquez une localisation avant de créer une période de disponibilité.");
"Indiquez une localisation avant de créer une période de disponibilité."
);
return; return;
} }
store.dispatch("calendarRanges/createRange", { store.dispatch('calendarRanges/createRange', {start: event.start, end: event.end, location: pickedLocation.value});
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 {
if (event.extendedProps.is !== "range") { console.log('onClickDelete', event);
if (event.extendedProps.is !== 'range') {
return; return;
} }
store.dispatch( store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId);
"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;
} }
@@ -409,26 +285,10 @@ function copyDay() {
if (null === copyFrom.value || null === copyTo.value) { if (null === copyFrom.value || null === copyTo.value) {
return; return;
} }
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
from: ISOToDate(copyFrom.value), store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.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>
@@ -439,9 +299,4 @@ onMounted(() => {
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,9 +5,11 @@ 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", copy_range_from_to: "Copier les plages d'un jour à l'autre",
from_day_to_day: "d'un jour à l'autre", copy_range_to_next_day: "Copier les plages du jour au jour suivant",
from_week_to_week: "d'une semaine à l'autre", copy_range_from_day: "Copier les plages du ",
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,23 +52,6 @@ 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;
}, },
}, },
@@ -255,7 +238,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);
@@ -263,23 +246,6 @@ 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

@@ -201,4 +201,36 @@ class DocumentPersonController extends AbstractController
['document' => $document, 'person' => $person] ['document' => $document, 'person' => $person]
); );
} }
#[Route(path: '/{id}/signature', name: 'person_document_signature', methods: 'GET')]
public function signature(Person $person, PersonDocument $document): Response
{
$this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person);
$this->denyAccessUnlessGranted('CHILL_PERSON_DOCUMENT_SEE', $document);
$event = new PrivacyEvent($person, [
'element_class' => PersonDocument::class,
'element_id' => $document->getId(),
'action' => 'show',
]);
$this->eventDispatcher->dispatch($event, PrivacyEvent::PERSON_PRIVACY_EVENT);
$storedObject = $document->getObject();
$content = $this->storedObjectManagerInterface->read($storedObject);
$zones = $this->PDFSignatureZoneParser->findSignatureZones($content);
$signature = [];
$signature['id'] = 1;
$signature['storedObject'] = [ // TEMP
'filename' => $storedObject->getFilename(),
'iv' => $storedObject->getIv(),
'keyInfos' => $storedObject->getKeyInfos(),
];
$signature['zones'] = $zones;
return $this->render(
'@ChillDocStore/PersonDocument/signature.html.twig',
['document' => $document, 'person' => $person, 'signature' => $signature]
);
}
} }

View File

@@ -15,19 +15,12 @@ use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessa
use Chill\DocStoreBundle\Service\Signature\PDFPage; use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SignatureRequestController class SignatureRequestController
{ {
@@ -35,24 +28,12 @@ class SignatureRequestController
private readonly MessageBusInterface $messageBus, private readonly MessageBusInterface $messageBus,
private readonly StoredObjectManagerInterface $storedObjectManager, private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly EntityWorkflowManager $entityWorkflowManager, private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly ChillEntityRenderManagerInterface $entityRender,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
) {} ) {}
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')] #[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse
{ {
if (!$this->security->isGranted(EntityWorkflowStepSignatureVoter::SIGN, $signature)) {
throw new AccessDeniedHttpException('not authorized to sign this step');
}
$entityWorkflow = $signature->getStep()->getEntityWorkflow(); $entityWorkflow = $signature->getStep()->getEntityWorkflow();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
return new JsonResponse([], status: Response::HTTP_CONFLICT);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
$content = $this->storedObjectManager->read($storedObject); $content = $this->storedObjectManager->read($storedObject);
@@ -70,14 +51,8 @@ class SignatureRequestController
$signature->getId(), $signature->getId(),
$zone, $zone,
$data['zone']['index'], $data['zone']['index'],
'Signed by IP: '.(string) $request->getClientIp().', authenticated user: '.$this->entityRender->renderString($this->security->getUser(), []), 'test signature', // reason (string)
$this->entityRender->renderString($signature->getSigner(), [ 'Mme Caroline Diallo', // signerText (string)
// options for user render
'absence' => false,
'main_scope' => false,
// options for person render
'addAge' => false,
]),
$content $content
)); ));
@@ -87,16 +62,6 @@ class SignatureRequestController
#[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')] #[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')]
public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse
{ {
$entityWorkflow = $signature->getStep()->getEntityWorkflow(); return new JsonResponse($signature->getState(), JsonResponse::HTTP_OK, []);
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
return new JsonResponse(
[
'state' => $signature->getState(),
'storedObject' => $this->normalizer->normalize($storedObject, 'json'),
],
JsonResponse::HTTP_OK,
[]
);
} }
} }

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class StoredObjectRestoreVersionApiController
{
public function __construct(private Security $security, private StoredObjectRestoreInterface $storedObjectRestore, private EntityManagerInterface $entityManager, private SerializerInterface $serializer) {}
#[Route('/api/1.0/doc-store/stored-object/restore-from-version/{id}', methods: ['POST'])]
public function restoreStoredObjectVersion(StoredObjectVersion $storedObjectVersion): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObjectVersion->getStoredObject())) {
throw new AccessDeniedHttpException('not allowed to edit the stored object');
}
$newVersion = $this->storedObjectRestore->restore($storedObjectVersion);
$this->entityManager->persist($newVersion);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]),
json: true
);
}
}

View File

@@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class StoredObjectVersionApiController
{
public function __construct(
private PaginatorFactoryInterface $paginatorFactory,
private SerializerInterface $serializer,
private Security $security,
) {}
/**
* Lists the versions of the specified stored object.
*
* @param StoredObject $storedObject the stored object whose versions are to be listed
*
* @return JsonResponse a JSON response containing the serialized versions of the stored object, encapsulated in a collection
*
* @throws AccessDeniedHttpException if the user is not allowed to see the stored object
*/
#[Route('/api/1.0/doc-store/stored-object/{uuid}/versions', name: 'chill_doc_store_stored_object_versions_list')]
public function listVersions(StoredObject $storedObject): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException('not allowed to see this stored object');
}
$total = $storedObject->getVersions()->count();
$paginator = $this->paginatorFactory->create($total);
$criteria = Criteria::create();
$criteria->orderBy(['id' => Order::Ascending]);
$criteria->setMaxResults($paginator->getItemsPerPage())->setFirstResult($paginator->getCurrentPageFirstItemNumber());
$items = $storedObject->getVersions()->matching($criteria);
return new JsonResponse(
$this->serializer->serialize(
new Collection($items, $paginator),
'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
),
json: true
);
}
}

View File

@@ -18,7 +18,6 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface; use Ramsey\Uuid\UuidInterface;
@@ -90,10 +89,10 @@ class StoredObject implements Document, TrackCreationInterface
private string $generationErrors = ''; private string $generationErrors = '';
/** /**
* @var Collection<int, StoredObjectVersion>&Selectable<int, StoredObjectVersion> * @var Collection<int, StoredObjectVersion>
*/ */
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
private Collection&Selectable $versions; private Collection $versions;
/** /**
* @param StoredObject::STATUS_* $status * @param StoredObject::STATUS_* $status
@@ -257,7 +256,7 @@ class StoredObject implements Document, TrackCreationInterface
return $this->template; return $this->template;
} }
public function getVersions(): Collection&Selectable public function getVersions(): Collection
{ {
return $this->versions; return $this->versions;
} }

View File

@@ -37,7 +37,7 @@ class StoredObjectPointInTime implements TrackCreationInterface
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')] #[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')]
#[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)] #[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)]
private StoredObjectVersion $objectVersion, private StoredObjectVersion $objectVersion,
#[ORM\Column(name: 'reason', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, enumType: StoredObjectPointInTimeReasonEnum::class)] #[ORM\Column(name: 'reason', type: 'text', nullable: false, enumType: StoredObjectPointInTimeReasonEnum::class)]
private StoredObjectPointInTimeReasonEnum $reason, private StoredObjectPointInTimeReasonEnum $reason,
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
private ?User $byUser = null, private ?User $byUser = null,

View File

@@ -48,25 +48,6 @@ class StoredObjectVersion implements TrackCreationInterface
#[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection&Selectable $pointInTimes; private Collection&Selectable $pointInTimes;
/**
* Previous storedObjectVersion, from which the current stored object version is created.
*
* If null, the current stored object version is generated by other means.
*
* Those version may be associated with the same storedObject, or not. In this last case, that means that
* the stored object's current version is created from another stored object version.
*/
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class)]
private ?StoredObjectVersion $createdFrom = null;
/**
* List of stored object versions created from the current version.
*
* @var Collection<int, StoredObjectVersion>
*/
#[ORM\OneToMany(mappedBy: 'createdFrom', targetEntity: StoredObjectVersion::class)]
private Collection $children;
public function __construct( public function __construct(
/** /**
* The stored object associated with this version. * The stored object associated with this version.
@@ -106,7 +87,6 @@ class StoredObjectVersion implements TrackCreationInterface
) { ) {
$this->filename = $filename ?? self::generateFilename($this); $this->filename = $filename ?? self::generateFilename($this);
$this->pointInTimes = new ArrayCollection(); $this->pointInTimes = new ArrayCollection();
$this->children = new ArrayCollection();
} }
public static function generateFilename(StoredObjectVersion $storedObjectVersion): string public static function generateFilename(StoredObjectVersion $storedObjectVersion): string
@@ -169,6 +149,8 @@ class StoredObjectVersion implements TrackCreationInterface
} }
/** /**
* @return $this
*
* @internal use @see{StoredObjectPointInTime} constructor instead * @internal use @see{StoredObjectPointInTime} constructor instead
*/ */
public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
@@ -188,42 +170,4 @@ class StoredObjectVersion implements TrackCreationInterface
return $this; return $this;
} }
public function getCreatedFrom(): ?StoredObjectVersion
{
return $this->createdFrom;
}
public function setCreatedFrom(?StoredObjectVersion $createdFrom): StoredObjectVersion
{
if (null === $createdFrom && null !== $this->createdFrom) {
$this->createdFrom->removeChild($this);
}
$createdFrom?->addChild($this);
$this->createdFrom = $createdFrom;
return $this;
}
public function addChild(StoredObjectVersion $child): self
{
if (!$this->children->contains($child)) {
$this->children->add($child);
}
return $this;
}
public function removeChild(StoredObjectVersion $child): self
{
$result = $this->children->removeElement($child);
if (false === $result) {
throw new \UnexpectedValueException('the child is not associated with the current stored object version');
}
return $this;
}
} }

View File

@@ -3,7 +3,6 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v
import {createApp} from "vue"; import {createApp} from "vue";
import {StoredObject, StoredObjectStatusChange} from "../../types"; import {StoredObject, StoredObjectStatusChange} from "../../types";
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers"; import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({}); const i18n = _createI18n({});
@@ -49,6 +48,6 @@ window.addEventListener('DOMContentLoaded', function (e) {
} }
}); });
app.use(i18n).use(ToastPlugin).mount(el); app.use(i18n).mount(el);
}) })
}); });

View File

@@ -1,130 +1,100 @@
import { import {DateTime, User} from "../../../ChillMainBundle/Resources/public/types";
DateTime,
User,
} from "../../../ChillMainBundle/Resources/public/types";
export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending"; export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending";
export interface StoredObject { export interface StoredObject {
id: number; id: number,
title: string | null; title: string|null,
uuid: string; uuid: string,
prefix: string; prefix: string,
status: StoredObjectStatus; status: StoredObjectStatus,
currentVersion: currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted,
| null totalVersions: number,
| StoredObjectVersionCreated datas: object,
| StoredObjectVersionPersisted;
totalVersions: number;
datas: object;
/** @deprecated */ /** @deprecated */
creationDate: DateTime; creationDate: DateTime,
createdAt: DateTime | null; createdAt: DateTime|null,
createdBy: User | null; createdBy: User|null,
_permissions: { _permissions: {
canEdit: boolean; canEdit: boolean,
canSee: boolean; canSee: boolean,
}; },
_links?: { _links?: {
dav_link?: { dav_link?: {
href: string; href: string
expiration: number; expiration: number
}; },
}; },
} }
export interface StoredObjectVersion { export interface StoredObjectVersion {
/** /**
* filename of the object in the object storage * filename of the object in the object storage
*/ */
filename: string; filename: string,
iv: number[]; iv: number[],
keyInfos: JsonWebKey; keyInfos: JsonWebKey,
type: string; type: string,
} }
export interface StoredObjectVersionCreated extends StoredObjectVersion { export interface StoredObjectVersionCreated extends StoredObjectVersion {
persisted: false; persisted: false,
} }
export interface StoredObjectVersionPersisted export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated {
extends StoredObjectVersionCreated { version: number,
version: number; id: number,
id: number; createdAt: DateTime|null,
createdAt: DateTime | null; createdBy: User|null,
createdBy: User | null;
} }
export interface StoredObjectStatusChange { export interface StoredObjectStatusChange {
id: number; id: number,
filename: string; filename: string,
status: StoredObjectStatus; status: StoredObjectStatus,
type: string; type: string,
}
export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted {
"point-in-times": StoredObjectPointInTime[];
"from-restored": StoredObjectVersionPersisted|null;
}
export interface StoredObjectPointInTime {
id: number;
byUser: User | null;
reason: 'keep-before-conversion'|'keep-by-user';
} }
/** /**
* Function executed by the WopiEditButton component. * Function executed by the WopiEditButton component.
*/ */
export type WopiEditButtonExecutableBeforeLeaveFunction = { export type WopiEditButtonExecutableBeforeLeaveFunction = {
(): Promise<void>; (): Promise<void>
}; }
/** /**
* Object containing information for performering a POST request to a swift object store * Object containing information for performering a POST request to a swift object store
*/ */
export interface PostStoreObjectSignature { export interface PostStoreObjectSignature {
method: "POST"; method: "POST",
max_file_size: number; max_file_size: number,
max_file_count: 1; max_file_count: 1,
expires: number; expires: number,
submit_delay: 180; submit_delay: 180,
redirect: string; redirect: string,
prefix: string; prefix: string,
url: string; url: string,
signature: string; signature: string,
} }
export interface PDFPage { export interface PDFPage {
index: number; index: number,
width: number; width: number,
height: number; height: number,
} }
export interface SignatureZone { export interface SignatureZone {
index: number | null; index: number,
x: number; x: number,
y: number; y: number,
width: number; width: number,
height: number; height: number,
PDFPage: PDFPage; PDFPage: PDFPage,
} }
export interface Signature { export interface Signature {
id: number; id: number,
storedObject: StoredObject; storedObject: StoredObject,
zones: SignatureZone[]; zones: SignatureZone[],
} }
export type SignedState = export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error';
| "pending"
| "signed"
| "rejected"
| "canceled"
| "error";
export interface CheckSignature {
state: SignedState;
storedObject: StoredObject;
}
export type CanvasEvent = "select" | "add";

View File

@@ -14,10 +14,7 @@
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button> <convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li> </li>
<li v-if="isDownloadable"> <li v-if="isDownloadable">
<download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}" :display-action-string-in-button="true"></download-button> <download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
</li>
<li v-if="isHistoryViewable">
<history-button :stored-object="props.storedObject" :can-edit="canEdit && props.storedObject._permissions.canEdit"></history-button>
</li> </li>
</ul> </ul>
</div> </div>
@@ -43,7 +40,6 @@ import {
WopiEditButtonExecutableBeforeLeaveFunction WopiEditButtonExecutableBeforeLeaveFunction
} from "../types"; } from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue"; import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue";
interface DocumentActionButtonsGroupConfig { interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject, storedObject: StoredObject,
@@ -130,11 +126,7 @@ const isConvertibleToPdf = computed<boolean>(() => {
&& is_extension_viewable(props.storedObject.currentVersion.type) && is_extension_viewable(props.storedObject.currentVersion.type)
&& props.storedObject.currentVersion.type !== 'application/pdf' && props.storedObject.currentVersion.type !== 'application/pdf'
&& props.storedObject.currentVersion.persisted !== false; && props.storedObject.currentVersion.persisted !== false;
}); })
const isHistoryViewable = computed<boolean>(() => {
return props.storedObject.status === 'ready';
});
const checkForReady = function(): void { const checkForReady = function(): void {
if ( if (

View File

@@ -26,120 +26,12 @@
</template> </template>
</modal> </modal>
</teleport> </teleport>
<div class="col-12 m-auto"> <div class="col-12">
<div class="row justify-content-center border-bottom pdf-tools d-md-none"> <div
<div v-if="pageCount > 1" class="col text-center turn-page"> class="row justify-content-center mb-2"
<button
class="btn btn-light btn-sm"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }}/{{ pageCount }}</span>
<button
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</div>
<div v-if="signature.zones.length > 1" class="col-3 p-0">
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
</div>
<div v-if="signature.zones.length > 1" class="col-3 p-0">
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div class="col text-end p-0">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1" v-if="signature.zones.length > 1"
:title="$t('choose_another_signature')"
>
{{ $t("another_zone") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
</div>
<div class="col-1" v-if="signedState !== 'signed'">
<button
class="btn btn-create btn-sm"
:class="{ active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
></button>
</div>
</div>
<div
class="row justify-content-center border-bottom pdf-tools d-none d-md-flex"
>
<div v-if="pageCount > 1" class="col-2 text-center turn-page p-0">
<button
class="btn btn-light btn-sm"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-end d-xl-none"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-start d-xl-none"
>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-end d-none d-xl-flex p-0"
> >
<div class="col-4 gap-2 d-grid">
<button <button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1" :disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm" class="btn btn-light btn-sm"
@@ -148,10 +40,7 @@
{{ $t("last_sign_zone") }} {{ $t("last_sign_zone") }}
</button> </button>
</div> </div>
<div <div class="col-4 gap-2 d-grid">
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-start d-none d-xl-flex p-0"
>
<button <button
:disabled="userSignatureZone?.index >= signature.zones.length - 1" :disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm" class="btn btn-light btn-sm"
@@ -160,46 +49,39 @@
{{ $t("next_sign_zone") }} {{ $t("next_sign_zone") }}
</button> </button>
</div> </div>
<div class="col text-end p-0" v-if="signedState !== 'signed'">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
>
{{ $t("choose_another_signature") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
</div> </div>
<div <div
class="col text-end p-0 pe-2 pe-xxl-4" id="turn-page"
v-if="signedState !== 'signed'" class="row justify-content-center mb-2"
v-if="pageCount > 1"
> >
<div class="col-6-sm col-3-md text-center">
<button <button
class="btn btn-create btn-sm" class="btn btn-light btn-sm"
:class="{ active: canvasEvent === 'add' }" :disabled="page <= 1"
@click="toggleAddZone()" @click="turnPage(-1)"
:title="$t('add_sign_zone')"
> >
{{ $t("add_zone") }}
</button>
<span>page {{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center"> <div class="col-12 text-center">
<canvas class="m-auto" id="canvas"></canvas> <canvas class="m-auto" id="canvas"></canvas>
</div> </div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto p-4" id="action-buttons"> <div class="col-12 p-4" id="action-buttons" v-if="signedState !== 'signed'">
<div class="row"> <div class="row">
<div class="col-4" v-if="signedState !== 'signed'"> <div class="col-6">
<button <button
class="btn btn-action me-2" class="btn btn-action me-2"
:disabled="!userSignatureZone" :disabled="!userSignatureZone"
@@ -208,18 +90,26 @@
{{ $t("sign") }} {{ $t("sign") }}
</button> </button>
</div> </div>
<div class="col-4" v-else></div> <div class="col-6 d-flex justify-content-end">
<div class="col-8 d-flex justify-content-end"> <button
<a class="btn btn-misc me-2"
class="btn btn-delete" :hidden="!userSignatureZone"
v-if="signedState !== 'signed'" @click="undoSign"
:href="getReturnPath()" v-if="signature.zones.length > 1"
> >
{{ $t("choose_another_signature") }}
</button>
<button
class="btn btn-misc me-2"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
<button class="btn btn-delete" @click="undoSign">
{{ $t("cancel_signing") }} {{ $t("cancel_signing") }}
</a> </button>
<a class="btn btn-misc" v-else :href="getReturnPath()">
{{ $t("return") }}
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -229,13 +119,7 @@
import { ref, Ref, reactive } from "vue"; import { ref, Ref, reactive } from "vue";
import { useToast } from "vue-toast-notification"; import { useToast } from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css"; import "vue-toast-notification/dist/theme-sugar.css";
import { import { Signature, SignatureZone, SignedState } from "../../types";
CanvasEvent,
CheckSignature,
Signature,
SignatureZone,
SignedState,
} from "../../types";
import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import * as pdfjsLib from "pdfjs-dist"; import * as pdfjsLib from "pdfjs-dist";
import { import {
@@ -251,18 +135,19 @@ console.log(PdfWorker); // incredible but this is needed
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker; // pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { download_and_decrypt_doc } from "../StoredObjectButton/helpers"; import {
download_and_decrypt_doc,
} from "../StoredObjectButton/helpers";
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs"; pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
const modalOpen: Ref<boolean> = ref(false); const modalOpen: Ref<boolean> = ref(false);
const loading: Ref<boolean> = ref(false); const loading: Ref<boolean> = ref(false);
const adding: Ref<boolean> = ref(false);
const canvasEvent: Ref<CanvasEvent> = ref("select");
const signedState: Ref<SignedState> = ref("pending"); const signedState: Ref<SignedState> = ref("pending");
const page: Ref<number> = ref(1); const page: Ref<number> = ref(1);
const pageCount: Ref<number> = ref(0); const pageCount: Ref<number> = ref(0);
let userSignatureZone: Ref<null | SignatureZone> = ref(null); let userSignatureZone: Ref<null | SignatureZone> = ref(null);
let pdfSource: Ref<string> = ref("");
let pdf = {} as PDFDocumentProxy; let pdf = {} as PDFDocumentProxy;
declare global { declare global {
@@ -275,13 +160,11 @@ const $toast = useToast();
const signature = window.signature; const signature = window.signature;
console.log(signature);
const mountPdf = async (url: string) => { const mountPdf = async (url: string) => {
const loadingTask = pdfjsLib.getDocument(url); const loadingTask = pdfjsLib.getDocument(url);
pdf = await loadingTask.promise; pdf = await loadingTask.promise;
pageCount.value = pdf.numPages; pageCount.value = pdf.numPages;
await setPage(page.value); await setPage(1);
}; };
const getRenderContext = (pdfPage: PDFPageProxy) => { const getRenderContext = (pdfPage: PDFPageProxy) => {
@@ -304,61 +187,59 @@ const setPage = async (page: number) => {
await pdfPage.render(renderContext); await pdfPage.render(renderContext);
}; };
const init = () => downloadAndOpen().then(initPdf);
async function downloadAndOpen(): Promise<Blob> { async function downloadAndOpen(): Promise<Blob> {
let raw; let raw;
try { try {
raw = await download_and_decrypt_doc( raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion);
signature.storedObject,
signature.storedObject.currentVersion
);
} catch (e) { } catch (e) {
console.error("error while downloading and decrypting document", e); console.error("error while downloading and decrypting document", e);
throw e; throw e;
} }
await mountPdf(URL.createObjectURL(raw)); await mountPdf(URL.createObjectURL(raw));
initPdf();
return raw; return raw;
} }
const initPdf = () => { const initPdf = () => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener("pointerup", canvasClick, false); canvas.addEventListener(
setTimeout(() => drawAllZones(page.value), 800); "pointerup",
(e: PointerEvent) => canvasClick(e, canvas),
false
);
setTimeout(() => addZones(page.value), 800);
}; };
const scaleXToCanvas = (x: number, canvasWidth: number, PDFWidth: number) =>
Math.round((x * canvasWidth) / PDFWidth);
const scaleYToCanvas = (h: number, canvasHeight: number, PDFHeight: number) =>
Math.round((h * canvasHeight) / PDFHeight);
const hitSignature = ( const hitSignature = (
zone: SignatureZone, zone: SignatureZone,
xy: number[], xy: number[],
canvasWidth: number, canvasWidth: number,
canvasHeight: number canvasHeight: number
) => ) => {
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] && const scaleXToCanvas = (x: number) =>
xy[0] < Math.round((x * canvasWidth) / zone.PDFPage.width);
scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) && const scaleHeightToCanvas = (h: number) =>
zone.PDFPage.height - Math.round((h * canvasHeight) / zone.PDFPage.height);
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) < const scaleYToCanvas = (y: number) =>
xy[1] && Math.round(zone.PDFPage.height - scaleHeightToCanvas(y));
xy[1] < return (
scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) + scaleXToCanvas(zone.x) < xy[0] &&
zone.PDFPage.height; xy[0] < scaleXToCanvas(zone.x + zone.width) &&
scaleYToCanvas(zone.y) < xy[1] &&
xy[1] < scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height)
);
};
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => { const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
userSignatureZone.value = z; userSignatureZone.value = z;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx) {
setPage(page.value); setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200); setTimeout(() => drawZone(z, ctx, canvas.width, canvas.height), 200);
} }
}; };
const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) => const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) =>
signature.zones signature.zones
.filter((z) => z.PDFPage.index + 1 === page.value) .filter((z) => z.PDFPage.index + 1 === page.value)
.map((z) => { .map((z) => {
@@ -375,18 +256,11 @@ const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) =>
} }
}); });
const canvasClick = (e: PointerEvent) => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvasEvent.value === "select"
? selectZoneEvent(e, canvas)
: addZoneEvent(e, canvas);
};
const turnPage = async (upOrDown: number) => { const turnPage = async (upOrDown: number) => {
//userSignatureZone.value = null; // desactivate the reset of the zone when turning page userSignatureZone.value = null;
page.value = page.value + upOrDown; page.value = page.value + upOrDown;
await setPage(page.value); await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200); setTimeout(() => addZones(page.value), 200);
}; };
const turnSignature = async (upOrDown: number) => { const turnSignature = async (upOrDown: number) => {
@@ -416,6 +290,12 @@ const drawZone = (
) => { ) => {
const unselectedBlue = "#007bff"; const unselectedBlue = "#007bff";
const selectedBlue = "#034286"; const selectedBlue = "#034286";
const scaleXToCanvas = (x: number) =>
Math.round((x * canvasWidth) / zone.PDFPage.width);
const scaleHeightToCanvas = (h: number) =>
Math.round((h * canvasHeight) / zone.PDFPage.height);
const scaleYToCanvas = (y: number) =>
Math.round(zone.PDFPage.height - scaleHeightToCanvas(y));
ctx.strokeStyle = ctx.strokeStyle =
userSignatureZone.value?.index === zone.index userSignatureZone.value?.index === zone.index
? selectedBlue ? selectedBlue
@@ -423,22 +303,16 @@ const drawZone = (
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.lineJoin = "bevel"; ctx.lineJoin = "bevel";
ctx.strokeRect( ctx.strokeRect(
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width), scaleXToCanvas(zone.x),
zone.PDFPage.height - scaleYToCanvas(zone.y),
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height), scaleXToCanvas(zone.width),
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width), scaleHeightToCanvas(zone.height)
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height)
); );
ctx.font = "bold 16px serif"; ctx.font = "bold 16px serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillStyle = "black"; ctx.fillStyle = "black";
const xText = const xText = scaleXToCanvas(zone.x) + scaleXToCanvas(zone.width) / 2;
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) + const yText = scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height) / 2;
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width) / 2;
const yText =
zone.PDFPage.height -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) +
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height) / 2;
if (userSignatureZone.value?.index === zone.index) { if (userSignatureZone.value?.index === zone.index) {
ctx.fillStyle = selectedBlue; ctx.fillStyle = selectedBlue;
ctx.fillText("Signer ici", xText, yText); ctx.fillText("Signer ici", xText, yText);
@@ -446,33 +320,27 @@ const drawZone = (
ctx.fillStyle = unselectedBlue; ctx.fillStyle = unselectedBlue;
ctx.fillText("Choisir cette", xText, yText - 12); ctx.fillText("Choisir cette", xText, yText - 12);
ctx.fillText("zone de signature", xText, yText + 12); ctx.fillText("zone de signature", xText, yText + 12);
// ctx.strokeStyle = "#c6c6c6"; // halo
// ctx.strokeText("Choisir cette", xText, yText - 12);
// ctx.strokeText("zone de signature", xText, yText + 12);
} }
}; };
const drawAllZones = (page: number) => { const addZones = (page: number) => {
const canvas = document.querySelectorAll("canvas")[0]; const canvas = document.querySelectorAll("canvas")[0];
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx && signedState.value !== "signed") { if (ctx) {
signature.zones signature.zones
.filter((z) => z.PDFPage.index + 1 === page) .filter((z) => z.PDFPage.index + 1 === page)
.map((z) => { .map((z) => drawZone(z, ctx, canvas.width, canvas.height));
if (userSignatureZone.value) {
if (userSignatureZone.value?.index === z.index) {
drawZone(z, ctx, canvas.width, canvas.height);
}
} else {
drawZone(z, ctx, canvas.width, canvas.height);
}
});
} }
}; };
const checkSignature = () => { const checkSignature = () => {
const url = `/api/1.0/document/workflow/${signature.id}/check-signature`; const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
return makeFetch<null, CheckSignature>("GET", url) return makeFetch("GET", url)
.then((r) => { .then((r) => {
signedState.value = r.state; signedState.value = r as SignedState;
signature.storedObject = r.storedObject;
checkForReady(); checkForReady();
}) })
.catch((error) => { .catch((error) => {
@@ -546,66 +414,22 @@ const confirmSign = () => {
}; };
const undoSign = async () => { const undoSign = async () => {
signature.zones = signature.zones.filter((z) => z.index !== null); // const canvas = document.querySelectorAll("canvas")[0];
// const ctx = canvas.getContext("2d");
// if (ctx && userSignatureZone.value) {
// //drawZone(userSignatureZone.value, ctx, canvas.width, canvas.height);
// }
await setPage(page.value); await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200); setTimeout(() => addZones(page.value), 200);
userSignatureZone.value = null; userSignatureZone.value = null;
adding.value = false;
canvasEvent.value = "select";
}; };
const toggleAddZone = () => { downloadAndOpen();
canvasEvent.value === "select"
? (canvasEvent.value = "add")
: (canvasEvent.value = "select");
};
const addZoneEvent = async (e: PointerEvent, canvas: HTMLCanvasElement) => {
const BOX_WIDTH = 180;
const BOX_HEIGHT = 90;
const PDFPageHeight = canvas.height;
const PDFPageWidth = canvas.width;
const x = e.offsetX;
const y = e.offsetY;
const newZone: SignatureZone = {
index: null,
x:
scaleXToCanvas(x, canvas.width, PDFPageWidth) -
scaleXToCanvas(BOX_WIDTH / 2, canvas.width, PDFPageWidth),
y:
PDFPageHeight -
scaleYToCanvas(y, canvas.height, PDFPageHeight) +
scaleYToCanvas(BOX_HEIGHT / 2, canvas.height, PDFPageHeight),
width: BOX_WIDTH,
height: BOX_HEIGHT,
PDFPage: {
index: page.value - 1,
width: PDFPageWidth,
height: PDFPageHeight,
},
};
signature.zones.push(newZone);
userSignatureZone.value = newZone;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
canvasEvent.value = "select";
adding.value = true;
};
const getReturnPath = () =>
window.location.search
? window.location.search.split("?returnPath=")[1] ??
window.location.pathname
: window.location.pathname;
init();
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
#canvas { #canvas {
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2); box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1);
} }
div#action-buttons { div#action-buttons {
position: sticky; position: sticky;
@@ -613,15 +437,7 @@ div#action-buttons {
background-color: white; background-color: white;
z-index: 100; z-index: 100;
} }
div.pdf-tools { div#turn-page {
background-color: #f3f3f3;
font-size: 0.8rem;
@media (min-width: 1400px) {
// background: none;
// border: none !important;
}
}
div.turn-page {
span { span {
font-size: 0.8rem; font-size: 0.8rem;
margin: 0 0.4rem; margin: 0 0.4rem;

View File

@@ -10,20 +10,13 @@ const appMessages = {
you_are_going_to_sign: 'Vous allez signer le document', you_are_going_to_sign: 'Vous allez signer le document',
signature_confirmation: 'Confirmation de la signature', signature_confirmation: 'Confirmation de la signature',
sign: 'Signer', sign: 'Signer',
choose_another_signature: 'Choisir une autre zone', choose_another_signature: 'Choisir une autre zone de signature',
cancel: 'Annuler', cancel: 'Annuler',
cancel_signing: 'Refuser de signer', cancel_signing: 'Refuser de signer',
last_sign_zone: 'Zone de signature précédente', last_sign_zone: 'Zone de signature précédente',
next_sign_zone: 'Zone de signature suivante', next_sign_zone: 'Zone de signature suivante',
add_sign_zone: 'Ajouter une zone de signature',
last_zone: 'Zone précédente',
next_zone: 'Zone suivante',
add_zone: 'Ajouter une zone',
another_zone: 'Autre zone',
electronic_signature_in_progress: 'Signature électronique en cours...', electronic_signature_in_progress: 'Signature électronique en cours...',
loading: 'Chargement...', loading: 'Chargement...'
remove_sign_zone: 'Enlever la zone',
return: 'Retour',
} }
} }

View File

@@ -3,7 +3,6 @@
import {StoredObject, StoredObjectVersionCreated} from "../../types"; import {StoredObject, StoredObjectVersionCreated} from "../../types";
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader"; import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
import {computed, ref, Ref} from "vue"; import {computed, ref, Ref} from "vue";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
interface DropFileConfig { interface DropFileConfig {
existingDoc?: StoredObject, existingDoc?: StoredObject,
@@ -17,7 +16,6 @@ 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;
@@ -79,7 +77,6 @@ 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
@@ -111,11 +108,18 @@ 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" class="file-icon"> <p v-if="has_existing_doc">
<file-icon :type="props.existingDoc?.type"></file-icon> <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.openxmlformats-officedocument.wordprocessingml.document'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></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>
</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>
@@ -131,18 +135,9 @@ 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: 10rem; height: 8rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -163,5 +158,4 @@ const handleFile = async (file: File): Promise<void> => {
} }
} }
} }
</style> </style>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
interface FileIconConfig {
type: string;
}
const props = defineProps<FileIconConfig>();
</script>
<template>
<i class="fa fa-file-pdf-o" v-if="props.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/msword'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.ms-excel'"></i>
<i class="fa fa-file-image-o" v-else-if="props.type === 'image/jpeg'"></i>
<i class="fa fa-file-image-o" v-else-if="props.type === 'image/png'"></i>
<i class="fa fa-file-archive-o" v-else-if="props.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<a :class="props.classes" @click="download_and_open($event)" ref="btn"> <a :class="props.classes" @click="download_and_open($event)">
<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, ref} from "vue"; import {reactive} from "vue";
import {StoredObject} from "../../types"; import {StoredObject} from "../../types";
interface ConvertButtonConfig { interface ConvertButtonConfig {
@@ -24,7 +24,6 @@ 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;
@@ -42,14 +41,6 @@ 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

@@ -1,11 +1,11 @@
<template> <template>
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)" title="T&#233;l&#233;charger"> <a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)">
<i class="fa fa-download"></i> <i class="fa fa-download"></i>
<template v-if="displayActionStringInButton">Télécharger</template> Télécharger
</a> </a>
<a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button" title="Ouvrir"> <a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button">
<i class="fa fa-external-link"></i> <i class="fa fa-external-link"></i>
<template v-if="displayActionStringInButton">Ouvrir</template> Ouvrir
</a> </a>
</template> </template>
@@ -20,7 +20,6 @@ interface DownloadButtonConfig {
atVersion: StoredObjectVersion, atVersion: StoredObjectVersion,
classes: { [k: string]: boolean }, classes: { [k: string]: boolean },
filename?: string, filename?: string,
displayActionStringInButton: boolean,
} }
interface DownloadButtonState { interface DownloadButtonState {
@@ -29,7 +28,7 @@ interface DownloadButtonState {
href_url: string, href_url: string,
} }
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true}); const props = defineProps<DownloadButtonConfig>();
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"}); const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
const open_button = ref<HTMLAnchorElement | null>(null); const open_button = ref<HTMLAnchorElement | null>(null);
@@ -77,15 +76,6 @@ 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

@@ -1,57 +0,0 @@
<script setup lang="ts">
import HistoryButtonModal from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue";
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../types";
import {computed, reactive, ref, useTemplateRef} from "vue";
import {get_versions} from "./HistoryButton/api";
interface HistoryButtonConfig {
storedObject: StoredObject;
canEdit: boolean;
}
interface HistoryButtonState {
versions: StoredObjectVersionWithPointInTime[];
loaded: boolean;
}
const props = defineProps<HistoryButtonConfig>();
const state = reactive<HistoryButtonState>({versions: [], loaded: false});
const modal = useTemplateRef<typeof HistoryButtonModal>('modal');
const download_version_and_open_modal = async function (): Promise<void> {
if (null !== modal.value) {
modal.value.open();
} else {
console.log("modal is null");
}
if (!state.loaded) {
const versions = await get_versions(props.storedObject);
for (const version of versions) {
state.versions.push(version);
}
state.loaded = true;
}
}
const onRestoreVersion = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
state.versions.unshift(newVersion);
}
</script>
<template>
<a @click="download_version_and_open_modal" class="dropdown-item">
<history-button-modal ref="modal" :versions="state.versions" :stored-object="storedObject" :can-edit="canEdit" @restore-version="onRestoreVersion"></history-button-modal>
<i class="fa fa-history"></i>
Historique
</a>
</template>
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
</style>

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
import HistoryButtonListItem from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue";
import {computed, reactive} from "vue";
interface HistoryButtonListConfig {
versions: StoredObjectVersionWithPointInTime[];
storedObject: StoredObject;
canEdit: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
interface HistoryButtonListState {
/**
* Contains the number of the newly created version when a version is restored.
*/
restored: number;
}
const props = defineProps<HistoryButtonListConfig>();
const state = reactive<HistoryButtonListState>({restored: -1})
const higher_version = computed<number>(() => props.versions.reduce(
(accumulator: number, version: StoredObjectVersionWithPointInTime) => Math.max(accumulator, version.version),
-1
)
);
/**
* Executed when a version in child component is restored.
*
* internally, keep track of the newly restored version
*/
const onRestored = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
state.restored = newVersion.version;
emit('restoreVersion', {newVersion});
}
</script>
<template>
<template v-if="props.versions.length > 0">
<div class="container">
<template v-for="v in props.versions">
<history-button-list-item
:version="v"
:can-edit="canEdit"
:is-current="higher_version === v.version"
:is-restored="v.version === state.restored"
:stored-object="storedObject"
@restore-version="onRestored"
></history-button-list-item>
</template>
</div>
</template>
<template v-else>
<p>Chargement des versions</p>
</template>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,113 +0,0 @@
<script setup lang="ts">
import {StoredObject, StoredObjectPointInTime, StoredObjectVersionWithPointInTime} from "./../../../types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import {ISOToDatetime} from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
import {computed} from "vue";
interface HistoryButtonListItemConfig {
version: StoredObjectVersionWithPointInTime;
storedObject: StoredObject;
canEdit: boolean;
isCurrent: boolean;
isRestored: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
const props = defineProps<HistoryButtonListItemConfig>();
const onRestore = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
emit('restoreVersion', {newVersion});
}
const isKeptBeforeConversion = computed<boolean>(() => props.version["point-in-times"].reduce(
(accumulator: boolean, pit: StoredObjectPointInTime) => accumulator || "keep-before-conversion" === pit.reason,
false
),
);
const isRestored = computed<boolean>(() => null !== props.version["from-restored"]);
const classes = computed<{row: true, 'row-hover': true, 'blinking-1': boolean, 'blinking-2': boolean}>(() => ({row: true, 'row-hover': true, 'blinking-1': props.isRestored && 0 === props.version.version % 2, 'blinking-2': props.isRestored && 1 === props.version.version % 2}));
</script>
<template>
<div :class="classes">
<div class="col-12 tags" v-if="isCurrent || isKeptBeforeConversion || isRestored">
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion">Conservée avant conversion dans un autre format</span>
<span class="badge bg-info" v-if="isRestored">Restaurée depuis la version {{ version["from-restored"]?.version }}</span>
</div>
<div class="col-12">
<file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <strong v-if="version.version == 0">Créé par</strong><strong v-else>modifié par</strong> <span class="badge-user"><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge></span> <strong>à</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}
</div>
<div class="col-12">
<ul class="record_actions small slim on-version-actions">
<li v-if="canEdit && !isCurrent">
<restore-version-button :stored-object-version="props.version" @restore-version="onRestore"></restore-version-button>
</li>
<li>
<download-button :stored-object="storedObject" :at-version="version" :classes="{btn: true, 'btn-outline-primary': true}" :display-action-string-in-button="false"></download-button>
</li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
div.tags {
span.badge:not(:last-child) {
margin-right: 0.5rem;
}
}
// to make the animation restart, we have the same animation twice,
// and alternate between both
.blinking-1 {
animation-name: backgroundColorPalette-1;
animation-duration: 8s;
animation-iteration-count: 1;
animation-direction: normal;
animation-timing-function: linear;
}
@keyframes backgroundColorPalette-1 {
0% {
background: var(--bs-chill-green-dark);
}
25% {
background: var(--bs-chill-green);
}
65% {
background: var(--bs-chill-beige);
}
100% {
background: unset;
}
}
.blinking-2 {
animation-name: backgroundColorPalette-2;
animation-duration: 8s;
animation-iteration-count: 1;
animation-direction: normal;
animation-timing-function: linear;
}
@keyframes backgroundColorPalette-2 {
0% {
background: var(--bs-chill-green-dark);
}
25% {
background: var(--bs-chill-green);
}
65% {
background: var(--bs-chill-beige);
}
100% {
background: unset;
}
}
</style>

View File

@@ -1,48 +0,0 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {reactive} from "vue";
import HistoryButtonList from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue";
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
interface HistoryButtonListConfig {
versions: StoredObjectVersionWithPointInTime[];
storedObject: StoredObject;
canEdit: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
interface HistoryButtonModalState {
opened: boolean;
}
const props = defineProps<HistoryButtonListConfig>();
const state = reactive<HistoryButtonModalState>({opened: false});
const open = () => {
console.log('open');
state.opened = true;
}
defineExpose({open});
</script>
<template>
<Teleport to="body">
<modal v-if="state.opened" @close="state.opened = false">
<template v-slot:header>
<h3>Historique des versions du document</h3>
</template>
<template v-slot:body>
<p>Les versions sont conservées pendant 90 jours.</p>
<history-button-list :versions="props.versions" :can-edit="canEdit" :stored-object="storedObject" @restore-version="(payload) => emit('restoreVersion', payload)"></history-button-list>
</template>
</modal>
</Teleport>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import {StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
import {useToast} from "vue-toast-notification";
import {restore_version} from "./api";
interface RestoreVersionButtonProps {
storedObjectVersion: StoredObjectVersionPersisted,
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
const props = defineProps<RestoreVersionButtonProps>()
const $toast = useToast();
const restore_version_fn = async () => {
const newVersion = await restore_version(props.storedObjectVersion);
$toast.success("Version restaurée");
emit('restoreVersion', {newVersion});
}
</script>
<template>
<button class="btn btn-outline-action" @click="restore_version_fn" title="Restaurer"><i class="fa fa-rotate-left"></i> Restaurer</button>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,12 +0,0 @@
import {StoredObject, StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
import {fetchResults, makeFetch} from "../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
export const get_versions = async (storedObject: StoredObject): Promise<StoredObjectVersionWithPointInTime[]> => {
const versions = await fetchResults<StoredObjectVersionWithPointInTime>(`/api/1.0/doc-store/stored-object/${storedObject.uuid}/versions`);
return versions.sort((a: StoredObjectVersionWithPointInTime, b: StoredObjectVersionWithPointInTime) => b.version - a.version);
}
export const restore_version = async (version: StoredObjectVersionPersisted): Promise<StoredObjectVersionWithPointInTime> => {
return await makeFetch<null, StoredObjectVersionWithPointInTime>("POST", `/api/1.0/doc-store/stored-object/restore-from-version/${version.id}`);
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class StoredObjectPointInTimeNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, ?string $format = null, array $context = [])
{
/* @var StoredObjectPointInTime $object */
return [
'id' => $object->getId(),
'reason' => $object->getReason()->value,
'byUser' => $this->normalizer->normalize($object->getByUser(), $format, [AbstractNormalizer::GROUPS => 'read']),
];
}
public function supportsNormalization($data, ?string $format = null)
{
return $data instanceof StoredObjectPointInTime;
}
}

View File

@@ -12,8 +12,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer; namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -22,17 +20,13 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw
{ {
use NormalizerAwareTrait; use NormalizerAwareTrait;
final public const WITH_POINT_IN_TIMES_CONTEXT = 'with-point-in-times';
final public const WITH_RESTORED_CONTEXT = 'with-restored';
public function normalize($object, ?string $format = null, array $context = []) public function normalize($object, ?string $format = null, array $context = [])
{ {
if (!$object instanceof StoredObjectVersion) { if (!$object instanceof StoredObjectVersion) {
throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class); throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class);
} }
$data = [ return [
'id' => $object->getId(), 'id' => $object->getId(),
'filename' => $object->getFilename(), 'filename' => $object->getFilename(),
'version' => $object->getVersion(), 'version' => $object->getVersion(),
@@ -40,18 +34,8 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw
'keyInfos' => $object->getKeyInfos(), 'keyInfos' => $object->getKeyInfos(),
'type' => $object->getType(), 'type' => $object->getType(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context), 'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, [...$context, UserNormalizer::AT_DATE => $object->getCreatedAt()]), 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
]; ];
if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
$data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context);
}
if (in_array(self::WITH_RESTORED_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
$data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]);
}
return $data;
} }
public function supportsNormalization($data, ?string $format = null, array $context = []) public function supportsNormalization($data, ?string $format = null, array $context = [])

View File

@@ -18,7 +18,7 @@ final readonly class PdfSignedMessage
{ {
public function __construct( public function __construct(
public readonly int $signatureId, public readonly int $signatureId,
public readonly ?int $signatureZoneIndex, public readonly int $signatureZoneIndex,
public readonly string $content, public readonly string $content,
) {} ) {}
} }

View File

@@ -12,11 +12,12 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner; namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface; use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final readonly class PdfSignedMessageHandler implements MessageHandlerInterface final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
@@ -32,7 +33,7 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
private StoredObjectManagerInterface $storedObjectManager, private StoredObjectManagerInterface $storedObjectManager,
private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository, private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private SignatureStepStateChanger $signatureStepStateChanger, private ClockInterface $clock,
) {} ) {}
public function __invoke(PdfSignedMessage $message): void public function __invoke(PdfSignedMessage $message): void
@@ -53,8 +54,8 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
$this->storedObjectManager->write($storedObject, $message->content); $this->storedObjectManager->write($storedObject, $message->content);
$this->signatureStepStateChanger->markSignatureAsSigned($signature, $message->signatureZoneIndex); $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now());
$signature->setZoneSignatureIndex($message->signatureZoneIndex);
$this->entityManager->flush(); $this->entityManager->flush();
$this->entityManager->clear(); $this->entityManager->clear();
} }

View File

@@ -21,7 +21,7 @@ final readonly class RequestPdfSignMessage
public function __construct( public function __construct(
public int $signatureId, public int $signatureId,
public PDFSignatureZone $PDFSignatureZone, public PDFSignatureZone $PDFSignatureZone,
public ?int $signatureZoneIndex, public int $signatureZoneIndex,
public string $reason, public string $reason,
public string $signerText, public string $signerText,
public string $content, public string $content,

View File

@@ -17,7 +17,7 @@ final readonly class PDFSignatureZone
{ {
public function __construct( public function __construct(
#[Groups(['read'])] #[Groups(['read'])]
public ?int $index, public int $index,
#[Groups(['read'])] #[Groups(['read'])]
public float $x, public float $x,
#[Groups(['read'])] #[Groups(['read'])]

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
final readonly class StoredObjectRestore implements StoredObjectRestoreInterface
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion
{
$oldContent = $this->storedObjectManager->read($storedObjectVersion);
$newVersion = $this->storedObjectManager->write($storedObjectVersion->getStoredObject(), $oldContent, $storedObjectVersion->getType());
$newVersion->setCreatedFrom($storedObjectVersion);
$this->logger->info('[StoredObjectRestore] Restore stored object version', [
'stored_object_uuid' => $storedObjectVersion->getStoredObject()->getUuid(),
'old_version_id' => $storedObjectVersion->getId(),
'old_version_version' => $storedObjectVersion->getVersion(),
'new_version_id' => $newVersion->getVersion(),
]);
return $newVersion;
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
/**
* Restore an old version of the stored object as the current one.
*/
interface StoredObjectRestoreInterface
{
public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion;
}

View File

@@ -1,74 +0,0 @@
<?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 App\Tests\Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
use Chill\DocStoreBundle\Controller\StoredObjectRestoreVersionApiController;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectRestoreVersionApiControllerTest extends TestCase
{
public function testRestoreStoredObjectVersion(): void
{
$security = $this->createMock(Security::class);
$storedObjectRestore = $this->createMock(StoredObjectRestoreInterface::class);
$entityManager = $this->createMock(EntityManagerInterface::class);
$serializer = $this->createMock(SerializerInterface::class);
$storedObjectVersion = $this->createMock(StoredObjectVersion::class);
$controller = new StoredObjectRestoreVersionApiController($security, $storedObjectRestore, $entityManager, $serializer);
$security->expects($this->once())
->method('isGranted')
->willReturn(true);
$storedObjectRestore->expects($this->once())
->method('restore')
->willReturn($storedObjectVersion);
$entityManager->expects($this->once())
->method('persist');
$entityManager->expects($this->once())
->method('flush');
$serializer->expects($this->once())
->method('serialize')
->willReturn('test');
$response = $controller->restoreStoredObjectVersion($storedObjectVersion);
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('test', $response->getContent());
}
public function testRestoreStoredObjectVersionAccessDenied(): void
{
$security = $this->createMock(Security::class);
$storedObjectRestore = $this->createMock(StoredObjectRestoreInterface::class);
$entityManager = $this->createMock(EntityManagerInterface::class);
$serializer = $this->createMock(SerializerInterface::class);
$storedObjectVersion = $this->createMock(StoredObjectVersion::class);
$controller = new StoredObjectRestoreVersionApiController($security, $storedObjectRestore, $entityManager, $serializer);
self::expectException(\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException::class);
$security->expects($this->once())
->method('isGranted')
->willReturn(false);
$controller->restoreStoredObjectVersion($storedObjectVersion);
}
}

View File

@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\StoredObjectVersionApiController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\MainBundle\Pagination\Paginator;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
{
use ProphecyTrait;
public function testListVersion(): void
{
$storedObject = new StoredObject();
for ($i = 0; $i < 15; ++$i) {
$storedObject->registerVersion();
}
$security = $this->prophesize(Security::class);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
->willReturn(true)
->shouldBeCalledOnce();
$controller = $this->buildController($security->reveal());
$response = $controller->listVersions($storedObject);
$body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($response->getStatusCode(), 200);
self::assertIsArray($body);
self::assertArrayHasKey('results', $body);
self::assertCount(10, $body['results']);
}
private function buildController(Security $security): StoredObjectVersionApiController
{
$paginator = $this->prophesize(Paginator::class);
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
$paginator->getItemsPerPage()->willReturn(10);
$paginator->getTotalItems()->willReturn(15);
$paginator->hasNextPage()->willReturn(false);
$paginator->hasPreviousPage()->willReturn(false);
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
$paginatorFactory->create(Argument::type('int'))->willReturn($paginator);
$serializer = new Serializer([
new StoredObjectVersionNormalizer(), new CollectionNormalizer(),
], [new JsonEncoder()]);
return new StoredObjectVersionApiController($paginatorFactory->reveal(), $serializer, $security);
}
}

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectPointInTimeNormalizer;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
use Chill\MainBundle\Templating\Entity\UserRender;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectPointInTimeNormalizerTest extends TestCase
{
use ProphecyTrait;
public function testNormalize(): void
{
$storedObject = new StoredObject();
$version = $storedObject->registerVersion();
$storedObjectPointInTime = new StoredObjectPointInTime($version, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION, new User());
$normalizer = new StoredObjectPointInTimeNormalizer();
$normalizer->setNormalizer($this->buildNormalizer());
$actual = $normalizer->normalize($storedObjectPointInTime, 'json', ['read']);
self::assertIsArray($actual);
self::assertArrayHasKey('id', $actual);
self::assertArrayHasKey('byUser', $actual);
self::assertArrayHasKey('reason', $actual);
self::assertEquals(StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION->value, $actual['reason']);
}
public function buildNormalizer(): NormalizerInterface
{
$userRender = $this->prophesize(UserRender::class);
$userRender->renderString(Argument::type(User::class), Argument::type('array'))->willReturn('username');
return new Serializer(
[new UserNormalizer($userRender->reveal(), new MockClock())]
);
}
}

View File

@@ -20,12 +20,12 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
/** /**
* @internal * @internal
@@ -45,9 +45,6 @@ class PdfSignedMessageHandlerTest extends TestCase
$entityWorkflow->setStep('new_step', $dto, 'new_transition', new \DateTimeImmutable(), new User()); $entityWorkflow->setStep('new_step', $dto, 'new_transition', new \DateTimeImmutable(), new User());
$step = $entityWorkflow->getCurrentStep(); $step = $entityWorkflow->getCurrentStep();
$signature = $step->getSignatures()->first(); $signature = $step->getSignatures()->first();
$stateChanger = $this->createMock(SignatureStepStateChanger::class);
$stateChanger->expects(self::once())->method('markSignatureAsSigned')
->with($signature, 99);
$handler = new PdfSignedMessageHandler( $handler = new PdfSignedMessageHandler(
new NullLogger(), new NullLogger(),
@@ -55,12 +52,15 @@ class PdfSignedMessageHandlerTest extends TestCase
$this->buildStoredObjectManager($storedObject, $expectedContent = '1234'), $this->buildStoredObjectManager($storedObject, $expectedContent = '1234'),
$this->buildSignatureRepository($signature), $this->buildSignatureRepository($signature),
$this->buildEntityManager(true), $this->buildEntityManager(true),
$stateChanger, new MockClock('now'),
); );
// we simply call the handler. The mocked StoredObjectManager will check that the "write" method is invoked once // we simply call the handler. The mocked StoredObjectManager will check that the "write" method is invoked once
// with the content "1234" // with the content "1234"
$handler(new PdfSignedMessage(10, 99, $expectedContent)); $handler(new PdfSignedMessage(10, 99, $expectedContent));
self::assertEquals('signed', $signature->getState()->value);
self::assertEquals(99, $signature->getZoneSignatureIndex());
} }
private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\StoredObjectRestore;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectRestoreTest extends TestCase
{
use ProphecyTrait;
public function testRestore(): void
{
$storedObject = new StoredObject();
$version = $storedObject->registerVersion(type: 'application/test');
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($version)->willReturn('1234')->shouldBeCalledOnce();
$storedObjectManager->write($storedObject, '1234', 'application/test')->shouldBeCalledOnce()
->will(function ($args) {
/** @var StoredObject $object */
$object = $args[0];
return $object->registerVersion();
})
;
$restore = new StoredObjectRestore($storedObjectManager->reveal(), new NullLogger());
$newVersion = $restore->restore($version);
self::assertNotSame($version, $newVersion);
self::assertSame($version, $newVersion->getCreatedFrom());
}
}

View File

@@ -105,50 +105,3 @@ paths:
404: 404:
description: "Not found" description: "Not found"
/1.0/doc-store/stored-object/{uuid}/versions:
get:
tags:
- storedobject
summary: Get a signed route to post stored object
parameters:
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObjeect
schema:
type: string
format: uuid
responses:
200:
description: "OK"
content:
application/json:
schema:
type: object
403:
description: "Unauthorized"
404:
description: "Not found"
/1.0/doc-store/stored-object/restore-from-version/{id}:
post:
tags:
- storedobject
summary: Restore an old version of a stored object
parameters:
- in: path
name: id
required: true
allowEmptyValue: false
description: The id of the stored object version
schema:
type: integer
responses:
200:
description: "OK"
content:
application/json:
schema:
type: object

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240918073234 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a relation between stored object version when a version is restored';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object_version ADD createdFrom_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_doc.stored_object_version ADD CONSTRAINT FK_C1D553024DEC38BB FOREIGN KEY (createdFrom_id) REFERENCES chill_doc.stored_object_version (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_C1D553024DEC38BB ON chill_doc.stored_object_version (createdFrom_id)');
$this->addSql('ALTER INDEX chill_doc.idx_c1d55302232d562b RENAME TO IDX_C1D553024B136083');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object_version DROP createdFrom_id');
$this->addSql('ALTER INDEX chill_doc.idx_c1d553024b136083 RENAME TO idx_c1d55302232d562b');
}
}

View File

@@ -94,38 +94,4 @@ 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('ROLE_USER'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$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,
$paginator->getItemsPerPage(), $limit = $paginator->getItemsPerPage(),
$paginator->getCurrentPage()->getFirstItemNumber() $offset = $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\FormInterface * @return \Symfony\Component\Form\Form
*/ */
private function passwordForm(User $user) private function passwordForm(User $user)
{ {

View File

@@ -264,7 +264,6 @@ 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();
} }
@@ -274,7 +273,11 @@ class UserController extends CRUDController
return parent::countEntities($action, $request, $filterOrder); return parent::countEntities($action, $request, $filterOrder);
} }
return $this->userRepository->countFilteredUsers($filterOrder->getQueryString(), $filterOrder->getCheckboxData('activeFilter')); if (null === $filterOrder->getQueryString()) {
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
@@ -331,13 +334,16 @@ class UserController extends CRUDController
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder); return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
} }
$queryString = $filterOrder->getQueryString(); if (null === $filterOrder->getQueryString()) {
$activeFilter = $filterOrder->getCheckboxData('activeFilter'); return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
$nb = $this->userRepository->countFilteredUsers($queryString, $activeFilter); }
$paginator = $this->getPaginatorFactory()->create($nb); return $this->userRepository->findByUsernameOrEmail(
$filterOrder->getQueryString(),
return $this->userRepository->findFilteredUsers($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage()); ['usernameCanonical' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
} }
protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request) protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request)
@@ -368,12 +374,10 @@ 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( ->setAction($this->generateUrl(
$this->generateUrl(
'admin_user_add_groupcenter', 'admin_user_add_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId()]) 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'])
@@ -388,12 +392,10 @@ 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( ->setAction($this->generateUrl(
$this->generateUrl(
'admin_user_delete_groupcenter', 'admin_user_delete_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()]) 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

@@ -12,18 +12,12 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneAvailable; use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneAvailable;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment; use Twig\Environment;
@@ -34,29 +28,13 @@ final readonly class WorkflowAddSignatureController
private PDFSignatureZoneAvailable $PDFSignatureZoneAvailable, private PDFSignatureZoneAvailable $PDFSignatureZoneAvailable,
private NormalizerInterface $normalizer, private NormalizerInterface $normalizer,
private Environment $twig, private Environment $twig,
private UrlGeneratorInterface $urlGenerator,
private Security $security,
) {} ) {}
#[Route(path: '/{_locale}/main/workflow/signature/{id}/sign', name: 'chill_main_workflow_signature_add', methods: 'GET')] #[Route(path: '/{_locale}/main/workflow/signature/{id}/sign', name: 'chill_main_workflow_signature_add', methods: 'GET')]
public function __invoke(EntityWorkflowStepSignature $signature, Request $request): Response public function __invoke(EntityWorkflowStepSignature $signature, Request $request): Response
{ {
if (!$this->security->isGranted(EntityWorkflowStepSignatureVoter::SIGN, $signature)) {
throw new AccessDeniedHttpException('not authorized to sign this step');
}
$entityWorkflow = $signature->getStep()->getEntityWorkflow(); $entityWorkflow = $signature->getStep()->getEntityWorkflow();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
if ($request->query->has('returnPath')) {
return new RedirectResponse($request->query->get('returnPath'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) { if (null === $storedObject) {
throw new NotFoundHttpException('No stored object found'); throw new NotFoundHttpException('No stored object found');

View File

@@ -396,10 +396,7 @@ class WorkflowController extends AbstractController
} }
if ($signature->getSigner() instanceof User) { if ($signature->getSigner() instanceof User) {
return $this->redirectToRoute('chill_main_workflow_signature_add', [ return $this->redirectToRoute('chill_main_workflow_signature_add', ['id' => $signature_id]);
'id' => $signature_id,
'returnPath' => $request->query->get('returnPath', null),
]);
} }
$metadataForm = $this->createForm(WorkflowSignatureMetadataType::class); $metadataForm = $this->createForm(WorkflowSignatureMetadataType::class);
@@ -423,10 +420,7 @@ class WorkflowController extends AbstractController
$this->entityManager->persist($signature); $this->entityManager->persist($signature);
$this->entityManager->flush(); $this->entityManager->flush();
return $this->redirectToRoute('chill_main_workflow_signature_add', [ return $this->redirectToRoute('chill_main_workflow_signature_add', ['id' => $signature_id]);
'id' => $signature_id,
'returnPath' => $request->query->get('returnPath', null),
]);
} }
return $this->render( return $this->render(

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<string|int, int|float|string|bool|array<int|string, int|float|string|bool>> $lastExecutionData the data which was returned from the previous execution * @param array $lastExecutionData the data which was returned from 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 * @return array|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

@@ -318,7 +318,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
} }
} }
return array_values($usersInvolved); return $usersInvolved;
} }
public function getWorkflowName(): string public function getWorkflowName(): string
@@ -446,10 +446,6 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
$newStep->addDestUser($user); $newStep->addDestUser($user);
} }
if (null !== $transitionContextDTO->futureUserSignature) {
$newStep->addDestUser($transitionContextDTO->futureUserSignature);
}
foreach ($transitionContextDTO->futureDestEmails as $email) { foreach ($transitionContextDTO->futureDestEmails as $email) {
$newStep->addDestEmail($email); $newStep->addDestEmail($email);
} }

View File

@@ -105,11 +105,6 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return $this->state; return $this->state;
} }
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setState(EntityWorkflowSignatureStateEnum $state): EntityWorkflowStepSignature public function setState(EntityWorkflowSignatureStateEnum $state): EntityWorkflowStepSignature
{ {
$this->state = $state; $this->state = $state;
@@ -122,11 +117,6 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return $this->stateDate; return $this->stateDate;
} }
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setStateDate(?\DateTimeImmutable $stateDate): EntityWorkflowStepSignature public function setStateDate(?\DateTimeImmutable $stateDate): EntityWorkflowStepSignature
{ {
$this->stateDate = $stateDate; $this->stateDate = $stateDate;
@@ -139,11 +129,6 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return $this->zoneSignatureIndex; return $this->zoneSignatureIndex;
} }
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setZoneSignatureIndex(?int $zoneSignatureIndex): EntityWorkflowStepSignature public function setZoneSignatureIndex(?int $zoneSignatureIndex): EntityWorkflowStepSignature
{ {
$this->zoneSignatureIndex = $zoneSignatureIndex; $this->zoneSignatureIndex = $zoneSignatureIndex;
@@ -156,32 +141,6 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return EntityWorkflowSignatureStateEnum::SIGNED == $this->getState(); return EntityWorkflowSignatureStateEnum::SIGNED == $this->getState();
} }
public function isPending(): bool
{
return EntityWorkflowSignatureStateEnum::PENDING == $this->getState();
}
/**
* Checks whether all signatures associated with a given workflow step are not pending.
*
* Iterates over each signature in the provided workflow step, and returns false if any signature
* is found to be pending. If all signatures are not pending, returns true.
*
* @param EntityWorkflowStep $step the workflow step whose signatures are to be checked
*
* @return bool true if all signatures are not pending, false otherwise
*/
public static function isAllSignatureNotPendingForStep(EntityWorkflowStep $step): bool
{
foreach ($step->getSignatures() as $signature) {
if ($signature->isPending()) {
return false;
}
}
return true;
}
/** /**
* @return 'person'|'user' * @return 'person'|'user'
*/ */

View File

@@ -13,8 +13,6 @@ 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;
@@ -83,7 +81,10 @@ final class NotificationRepository implements ObjectRepository
$results->free(); $results->free();
} else { } else {
$wheres = []; $wheres = [];
foreach ([['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId], ...$more] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) { foreach ([
['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;
@@ -254,12 +255,10 @@ 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( $or = $qb->expr()->orX($qb->expr()->andX(
$qb->expr()->andX(
$qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'), $qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'),
$qb->expr()->eq('n.relatedEntityId', ':relatedEntityId') $qb->expr()->eq('n.relatedEntityId', ':relatedEntityId')
) ));
);
$qb $qb
->setParameter('relatedEntityClass', $relatedEntityClass) ->setParameter('relatedEntityClass', $relatedEntityClass)
->setParameter('relatedEntityId', $relatedEntityId); ->setParameter('relatedEntityId', $relatedEntityId);
@@ -311,86 +310,4 @@ 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,7 +17,6 @@ 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;
@@ -27,25 +26,9 @@ final readonly class UserRepository implements UserRepositoryInterface
{ {
private EntityRepository $repository; private EntityRepository $repository;
private const FIELDS = [ private const FIELDS = ['id', 'email', 'enabled', 'civility_id', 'civility_abbreviation', 'civility_name', 'label', 'mainCenter_id',
'id', 'mainCenter_name', 'mainScope_id', 'mainScope_name', 'userJob_id', 'userJob_name', 'currentLocation_id', 'currentLocation_name',
'email', 'mainLocation_id', 'mainLocation_name'];
'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)
{ {
@@ -313,25 +296,6 @@ 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');
@@ -348,49 +312,4 @@ 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,13 +1,11 @@
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 document.querySelectorAll('.notification_toggle_read_status')
.querySelectorAll(".notification_toggle_read_status")
.forEach(function (el, i) { .forEach(function (el, i) {
createApp({ createApp({
template: `<notification-read-toggle template: `<notification-read-toggle
@@ -24,40 +22,35 @@ window.addEventListener("DOMContentLoaded", function (e) {
}, },
data() { data() {
return { return {
notificationId: parseInt(el.dataset.notificationId), notificationId: 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 { } else { throw 'data-container attribute is missing' }
throw "data-container attribute is missing";
}
this.isRead = false; this.isRead = false;
}, },
onMarkUnread() { onMarkUnread() {
if (typeof this.getContainer[i] !== "undefined") { if (typeof this.getContainer[i] !== 'undefined') {
this.getContainer[i].classList.replace("unread", "read"); this.getContainer[i].classList.replace('unread', 'read');
} else { } else { throw 'data-container attribute is missing' }
throw "data-container attribute is missing";
}
this.isRead = true; this.isRead = true;
}, },
}, }
}) })
.use(i18n) .use(i18n)
.mount(el); .mount(el);
}); });
}); });

View File

@@ -1,39 +0,0 @@
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

@@ -1,50 +0,0 @@
<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,11 +1,8 @@
<template> <template>
<div <div :class="{'btn-group btn-group-sm float-end': isButtonGroup }"
:class="{ 'btn-group btn-group-sm float-end': isButtonGroup }" role="group" aria-label="Notification actions">
role="group"
aria-label="Notification actions" <button v-if="isRead"
>
<button
v-if="isRead"
class="btn" class="btn"
:class="overrideClass" :class="overrideClass"
type="button" type="button"
@@ -14,12 +11,11 @@
> >
<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 <button v-if="!isRead"
v-if="!isRead"
class="btn" class="btn"
:class="overrideClass" :class="overrideClass"
type="button" type="button"
@@ -28,12 +24,11 @@
> >
<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 <a v-if="isButtonGroup"
v-if="isButtonGroup"
type="button" type="button"
class="btn btn-outline-primary" class="btn btn-outline-primary"
:href="showUrl" :href="showUrl"
@@ -42,25 +37,11 @@
<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",
@@ -76,7 +57,7 @@ export default {
// Optional // Optional
buttonClass: { buttonClass: {
required: false, required: false,
type: String, type: String
}, },
buttonNoText: { buttonNoText: {
required: false, required: false,
@@ -84,14 +65,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() {
@@ -101,48 +82,31 @@ 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( makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/unread`, []).then(response => {
"POST", this.$emit('markRead', { notificationId: this.notificationId });
`/api/1.0/main/notification/${this.notificationId}/mark/unread`, })
[]
).then((response) => {
this.$emit("markRead", {notificationId: this.notificationId});
});
}, },
markAsRead() { markAsRead() {
makeFetch( makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/read`, []).then(response => {
"POST", this.$emit('markUnread', { notificationId: this.notificationId });
`/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> <style lang="scss">
</style>

View File

@@ -1,16 +1,13 @@
{% 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 <a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">
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">
@@ -19,7 +16,7 @@
<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 %}
@@ -27,7 +24,7 @@
{{ 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 %}
@@ -37,20 +34,20 @@
{% 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 %}
@@ -60,10 +57,7 @@
</span> </span>
{% endfor %} {% endfor %}
{% for a in c.notification.addressesEmails %} {% for a in c.notification.addressesEmails %}
<span <span class="badge-user" title="{{ 'notification.Email with access link'|trans|e('html_attr') }}">
class="badge-user"
title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"
>
{{ a }} {{ a }}
</span> </span>
{% endfor %} {% endfor %}
@@ -76,6 +70,7 @@
</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 %}
@@ -90,29 +85,25 @@
{% 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"> <p class="read-more"><a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">{{ 'Read more'|trans }}</a></p>
<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">
@@ -120,13 +111,13 @@
</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 <span class="notification_toggle_read_status"
class="notification_toggle_read_status"
data-notification-id="{{ c.notification.id }}" data-notification-id="{{ c.notification.id }}"
data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}" data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}"
data-container="notification-status" data-container="notification-status"
@@ -134,31 +125,18 @@
</li> </li>
{% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %} {% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %}
<li> <li>
<a <a href="{{ chill_path_add_return_path('chill_main_notification_edit', {'id': c.notification.id}) }}"
href="{{ chill_path_add_return_path( class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
'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', {% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', c.notification) %}
c.notification) %}
<li> <li>
<a <a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}"
href="{{ chill_path_add_return_path( class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}" title="{{ 'notification.see_comments_thread'|trans }}">
'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>
@@ -169,30 +147,24 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
<div <div class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}">
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 <button type="button" class="accordion-button collapsed"
type="button" data-bs-toggle="collapse" data-bs-target="#flush-collapse-{{ notification.id }}"
class="accordion-button collapsed" aria-expanded="false" aria-controls="flush-collapse-{{ notification.id }}">
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 <div id="flush-collapse-{{ notification.id }}"
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) }}
@@ -202,4 +174,5 @@
{{ _self.content(_context) }} {{ _self.content(_context) }}
{{ _self.actions(_context) }} {{ _self.actions(_context) }}
{% endif %} {% endif %}
</div> </div>

View File

@@ -4,75 +4,59 @@
{% 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"> <ul class="nav nav-pills justify-content-center">
<li class="nav-item"> <li class="nav-item">
<a <a class="nav-link {% if step == 'inbox' %}active{% endif %}" href="{{ path('chill_main_notification_my') }}">
class="nav-link {% if step == 'inbox' %}active{% endif %}" {{ 'notification.Notifications received'|trans }}
href="{{ path('chill_main_notification_my') }}"
>
{{ "notification.Notifications received" | trans }}
{% if unreads['inbox'] > 0 %} {% if unreads['inbox'] > 0 %}
<span class="badge rounded-pill bg-danger"> <span class="badge rounded-pill bg-danger">
{{ unreads["inbox"] }} {{ unreads['inbox'] }}
</span> </span>
{% endif %} {% endif %}
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a <a class="nav-link {% if step == 'sent' %}active{% endif %}" href="{{ path('chill_main_notification_sent') }}">
class="nav-link {% if step == 'sent' %}active{% endif %}" {{ 'notification.Notifications sent'|trans }}
href="{{ path('chill_main_notification_sent') }}"
>
{{ "notification.Notifications sent" | trans }}
{% if unreads['sent'] > 0 %} {% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger"> <span class="badge rounded-pill bg-danger">
{{ unreads["sent"] }} {{ unreads['sent'] }}
</span> </span>
{% endif %} {% endif %}
</a> </a>
</li> </li>
</ul> </ul>
{% if datas|length == 0 %} {% if step == 'inbox' %} {% if datas|length == 0 %}
<p class="chill-no-data-statement"> {% if step == 'inbox' %}
{{ "notification.Any notification received" | trans }} <p class="chill-no-data-statement">{{ 'notification.Any notification received'|trans }}</p>
</p>
{% else %} {% else %}
<p class="chill-no-data-statement"> <p class="chill-no-data-statement">{{ 'notification.Any notification sent'|trans }}</p>
{{ "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, 'notification_cc': data.template_data.notificationCc 'fold_item': true,
is defined ? data.template_data.notificationCc : false } %} 'notification_cc': data.template_data.notificationCc is defined ? data.template_data.notificationCc : false
} %}
{% endfor %} {% endfor %}
</div> </div>
{{ chill_pagination(paginator) }} {{ chill_pagination(paginator) }}
{% endif %} {% 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> </div>
{% endblock content %} {% endblock content %}

View File

@@ -40,13 +40,11 @@
{% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition) %} {% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition) %}
<div class="item-row separator"> <div class="item-row separator">
<div class="item-col" style="width: inherit;"> <div class="item-col" style="width: inherit;">
{% if step.transitionBy is not null %}
<div> <div>
{%- if step.transitionBy is not null -%}
{{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }} {{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }}
{% else %}
<span class="chill-no-data-statement">{{ 'workflow.Automated transition'|trans }}</span>
{%- endif -%}
</div> </div>
{% endif %}
<div> <div>
<span>{{ step.transitionAt|format_datetime('long', 'medium') }}</span> <span>{{ step.transitionAt|format_datetime('long', 'medium') }}</span>
</div> </div>

View File

@@ -23,7 +23,6 @@
{% if s.isSigned %} {% if s.isSigned %}
<span class="text-end">{{ 'workflow.signature_zone.has_signed_statement'|trans({ 'datetime' : s.stateDate }) }}</span> <span class="text-end">{{ 'workflow.signature_zone.has_signed_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% else %} {% else %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s) %}
<ul class="record_actions slim"> <ul class="record_actions slim">
<li> <li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a> <a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a>
@@ -32,9 +31,6 @@
{% endif %} {% endif %}
</li> </li>
</ul> </ul>
{% else %}
<span class="text-end">{{ 'workflow.waiting_for_signature'|trans }}</span>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -25,11 +25,13 @@
{% endblock %} {% endblock %}
<div class="content" id="content"> <div class="content" id="content">
<div class="container-xxl">
<div class="row"> <div class="row">
<div class="col-12 m-auto"> <div class="col-xs-12 col-md-12 col-lg-9 my-5 m-auto">
<div class="row" id="document-signature"></div> <div class="row" id="document-signature"></div>
</div> </div>
</div> </div>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@@ -3,28 +3,24 @@
{% if step.previous is not null %} {% if step.previous is not null %}
<li> <li>
<span class="item-key">{{ 'By'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'By'|trans ~ ' : ' }}</span>
<b>{% if step.previous.transitionBy is not null %}{{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }}{% else %}<span class="chill-no-data-statement">{{ 'workflow.Automated transition'|trans }}</span>{% endif %}</b> <b>{{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }}</b>
</li> </li>
<li> <li>
<span class="item-key">{{ 'Le'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'Le'|trans ~ ' : ' }}</span>
<b>{{ step.previous.transitionAt|format_datetime('short', 'short') }}</b> <b>{{ step.previous.transitionAt|format_datetime('short', 'short') }}</b>
</li> </li>
{% if step.destUser|length > 0 %}
<li> <li>
<span class="item-key">{{ 'workflow.For'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'workflow.For'|trans ~ ' : ' }}</span>
<b> <b>
{% for d in step.destUser %}{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}{% if not loop.last %}, {% endif %}{% endfor %} {% for d in step.destUser %}{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b> </b>
</li> </li>
{% endif %}
{% if step.ccUser|length > 0 %}
<li> <li>
<span class="item-key">{{ 'workflow.Cc'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'workflow.Cc'|trans ~ ' : ' }}</span>
<b> <b>
{% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %} {% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b> </b>
</li> </li>
{% endif %}
{% else %} {% else %}
<li> <li>
<span class="item-key">{{ 'workflow.Created by'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'workflow.Created by'|trans ~ ' : ' }}</span>

View File

@@ -8,6 +8,6 @@ Vous êtes invités à valider cette étape au plus tôt.
Vous pouvez visualiser le workflow sur cette page: Vous pouvez visualiser le workflow sur cette page:
{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': 'fr'})) }} {{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id})) }}
Cordialement, Cordialement,

View File

@@ -6,7 +6,7 @@ Titre du workflow: "{{ entityTitle }}".
Vous êtes invité·e à valider cette étape. Pour obtenir un accès, vous pouvez cliquer sur le lien suivant: Vous êtes invité·e à valider cette étape. Pour obtenir un accès, vous pouvez cliquer sur le lien suivant:
{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey, '_locale': fr})) }} {{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey})) }}
Dès que vous aurez cliqué une fois sur le lien, vous serez autorisé à valider cette étape. Dès que vous aurez cliqué une fois sur le lien, vous serez autorisé à valider cette étape.

View File

@@ -1,41 +0,0 @@
<?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\Security\Authorization;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class EntityWorkflowStepSignatureVoter extends Voter
{
public const SIGN = 'CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN';
protected function supports(string $attribute, $subject)
{
return $subject instanceof EntityWorkflowStepSignature && self::SIGN === $attribute;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token)
{
/** @var EntityWorkflowStepSignature $subject */
if ($subject->getSigner() instanceof Person) {
return true;
}
if ($subject->getSigner() === $token->getUser()) {
return true;
}
return false;
}
}

View File

@@ -1,88 +0,0 @@
<?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\Security\Authorization;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter class that determines if a user has permission to apply all transitions
* in a workflow based on their roles and the centers they have access to.
*/
final class EntityWorkflowTransitionVoter extends Voter implements ProvideRoleHierarchyInterface
{
final public const APPLY_ALL_TRANSITIONS = 'CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION';
public function __construct(
private readonly EntityWorkflowManager $workflowManager,
private readonly AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private readonly CenterResolverManagerInterface $centerResolverManager,
private readonly AccessDecisionManagerInterface $accessDecisionManager,
) {}
public function getRoles(): array
{
return [self::APPLY_ALL_TRANSITIONS];
}
public function getRolesWithoutScope(): array
{
return [self::APPLY_ALL_TRANSITIONS];
}
public function getRolesWithHierarchy(): array
{
return [
'workflow.Permissions' => [
self::APPLY_ALL_TRANSITIONS,
],
];
}
protected function supports(string $attribute, $subject): bool
{
return self::APPLY_ALL_TRANSITIONS === $attribute && $subject instanceof EntityWorkflowStep;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var EntityWorkflowStep $subject */
$entityWorkflow = $subject->getEntityWorkflow();
if (!$this->accessDecisionManager->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)) {
return false;
}
$handler = $this->workflowManager->getHandler($entityWorkflow);
$entity = $handler->getRelatedEntity($entityWorkflow);
if (null === $entity) {
return false;
}
$centers = $this->centerResolverManager->resolveCenters($entity);
$reachableCenters = $this->authorizationHelper->getReachableCenters(self::APPLY_ALL_TRANSITIONS);
foreach ($centers as $center) {
if (in_array($center, $reachableCenters, true)) {
return true;
}
}
return false;
}
}

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->expire($key, $ttl); $this->chillRedis->setTimeout($key, $ttl);
break; break;
} }

View File

@@ -15,12 +15,6 @@ 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'
@@ -53,18 +47,11 @@ 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($allowRemoveDoubleRefId); $this->updateAddressReferenceTable();
$this->deleteTemporaryTable(); $this->deleteTemporaryTable();
@@ -72,11 +59,6 @@ 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,
@@ -185,48 +167,15 @@ final class AddressReferenceBaseImporter
$this->isInitialized = true; $this->isInitialized = true;
} }
private function updateAddressReferenceTable(bool $allowRemoveDoubleRefId): void private function updateAddressReferenceTable(): 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)'
); );
// 0) detect for doublon in current temporary table
$results = $this->defaultConnection->executeQuery(
'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 // 1) Add new addresses
$this->logger->info(self::LOG_PREFIX.'upsert new addresses'); $this->logger->info(self::LOG_PREFIX.'upsert new addresses');
$affected = $connection->executeStatement("INSERT INTO chill_main_address_reference $affected = $this->defaultConnection->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'),
@@ -248,13 +197,12 @@ final class AddressReferenceBaseImporter
// 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 = $connection->executeStatement('UPDATE chill_main_address_reference $affected = $this->defaultConnection->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,8 +42,7 @@ class PostalCodeBaseImporter
NOW(), NOW(),
NOW() NOW()
FROM g FROM g
ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE SET label = excluded.label, center = excluded.center, updatedAt = NOW()
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

@@ -19,7 +19,24 @@ use Twig\TwigFilter;
*/ */
class ChillEntityRenderExtension extends AbstractExtension class ChillEntityRenderExtension extends AbstractExtension
{ {
public function __construct(private readonly ChillEntityRenderManagerInterface $renderManager) {} /**
* @var ChillEntityRender
*/
protected $defaultRender;
/**
* @var iterable|ChillEntityRenderInterface[]
*/
protected $renders = [];
/**
* ChillEntityRenderExtension constructor.
*/
public function __construct(iterable $renders)
{
$this->defaultRender = new ChillEntityRender();
$this->renders = $renders;
}
/** /**
* @return array|TwigFilter[] * @return array|TwigFilter[]
@@ -36,13 +53,34 @@ class ChillEntityRenderExtension extends AbstractExtension
]; ];
} }
public function renderBox(?object $entity, array $options = []): string public function renderBox($entity, array $options = []): string
{ {
return $this->renderManager->renderBox($entity, $options); if (null === $entity) {
return '';
} }
public function renderString(?object $entity, array $options = []): string return $this->getRender($entity, $options)
->renderBox($entity, $options);
}
public function renderString($entity, array $options = []): string
{ {
return $this->renderManager->renderString($entity, $options); if (null === $entity) {
return '';
}
return $this->getRender($entity, $options)
->renderString($entity, $options);
}
protected function getRender($entity, $options): ?ChillEntityRenderInterface
{
foreach ($this->renders as $render) {
if ($render->supports($entity, $options)) {
return $render;
}
}
return $this->defaultRender;
} }
} }

View File

@@ -15,7 +15,7 @@ namespace Chill\MainBundle\Templating\Entity;
* Interface to implement which will render an entity in template on a custom * Interface to implement which will render an entity in template on a custom
* manner. * manner.
* *
* @template T of object * @template T
*/ */
interface ChillEntityRenderInterface interface ChillEntityRenderInterface
{ {
@@ -31,7 +31,7 @@ interface ChillEntityRenderInterface
* </span> * </span>
* ``` * ```
* *
* @param T|null $entity * @param T $entity
* *
* @phpstan-pure * @phpstan-pure
*/ */
@@ -42,7 +42,7 @@ interface ChillEntityRenderInterface
* *
* Example: returning the name of a person. * Example: returning the name of a person.
* *
* @param T|null $entity * @param T $entity
* *
* @phpstan-pure * @phpstan-pure
*/ */

View File

@@ -1,56 +0,0 @@
<?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\Templating\Entity;
final readonly class ChillEntityRenderManager implements ChillEntityRenderManagerInterface
{
private ChillEntityRender $defaultRender;
public function __construct(/**
* @var iterable<ChillEntityRenderInterface>
*/
private iterable $renders,
) {
$this->defaultRender = new ChillEntityRender();
}
public function renderBox($entity, array $options = []): string
{
if (null === $entity) {
return '';
}
return $this->getRender($entity, $options)
->renderBox($entity, $options);
}
public function renderString($entity, array $options = []): string
{
if (null === $entity) {
return '';
}
return $this->getRender($entity, $options)
->renderString($entity, $options);
}
private function getRender($entity, $options): ChillEntityRenderInterface
{
foreach ($this->renders as $render) {
if ($render->supports($entity, $options)) {
return $render;
}
}
return $this->defaultRender;
}
}

View File

@@ -1,19 +0,0 @@
<?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\Templating\Entity;
interface ChillEntityRenderManagerInterface
{
public function renderBox(?object $entity, array $options = []): string;
public function renderString(?object $entity, array $options = []): string;
}

View File

@@ -1,144 +0,0 @@
<?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\Authorization;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @internal
*
* @coversNothing
*/
class EntityWorkflowTransitionVoterTest extends TestCase
{
use ProphecyTrait;
public function testVoteOnAttributeHappyScenario(): void
{
$entityWorkflow = new EntityWorkflow();
$object = new \stdClass();
$center = new Center();
$user = new User();
$handler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$handler->getRelatedEntity($entityWorkflow)->willReturn($object);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getHandler($entityWorkflow)->willReturn($handler);
$centerResolver = $this->prophesize(CenterResolverManagerInterface::class);
$centerResolver->resolveCenters($object)->willReturn([$center, new Center()]);
$autorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$autorizationHelper->getReachableCenters('CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION')
->willReturn([$center, new Center()]);
$token = new UsernamePasswordToken($user, 'default', $user->getRoles());
$accessDecision = $this->prophesize(AccessDecisionManagerInterface::class);
$accessDecision->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)
->willReturn(true)->shouldBeCalled();
$voter = new EntityWorkflowTransitionVoter(
$entityWorkflowManager->reveal(),
$autorizationHelper->reveal(),
$centerResolver->reveal(),
$accessDecision->reveal(),
);
self::assertEquals(Voter::ACCESS_GRANTED, $voter->vote($token, $entityWorkflow->getCurrentStep(), ['CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION']));
}
public function testVoteOnAttributeCenterNotReachable(): void
{
$entityWorkflow = new EntityWorkflow();
$object = new \stdClass();
$user = new User();
$handler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$handler->getRelatedEntity($entityWorkflow)->willReturn($object);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getHandler($entityWorkflow)->willReturn($handler);
$centerResolver = $this->prophesize(CenterResolverManagerInterface::class);
$centerResolver->resolveCenters($object)->willReturn([new Center()]);
$autorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$autorizationHelper->getReachableCenters('CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION')
->willReturn([new Center()]);
$token = new UsernamePasswordToken($user, 'default', $user->getRoles());
$accessDecision = $this->prophesize(AccessDecisionManagerInterface::class);
$accessDecision->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)
->willReturn(true)->shouldBeCalled();
$voter = new EntityWorkflowTransitionVoter(
$entityWorkflowManager->reveal(),
$autorizationHelper->reveal(),
$centerResolver->reveal(),
$accessDecision->reveal(),
);
self::assertEquals(Voter::ACCESS_DENIED, $voter->vote($token, $entityWorkflow->getCurrentStep(), ['CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION']));
}
public function testVoteNotOnSupportedAttribute(): void
{
$entityWorkflow = new EntityWorkflow();
$object = new \stdClass();
$user = new User();
$handler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$handler->getRelatedEntity($entityWorkflow)->willReturn($object);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getHandler($entityWorkflow)->willReturn($handler);
$centerResolver = $this->prophesize(CenterResolverManagerInterface::class);
$centerResolver->resolveCenters($object)->willReturn([new Center()]);
$autorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$autorizationHelper->getReachableCenters('CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION')
->willReturn([new Center()]);
$token = new UsernamePasswordToken($user, 'default', $user->getRoles());
$accessDecision = $this->prophesize(AccessDecisionManagerInterface::class);
$accessDecision->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)
->willReturn(true);
$voter = new EntityWorkflowTransitionVoter(
$entityWorkflowManager->reveal(),
$autorizationHelper->reveal(),
$centerResolver->reveal(),
$accessDecision->reveal(),
);
self::assertEquals(Voter::ACCESS_ABSTAIN, $voter->vote($token, new \stdClass(), ['CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION']));
self::assertEquals(Voter::ACCESS_ABSTAIN, $voter->vote($token, $entityWorkflow->getCurrentStep(), ['SOMETHING_ELSE']));
}
}

View File

@@ -18,14 +18,11 @@ use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneAvailable;
use Chill\MainBundle\Controller\WorkflowAddSignatureController; use Chill\MainBundle\Controller\WorkflowAddSignatureController;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment; use Twig\Environment;
@@ -65,13 +62,7 @@ class WorkflowAddSignatureControllerTest extends TestCase
$twig->method('render')->with('@ChillMain/Workflow/_signature_sign.html.twig', $this->isType('array')) $twig->method('render')->with('@ChillMain/Workflow/_signature_sign.html.twig', $this->isType('array'))
->willReturn('ok'); ->willReturn('ok');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class); $controller = new WorkflowAddSignatureController($entityWorkflowManager, $pdfSignatureZoneAvailable, $normalizer, $twig);
$security = $this->createMock(Security::class);
$security->expects($this->once())->method('isGranted')->with(EntityWorkflowStepSignatureVoter::SIGN, $signature)
->willReturn(true);
$controller = new WorkflowAddSignatureController($entityWorkflowManager, $pdfSignatureZoneAvailable, $normalizer, $twig, $urlGenerator, $security);
$actual = $controller($signature, new Request()); $actual = $controller($signature, new Request());

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 Chill\MainBundle\Tests\Repository; namespace Repository;
use Chill\MainBundle\Entity\NewsItem; use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Repository\NewsItemRepository; use Chill\MainBundle\Repository\NewsItemRepository;

View File

@@ -1,95 +0,0 @@
<?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

@@ -1,55 +0,0 @@
<?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

@@ -1,178 +0,0 @@
<?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\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\EventSubscriber\EntityWorkflowGuardTransition;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class EntityWorkflowGuardTransitionTest extends TestCase
{
use ProphecyTrait;
public static function buildRegistry(?EventSubscriberInterface $eventSubscriber): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces(['initial'])
->addPlaces(['initial', 'intermediate', 'step1', 'step2', 'step3'])
->addTransition(new Transition('intermediate', 'initial', 'intermediate'))
->addTransition($transition1 = new Transition('transition1', 'intermediate', 'step1'))
->addTransition($transition2 = new Transition('transition2', 'intermediate', 'step2'))
->addTransition($transition3 = new Transition('transition3', 'intermediate', 'step3'))
;
$transitionMetadata = new \SplObjectStorage();
$transitionMetadata->attach($transition1, ['transitionGuard' => 'only-dest']);
$transitionMetadata->attach($transition2, ['transitionGuard' => 'only-dest+system']);
$transitionMetadata->attach($transition3, ['transitionGuard' => 'system']);
$builder->setMetadataStore(new InMemoryMetadataStore(transitionsMetadata: $transitionMetadata));
if (null !== $eventSubscriber) {
$eventDispatcher = new EventDispatcher();
$eventDispatcher->addSubscriber($eventSubscriber);
}
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher ?? null, 'dummy');
$registry = new Registry();
$registry->addWorkflow(
$workflow,
new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
}
);
return $registry;
}
/**
* @dataProvider provideBlockingTransition
*/
public function testTransitionGuardBlocked(EntityWorkflow $entityWorkflow, string $transition, ?User $user, bool $isGrantedAllTransition, string $uuid): void
{
$userRender = $this->prophesize(UserRender::class);
$userRender->renderString(Argument::type(User::class), [])->willReturn('user-as-string');
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, $entityWorkflow->getCurrentStep())
->willReturn($isGrantedAllTransition);
$transitionGuard = new EntityWorkflowGuardTransition($userRender->reveal(), $security->reveal());
$registry = self::buildRegistry($transitionGuard);
$workflow = $registry->get($entityWorkflow, 'dummy');
$context = new WorkflowTransitionContextDTO($entityWorkflow);
self::expectException(NotEnabledTransitionException::class);
try {
$workflow->apply($entityWorkflow, $transition, ['context' => $context, 'byUser' => $user, 'transition' => $transition, 'transitionAt' => new \DateTimeImmutable('now')]);
} catch (NotEnabledTransitionException $e) {
$list = $e->getTransitionBlockerList();
self::assertEquals(1, $list->count());
$list = iterator_to_array($list->getIterator());
self::assertEquals($uuid, $list[0]->getCode());
throw $e;
}
}
/**
* @dataProvider provideValidTransition
*/
public function testTransitionGuardValid(EntityWorkflow $entityWorkflow, string $transition, ?User $user, bool $isGrantedAllTransition, string $newStep): void
{
$userRender = $this->prophesize(UserRender::class);
$userRender->renderString(Argument::type(User::class), [])->willReturn('user-as-string');
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, $entityWorkflow->getCurrentStep())
->willReturn($isGrantedAllTransition);
$transitionGuard = new EntityWorkflowGuardTransition($userRender->reveal(), $security->reveal());
$registry = self::buildRegistry($transitionGuard);
$workflow = $registry->get($entityWorkflow, 'dummy');
$context = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, $transition, ['context' => $context, 'byUser' => $user, 'transition' => $transition, 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals($newStep, $entityWorkflow->getStep());
}
public static function provideBlockingTransition(): iterable
{
yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), false, 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc'];
yield [self::buildEntityWorkflow([]), 'transition1', null, false, 'd9e39a18-704c-11ef-b235-8fe0619caee7'];
yield [self::buildEntityWorkflow([new User()]), 'transition1', null, false, 'd9e39a18-704c-11ef-b235-8fe0619caee7'];
yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, false, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'];
yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, true, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'];
}
public static function provideValidTransition(): iterable
{
yield [self::buildEntityWorkflow([$u = new User()]), 'transition1', $u, false, 'step1'];
yield [self::buildEntityWorkflow([$u = new User()]), 'transition2', $u, false, 'step2'];
yield [self::buildEntityWorkflow([new User()]), 'transition2', null, false, 'step2'];
yield [self::buildEntityWorkflow([]), 'transition2', null, false, 'step2'];
yield [self::buildEntityWorkflow([new User()]), 'transition3', null, false, 'step3'];
yield [self::buildEntityWorkflow([]), 'transition3', null, false, 'step3'];
// transition allowed thanks to permission "apply all transitions"
yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), true, 'step1'];
yield [self::buildEntityWorkflow([new User()]), 'transition2', new User(), true, 'step2'];
}
public static function buildEntityWorkflow(array $futureDestUsers): EntityWorkflow
{
$registry = self::buildRegistry(null);
$baseContext = ['transition' => 'intermediate', 'transitionAt' => new \DateTimeImmutable()];
// test a user not is destination is blocked
$entityWorkflow = new EntityWorkflow();
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers = $futureDestUsers;
$workflow->apply($entityWorkflow, 'intermediate', ['context' => $dto, ...$baseContext]);
return $entityWorkflow;
}
}

View File

@@ -1,146 +0,0 @@
<?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\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class SignatureStepStateChangerTest extends TestCase
{
public function testMarkSignatureAsSignedScenarioWhichExpectsTransition()
{
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy');
$registry = $this->buildRegistry();
$workflow = $registry->get($entityWorkflow, 'dummy');
$clock = new MockClock();
$user = new User();
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger());
// move it to signature
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures = [new Person(), new Person()];
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => $clock->now(),
'byUser' => $user, 'transition' => 'to_signature']);
// get the signature created
$signatures = $entityWorkflow->getCurrentStep()->getSignatures();
if (2 !== count($signatures)) {
throw new \LogicException('there should have 2 signatures at this step');
}
// we mark the first signature as signed
$changer->markSignatureAsSigned($signatures[0], 1);
self::assertEquals('signature', $entityWorkflow->getStep(), 'there should have any change in the entity workflow step');
self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[0]->getState());
self::assertEquals(1, $signatures[0]->getZoneSignatureIndex());
self::assertNotNull($signatures[0]->getStateDate());
// we mark the second signature as signed
$changer->markSignatureAsSigned($signatures[1], 2);
self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[1]->getState());
self::assertEquals('post-signature', $entityWorkflow->getStep(), 'the entity workflow step should be post-signature');
self::assertContains($user, $entityWorkflow->getCurrentStep()->getAllDestUser());
self::assertEquals(2, $signatures[1]->getZoneSignatureIndex());
self::assertNotNull($signatures[1]->getStateDate());
}
public function testMarkSignatureAsSignedScenarioWithoutRequiredMetadata()
{
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy');
$registry = $this->buildRegistry();
$workflow = $registry->get($entityWorkflow, 'dummy');
$clock = new MockClock();
$user = new User();
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger());
// move it to signature
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures = [new Person()];
$workflow->apply($entityWorkflow, 'to_signature-without-metadata', ['context' => $dto, 'transitionAt' => $clock->now(),
'byUser' => $user, 'transition' => 'to_signature-without-metadata']);
// get the signature created
$signatures = $entityWorkflow->getCurrentStep()->getSignatures();
if (1 !== count($signatures)) {
throw new \LogicException('there should have 2 signatures at this step');
}
// we mark the first signature as signed
$changer->markSignatureAsSigned($signatures[0], 1);
self::assertEquals('signature-without-metadata', $entityWorkflow->getStep(), 'there should have any change in the entity workflow step');
self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[0]->getState());
self::assertEquals(1, $signatures[0]->getZoneSignatureIndex());
self::assertNotNull($signatures[0]->getStateDate());
}
private function buildRegistry(): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces('initial')
->addPlaces(['initial', 'signature', 'signature-without-metadata', 'post-signature'])
->addTransition(new Transition('to_signature', 'initial', 'signature'))
->addTransition(new Transition('to_signature-without-metadata', 'initial', 'signature-without-metadata'))
->addTransition(new Transition('to_post-signature', 'signature', 'post-signature'))
->addTransition(new Transition('to_post-signature_2', 'signature-without-metadata', 'post-signature'))
;
$metadata = new InMemoryMetadataStore(
[],
[
'signature' => ['onSignatureCompleted' => ['transitionName' => 'to_post-signature']],
]
);
$builder->setMetadataStore($metadata);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
$registry = new Registry();
$registry->addWorkflow(
$workflow,
new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
}
);
return $registry;
}
}

View File

@@ -1,123 +0,0 @@
<?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\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
use Chill\MainBundle\Templating\Entity\UserRender;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;
/**
* Prevent apply a transition on an entity workflow.
*
* This apply logic and rules to decide if a transition can be applyed.
*
* Those rules are:
*
* - if the transition is system-only or is allowed for user;
* - if the user is present in the dest users for a workflow;
* - or if the user have permission to apply all the transitions
*/
class EntityWorkflowGuardTransition implements EventSubscriberInterface
{
public function __construct(
private readonly UserRender $userRender,
private readonly Security $security,
) {}
public static function getSubscribedEvents(): array
{
return [
'workflow.guard' => [
['guardEntityWorkflow', 0],
],
];
}
public function guardEntityWorkflow(GuardEvent $event)
{
if (!$event->getSubject() instanceof EntityWorkflow) {
return;
}
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
if ($entityWorkflow->isFinal()) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.The workflow is finalized',
'd6306280-7535-11ec-a40d-1f7bee26e2c0'
)
);
return;
}
$user = $this->security->getUser();
$metadata = $event->getWorkflow()->getMetadataStore()->getTransitionMetadata($event->getTransition());
$systemTransitions = explode('+', $metadata['transitionGuard'] ?? 'only-dest');
if (null === $user) {
if (in_array('system', $systemTransitions, true)) {
// it is safe to apply this transition
return;
}
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.Transition is not allowed for system',
'd9e39a18-704c-11ef-b235-8fe0619caee7'
)
);
return;
}
// for users
if (!in_array('only-dest', $systemTransitions, true)) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.Only system can apply this transition',
'5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'
)
);
}
if (
!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($user)
) {
if ($event->getMarking()->has('initial')) {
return;
}
if ($this->security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, $entityWorkflow->getCurrentStep())) {
return;
}
$event->addTransitionBlocker(new TransitionBlocker(
'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%',
'f3eeb57c-7532-11ec-9495-e7942a2ac7bc',
[
'%users%' => implode(
', ',
$entityWorkflow->getCurrentStep()->getAllDestUser()->map(fn (User $u) => $this->userRender->renderString($u, []))->toArray()
),
]
));
}
}
}

View File

@@ -13,16 +13,20 @@ namespace Chill\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Templating\Entity\UserRender;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\Event; use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;
final readonly class EntityWorkflowTransitionEventSubscriber implements EventSubscriberInterface final readonly class EntityWorkflowTransitionEventSubscriber implements EventSubscriberInterface
{ {
public function __construct( public function __construct(
private LoggerInterface $chillLogger, private LoggerInterface $chillLogger,
private Security $security, private Security $security,
private UserRender $userRender,
) {} ) {}
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
@@ -32,9 +36,48 @@ final readonly class EntityWorkflowTransitionEventSubscriber implements EventSub
'workflow.completed' => [ 'workflow.completed' => [
['markAsFinal', 2048], ['markAsFinal', 2048],
], ],
'workflow.guard' => [
['guardEntityWorkflow', 0],
],
]; ];
} }
public function guardEntityWorkflow(GuardEvent $event)
{
if (!$event->getSubject() instanceof EntityWorkflow) {
return;
}
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
if ($entityWorkflow->isFinal()) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.The workflow is finalized',
'd6306280-7535-11ec-a40d-1f7bee26e2c0'
)
);
return;
}
if (!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($this->security->getUser())) {
if (!$event->getMarking()->has('initial')) {
$event->addTransitionBlocker(new TransitionBlocker(
'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%',
'f3eeb57c-7532-11ec-9495-e7942a2ac7bc',
[
'%users%' => implode(
', ',
$entityWorkflow->getCurrentStep()->getAllDestUser()->map(fn (User $u) => $this->userRender->renderString($u, []))->toArray()
),
]
));
}
}
}
public function markAsFinal(Event $event): void public function markAsFinal(Event $event): void
{ {
// NOTE: it is not possible to move this method to the marking store, because // NOTE: it is not possible to move this method to the marking store, because
@@ -66,13 +109,11 @@ final readonly class EntityWorkflowTransitionEventSubscriber implements EventSub
/** @var EntityWorkflow $entityWorkflow */ /** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject(); $entityWorkflow = $event->getSubject();
$user = $this->security->getUser();
$this->chillLogger->info('[workflow] apply transition on entityWorkflow', [ $this->chillLogger->info('[workflow] apply transition on entityWorkflow', [
'relatedEntityClass' => $entityWorkflow->getRelatedEntityClass(), 'relatedEntityClass' => $entityWorkflow->getRelatedEntityClass(),
'relatedEntityId' => $entityWorkflow->getRelatedEntityId(), 'relatedEntityId' => $entityWorkflow->getRelatedEntityId(),
'transition' => $event->getTransition()->getName(), 'transition' => $event->getTransition()->getName(),
'by_user' => $user instanceof User ? $user->getId() : (string) $user?->getUserIdentifier(), 'by_user' => $this->security->getUser(),
'entityWorkflow' => $entityWorkflow->getId(), 'entityWorkflow' => $entityWorkflow->getId(),
]); ]);
} }

View File

@@ -1,112 +0,0 @@
<?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\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Workflow\Registry;
class SignatureStepStateChanger
{
private const LOG_PREFIX = '[SignatureStepStateChanger] ';
public function __construct(
private readonly Registry $registry,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {}
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): void
{
$signature
->setState(EntityWorkflowSignatureStateEnum::SIGNED)
->setZoneSignatureIndex($atIndex)
->setStateDate($this->clock->now())
;
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]);
if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) {
$this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]);
return;
}
$this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]);
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$metadataStore = $workflow->getMetadataStore();
// find a transition
$marking = $workflow->getMarking($entityWorkflow);
$places = $marking->getPlaces();
$transition = null;
foreach ($places as $place => $int) {
$metadata = $metadataStore->getPlaceMetadata($place);
if (array_key_exists('onSignatureCompleted', $metadata)) {
$transition = $metadata['onSignatureCompleted']['transitionName'];
}
}
if (null === $transition) {
$this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]);
return;
}
$previousUser = $this->getPreviousSender($signature->getStep());
if (null === $previousUser) {
$this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]);
return;
}
$transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
$transitionDto->futureDestUsers[] = $previousUser;
$workflow->apply($entityWorkflow, $transition, [
'context' => $transitionDto,
'transitionAt' => $this->clock->now(),
'transition' => $transition,
]);
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
}
private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User
{
$stepsChained = $entityWorkflowStep->getEntityWorkflow()->getStepsChained();
foreach ($stepsChained as $stepChained) {
if ($stepChained === $entityWorkflowStep) {
if (null === $previous = $stepChained->getPrevious()) {
return null;
}
if (null !== $previousUser = $previous->getTransitionBy()) {
return $previousUser;
}
return $this->getPreviousSender($previous);
}
}
throw new \LogicException('no same step found');
}
}

View File

@@ -165,6 +165,7 @@ components:
endDate: endDate:
$ref: "#/components/schemas/Date" $ref: "#/components/schemas/Date"
paths: paths:
/1.0/search.json: /1.0/search.json:
get: get:
@@ -236,7 +237,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:
@@ -274,7 +275,7 @@ paths:
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:
@@ -320,7 +321,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:
@@ -343,6 +344,7 @@ 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:
@@ -363,7 +365,7 @@ paths:
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:
@@ -404,7 +406,7 @@ paths:
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:
@@ -437,7 +439,7 @@ paths:
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:
@@ -475,7 +477,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"
@@ -508,7 +510,7 @@ paths:
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:
@@ -539,7 +541,7 @@ paths:
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:
@@ -573,12 +575,13 @@ paths:
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:
@@ -623,7 +626,7 @@ paths:
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:
@@ -781,7 +784,7 @@ 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:
@@ -820,38 +823,6 @@ paths:
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:
@@ -873,7 +844,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:
@@ -887,7 +858,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:
@@ -903,7 +874,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:
@@ -917,7 +888,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/DashboardConfigItem" $ref: '#/components/schemas/DashboardConfigItem'
403: 403:
description: "Unauthorized" description: "Unauthorized"
@@ -934,6 +905,6 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/NewsItem" $ref: '#/components/schemas/NewsItem'
403: 403:
description: "Unauthorized" description: "Unauthorized"

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