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
422 changed files with 2232 additions and 16196 deletions

View File

@@ -0,0 +1,8 @@
kind: Feature
body: |-
Electronic signature
Implementation of the electronic signature for documents within chill.
time: 2024-06-14T15:32:36.875891692+02:00
custom:
Issue: ""

View File

@@ -0,0 +1,7 @@
kind: Feature
body: The behavoir of the voters for stored objects is adjusted so as to limit edit
and delete possibilities to users related to the activity, social action or workflow
entity.
time: 2024-06-14T15:35:37.582159301+02:00
custom:
Issue: "286"

View File

@@ -0,0 +1,5 @@
kind: Feature
body: Metadata form added for person signatures
time: 2024-07-18T15:12:33.8134266+02:00
custom:
Issue: "288"

View File

@@ -1,30 +1,11 @@
## v2.23.0 - 2024-07-23 & 2024-07-19
## v2.23.0 - 2024-07-23
### Feature
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
* 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
* Fix resolving of centers for an household, which will fix in turn the access control
* 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.
* Fix resolving of centers for an household, which will fix in turn the access control
* Resolved type hinting error in activity list export

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.

View File

@@ -1,6 +0,0 @@
## v3.1.1 - 2024-10-01
### Fixed
* ([#308](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/308)) Show only the current referrer in the page "show" for an accompanying period workf
* ([#309](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/309)) Correctly compute the grouping by referrer aggregator
* Fixed typing of custom field long choice and custom field group

View File

@@ -1,3 +0,0 @@
## v3.2.0 - 2024-10-30
### Feature
* Introduce a gender entity

View File

@@ -1,4 +0,0 @@
## v3.2.1 - 2024-10-31
### Fixed
* Add the possibility of unknown to the gender entity
* Fix the fusion of person doubles by excluding accompanyingPeriod work entities to be deleted. They are moved instead.

View File

@@ -1,3 +0,0 @@
## v3.2.2 - 2024-10-31
### Fixed
* Fix gender translation for unknown

View File

@@ -1,4 +0,0 @@
## v3.2.3 - 2024-11-05
### Fixed
* ([#315](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/315)) Fix display of accompanying period work referrers. Only current referrers should be displayed.
Fix color of Chill footer

View File

@@ -1,3 +0,0 @@
## v3.2.4 - 2024-11-06
### Fixed
* Fix compilation of chill assets

View File

@@ -1,13 +0,0 @@
## v3.3.0 - 2024-11-20
### Feature
* Electronic signature
Implementation of the electronic signature for documents within chill.
* ([#286](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/286)) The behavoir of the voters for stored objects is adjusted so as to limit edit and delete possibilities to users related to the activity, social action or workflow entity.
* ([#288](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/288)) Metadata form added for person signatures
* Add a signature step in workflow, which allow to apply an electronic signature on documents
* Keep an history of each version of a stored object.
* Add a "send external" step in workflow, which allow to send stored objects and other elements to remote people, by sending them a public url
### Fixed
* Adjust household list export to include households even if their address is NULL
* Remove validation of date string on deathDate

View File

@@ -1,4 +0,0 @@
## v3.4.0 - 2024-11-20
### Feature
* ([#314](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/314)) Admin: improve document type admin form with a select field for related class.
Admin: Allow administrator to assign multiple group centers in one go to a user.

View File

@@ -1,3 +0,0 @@
## v3.4.1 - 2024-11-22
### Fixed
* Set the workflow's title to notification content and subject

2
.env
View File

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

View File

@@ -6,102 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v3.4.1 - 2024-11-22
### Fixed
* Set the workflow's title to notification content and subject
## v3.4.0 - 2024-11-20
### Feature
* ([#314](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/314)) Admin: improve document type admin form with a select field for related class.
Admin: Allow administrator to assign multiple group centers in one go to a user.
## v3.3.0 - 2024-11-20
### Feature
* Electronic signature
Implementation of the electronic signature for documents within chill.
* ([#286](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/286)) The behavoir of the voters for stored objects is adjusted so as to limit edit and delete possibilities to users related to the activity, social action or workflow entity.
* ([#288](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/288)) Metadata form added for person signatures
* Add a signature step in workflow, which allow to apply an electronic signature on documents
* Keep an history of each version of a stored object.
* Add a "send external" step in workflow, which allow to send stored objects and other elements to remote people, by sending them a public url
### Fixed
* Adjust household list export to include households even if their address is NULL
* Remove validation of date string on deathDate
## v3.2.4 - 2024-11-06
### Fixed
* Fix compilation of chill assets
## v3.2.3 - 2024-11-05
### Fixed
* ([#315](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/315)) Fix display of accompanying period work referrers. Only current referrers should be displayed.
Fix color of Chill footer
## v3.2.2 - 2024-10-31
### Fixed
* Fix gender translation for unknown
## v3.2.1 - 2024-10-31
### Fixed
* Add the possibility of unknown to the gender entity
* Fix the fusion of person doubles by excluding accompanyingPeriod work entities to be deleted. They are moved instead.
## v3.2.0 - 2024-10-30
### Feature
* Introduce a gender entity
## v3.1.1 - 2024-10-01
### Fixed
* ([#308](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/308)) Show only the current referrer in the page "show" for an accompanying period workf
* ([#309](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/309)) Correctly compute the grouping by referrer aggregator
* Fixed typing of custom field long choice and custom field group
## 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
### Fixed
* Fix delete action for accompanying periods in draft state
* Fix connection to azure when making an calendar event in chill
* CollectionType js fixes for remove button and adding multiple entries
## v2.24.0 - 2024-09-11
## v2.23.0 - 2024-07-23
### 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-23 & 2024-07-19
### Feature
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
* 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
* Fix resolving of centers for an household, which will fix in turn the access control
* 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.
* Fix resolving of centers for an household, which will fix in turn the access control
* Resolved type hinting error in activity list export
## v2.22.2 - 2024-07-03
### Fixed

View File

@@ -43,7 +43,6 @@
"symfony/dom-crawler": "^5.4",
"symfony/error-handler": "^5.4",
"symfony/event-dispatcher": "^5.4",
"symfony/event-dispatcher-contracts": "^2.4",
"symfony/expression-language": "^5.4",
"symfony/filesystem": "^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 DateInterval;
use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
class MyCronJob implements CronJobInterface
{
function __construct(private ClockInterface $clock) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
// 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
$now = $clock->now();
$now = new DateTimeImmutable('now');
return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D'))
&& in_array($now->format('H'), self::ACCEPTED_HOURS, true)
@@ -72,15 +69,10 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
return 'arbitrary-and-unique-key';
}
public function run(array $lastExecutionData): void
public function run(): void
{
// here, we execute the command
// we return execution data, which will be served for next execution
// this data should be easily serializable in a json column: it should contains
// only int, string, etc. Avoid storing object
return ['last-execution-id' => 0];
}
}
}
How are cron job scheduled ?

View File

@@ -55,12 +55,11 @@
"mime": "^4.0.0",
"pdfjs-dist": "^4.3.136",
"vis-network": "^9.1.0",
"vue": "^3.5.6",
"vue": "^3.2.37",
"vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2",
"vuex": "^4.0.0",
"bootstrap-icons": "^1.11.3"
"vuex": "^4.0.0"
},
"browserslist": [
"Firefox ESR"

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

View File

@@ -73,7 +73,7 @@ final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInt
$qb->andWhere(
$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;
}
public function alterQuery(QueryBuilder $qb, $data): void
public function alterQuery(QueryBuilder $qb, $data)
{
// create a subquery for activity
$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 [
[] === $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';
}

View File

@@ -16,7 +16,7 @@ use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
@@ -24,7 +24,7 @@ class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
public function __construct(
private readonly ActivityRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
}

View File

@@ -243,7 +243,3 @@ services:
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator:
tags:
- { 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:
title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré)
person: Usager
by_household:
title: Grouper les échanges par ménage
household: Identifiant ménage
acp:
by_activity_type:
title: Grouper les parcours par type d'échange

View File

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

View File

@@ -5,9 +5,11 @@ const appMessages = {
show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends",
copy_range: "Copier",
copy_range_from_to: "Copier les plages",
from_day_to_day: "d'un jour à l'autre",
from_week_to_week: "d'une semaine à l'autre",
copy_range_from_to: "Copier les plages d'un jour à l'autre",
copy_range_to_next_day: "Copier les plages du jour au jour suivant",
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.",
new_range_to_save: "Nouvelles plages à enregistrer",
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;
},
},
@@ -255,7 +238,7 @@ export default <Module<CalendarRangesState, State>>{
for (let r of rangesToCopy) {
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));
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
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}));
}
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));
}
}

View File

@@ -42,8 +42,8 @@ class CustomFieldLongChoice extends AbstractCustomField
$translatableStringHelper = $this->translatableStringHelper;
$builder->add($customField->getSlug(), Select2ChoiceType::class, [
'choices' => $entries,
'choice_label' => static fn (?Option $option) => $translatableStringHelper->localize($option->getText()),
'choice_value' => static fn (?Option $key): ?int => $key?->getId(),
'choice_label' => static fn (Option $option) => $translatableStringHelper->localize($option->getText()),
'choice_value' => static fn (Option $key): ?int => null === $key ? null : $key->getId(),
'multiple' => false,
'expanded' => false,
'required' => $customField->isRequired(),

View File

@@ -46,8 +46,11 @@ class CustomFieldsGroup
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
/**
* @var array
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)]
private array|string $name;
private $name;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)]
private array $options = [];
@@ -178,7 +181,7 @@ class CustomFieldsGroup
*
* @return CustomFieldsGroup
*/
public function setName(array|string $name)
public function setName($name)
{
$this->name = $name;

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 Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
final readonly class DocumentAccompanyingCourseDuplicateController
{
public function __construct(
private Security $security,
private AccompanyingCourseDocumentDuplicator $documentWorkflowDuplicator,
private EntityManagerInterface $entityManager,
private UrlGeneratorInterface $urlGenerator,
) {}
#[Route('/{_locale}/doc-store/accompanying-course-document/{id}/duplicate', name: 'chill_doc_store_accompanying_course_document_duplicate')]
public function __invoke(AccompanyingCourseDocument $document, Request $request, Session $session): Response
{
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $document)) {
throw new AccessDeniedHttpException('not allowed to see this document');
}
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::CREATE, $document->getCourse())) {
throw new AccessDeniedHttpException('not allowed to create this document');
}
$duplicated = $this->documentWorkflowDuplicator->duplicate($document);
$this->entityManager->persist($duplicated);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate('accompanying_course_document_edit', ['id' => $duplicated->getId(), 'course' => $duplicated->getCourse()->getId()])
);
}
}

View File

@@ -201,4 +201,36 @@ class DocumentPersonController extends AbstractController
['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,22 +15,12 @@ use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessa
use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SignatureRequestController
{
@@ -38,36 +28,16 @@ class SignatureRequestController
private readonly MessageBusInterface $messageBus,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly ChillEntityRenderManagerInterface $entityRender,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
private readonly StoredObjectToPdfConverter $converter,
private readonly EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
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();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
return new JsonResponse([], status: Response::HTTP_CONFLICT);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
$content = $this->storedObjectManager->read($storedObject);
if ('application/pdf' !== $storedObject->getType()) {
[$storedObject, $storedObjectVersion, $content] = $this->converter->addConvertedVersion($storedObject, $request->getLocale(), includeConvertedContent: true);
$this->entityManager->persist($storedObjectVersion);
$this->entityManager->flush();
} else {
$content = $this->storedObjectManager->read($storedObject);
}
$data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); // TODO parse payload: json_decode ou, mieux, dataTransfertObject
$zone = new PDFSignatureZone(
$data['zone']['index'],
$data['zone']['x'],
@@ -81,15 +51,8 @@ class SignatureRequestController
$signature->getId(),
$zone,
$data['zone']['index'],
'Signed by IP: '.(string) $request->getClientIp().', authenticated user: '.$this->entityRender->renderString($this->security->getUser(), []),
$this->entityRender->renderString($signature->getSigner(), [
// options for user render
'absence' => false,
'main_scope' => false,
UserRender::SPLIT_LINE_BEFORE_CHARACTER => 30,
// options for person render
'addAge' => false,
]),
'test signature', // reason (string)
'Mme Caroline Diallo', // signerText (string)
$content
));
@@ -99,16 +62,6 @@ class SignatureRequestController
#[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')]
public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse
{
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
return new JsonResponse(
[
'state' => $signature->getState(),
'storedObject' => $this->normalizer->normalize($storedObject, 'json'),
],
JsonResponse::HTTP_OK,
[]
);
return new JsonResponse($signature->getState(), 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 Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table('chill_doc.accompanyingcourse_document')]
#[ORM\UniqueConstraint(name: 'acc_course_document_unique_stored_object', columns: ['object_id'])]
class AccompanyingCourseDocument extends Document implements HasScopesInterface, HasCentersInterface
{
#[ORM\ManyToOne(targetEntity: AccompanyingPeriod::class)]

View File

@@ -40,7 +40,6 @@ class Document implements TrackCreationInterface, TrackUpdateInterface
#[Assert\Valid]
#[Assert\NotNull(message: 'Upload a document')]
#[ORM\ManyToOne(targetEntity: StoredObject::class, cascade: ['persist'])]
#[ORM\JoinColumn(name: 'object_id', referencedColumnName: 'id')]
private ?StoredObject $object = null;
#[ORM\ManyToOne(targetEntity: DocGeneratorTemplate::class)]

View File

@@ -19,7 +19,6 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table('chill_doc.person_document')]
#[ORM\UniqueConstraint(name: 'person_document_unique_stored_object', columns: ['object_id'])]
class PersonDocument extends Document implements HasCenterInterface, HasScopeInterface
{
#[ORM\Id]

View File

@@ -18,9 +18,6 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
@@ -92,10 +89,10 @@ class StoredObject implements Document, TrackCreationInterface
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)]
private Collection&Selectable $versions;
private Collection $versions;
/**
* @param StoredObject::STATUS_* $status
@@ -259,33 +256,11 @@ class StoredObject implements Document, TrackCreationInterface
return $this->template;
}
/**
* @return Selectable<int, StoredObjectVersion>&Collection<int, StoredObjectVersion>
*/
public function getVersions(): Collection&Selectable
public function getVersions(): Collection
{
return $this->versions;
}
/**
* Retrieves versions sorted by a given order.
*
* @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending
*
* @return readableCollection&Selectable The ordered collection of versions
*/
public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable
{
$versions = $this->getVersions()->toArray();
match ($order) {
'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()),
'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()),
};
return new ArrayCollection($versions);
}
public function hasCurrentVersion(): bool
{
return null !== $this->getCurrentVersion();
@@ -296,47 +271,6 @@ class StoredObject implements Document, TrackCreationInterface
return null !== $this->template;
}
/**
* Checks if there is a version kept before conversion.
*
* @return bool true if a version is kept before conversion, false otherwise
*/
public function hasKeptBeforeConversionVersion(): bool
{
foreach ($this->getVersions() as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return true;
}
}
}
return false;
}
/**
* Retrieves the last version of the stored object that was kept before conversion.
*
* This method iterates through the ordered versions and their respective points
* in time to find the most recent version that has a point in time with the reason
* 'KEEP_BEFORE_CONVERSION'.
*
* @return StoredObjectVersion|null the version that was kept before conversion,
* or null if not found
*/
public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion
{
foreach ($this->getVersionsOrdered('DESC') as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return $version;
}
}
}
return null;
}
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
{
$this->template = $template;

View File

@@ -37,7 +37,7 @@ class StoredObjectPointInTime implements TrackCreationInterface
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')]
#[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)]
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,
#[ORM\ManyToOne(targetEntity: User::class)]
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)]
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(
/**
* The stored object associated with this version.
@@ -106,7 +87,6 @@ class StoredObjectVersion implements TrackCreationInterface
) {
$this->filename = $filename ?? self::generateFilename($this);
$this->pointInTimes = new ArrayCollection();
$this->children = new ArrayCollection();
}
public static function generateFilename(StoredObjectVersion $storedObjectVersion): string
@@ -169,6 +149,8 @@ class StoredObjectVersion implements TrackCreationInterface
}
/**
* @return $this
*
* @internal use @see{StoredObjectPointInTime} constructor instead
*/
public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
@@ -188,42 +170,4 @@ class StoredObjectVersion implements TrackCreationInterface
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

@@ -17,23 +17,15 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
class DocumentCategoryType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$bundles = [
'chill-doc-store' => 'chill-doc-store',
];
$documentClasses = [
$this->translator->trans('Accompanying period document') => \Chill\DocStoreBundle\Entity\AccompanyingCourseDocument::class,
$this->translator->trans('Person document') => \Chill\DocStoreBundle\Entity\PersonDocument::class,
];
$builder
->add('bundleId', ChoiceType::class, [
'choices' => $bundles,
@@ -42,10 +34,7 @@ class DocumentCategoryType extends AbstractType
->add('idInsideBundle', null, [
'disabled' => true,
])
->add('documentClass', ChoiceType::class, [
'choices' => $documentClasses,
'expanded' => false,
'required' => true,
->add('documentClass', null, [
'disabled' => false,
])
->add('name', TranslatableStringFormType::class);

View File

@@ -23,7 +23,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $em)
public function __construct(private readonly EntityManagerInterface $em)
{
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
}

View File

@@ -1,27 +0,0 @@
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import {createApp} from "vue";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {defineComponent} from "vue";
import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll<HTMLDivElement>('div[data-download-button-single]').forEach((el) => {
const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject;
const title = el.dataset.title as string;
const app = createApp({
components: {DownloadButton},
data() {
return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}};
},
template: '<download-button :stored-object="storedObject" :at-version="storedObject.currentVersion" :classes="classes" :filename="title" :direct-download="true"></download-button>',
});
app.use(i18n).use(ToastPlugin).mount(el);
});
});

View File

@@ -3,7 +3,6 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v
import {createApp} from "vue";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
import ToastPlugin from "vue-toast-notification";
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,141 +1,100 @@
import {
DateTime,
User,
} from "../../../ChillMainBundle/Resources/public/types";
import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers";
import {DateTime, User} from "../../../ChillMainBundle/Resources/public/types";
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending";
export interface StoredObject {
id: number;
title: string | null;
uuid: string;
prefix: string;
status: StoredObjectStatus;
currentVersion:
| null
| StoredObjectVersionCreated
| StoredObjectVersionPersisted;
totalVersions: number;
datas: object;
id: number,
title: string|null,
uuid: string,
prefix: string,
status: StoredObjectStatus,
currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted,
totalVersions: number,
datas: object,
/** @deprecated */
creationDate: DateTime;
createdAt: DateTime | null;
createdBy: User | null;
creationDate: DateTime,
createdAt: DateTime|null,
createdBy: User|null,
_permissions: {
canEdit: boolean;
canSee: boolean;
};
canEdit: boolean,
canSee: boolean,
},
_links?: {
dav_link?: {
href: string;
expiration: number;
};
downloadLink?: SignedUrlGet;
};
href: string
expiration: number
},
},
}
export interface StoredObjectVersion {
/**
* filename of the object in the object storage
*/
filename: string;
iv: number[];
keyInfos: JsonWebKey;
type: string;
filename: string,
iv: number[],
keyInfos: JsonWebKey,
type: string,
}
export interface StoredObjectVersionCreated extends StoredObjectVersion {
persisted: false;
persisted: false,
}
export interface StoredObjectVersionPersisted
extends StoredObjectVersionCreated {
version: number;
id: number;
createdAt: DateTime | null;
createdBy: User | null;
export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated {
version: number,
id: number,
createdAt: DateTime|null,
createdBy: User|null,
}
export interface StoredObjectStatusChange {
id: number;
filename: string;
status: StoredObjectStatus;
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';
id: number,
filename: string,
status: StoredObjectStatus,
type: string,
}
/**
* Function executed by the WopiEditButton component.
*/
export type WopiEditButtonExecutableBeforeLeaveFunction = {
(): Promise<void>;
};
(): Promise<void>
}
/**
* Object containing information for performering a POST request to a swift object store
*/
export interface PostStoreObjectSignature {
method: "POST";
max_file_size: number;
max_file_count: 1;
expires: number;
submit_delay: 180;
redirect: string;
prefix: string;
url: string;
signature: string;
method: "POST",
max_file_size: number,
max_file_count: 1,
expires: number,
submit_delay: 180,
redirect: string,
prefix: string,
url: string,
signature: string,
}
export interface PDFPage {
index: number;
width: number;
height: number;
index: number,
width: number,
height: number,
}
export interface SignatureZone {
index: number | null;
x: number;
y: number;
width: number;
height: number;
PDFPage: PDFPage;
index: number,
x: number,
y: number,
width: number,
height: number,
PDFPage: PDFPage,
}
export interface Signature {
id: number;
storedObject: StoredObject;
zones: SignatureZone[];
id: number,
storedObject: StoredObject,
zones: SignatureZone[],
}
export type SignedState =
| "pending"
| "signed"
| "rejected"
| "canceled"
| "error";
export interface CheckSignature {
state: SignedState;
storedObject: StoredObject;
}
export type CanvasEvent = "select" | "add";
export interface ZoomLevel {
id: number;
zoom: number;
label: {
fr?: string,
nl?: string
};
}
export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error';

View File

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

View File

@@ -26,147 +26,12 @@
</template>
</modal>
</teleport>
<div class="col-12 m-auto">
<div class="row justify-content-center border-bottom pdf-tools d-md-none">
<div class="col text-center turn-page">
<select
class="form-select form-select-sm"
id="zoomSelect"
v-model="zoomLevel"
@change="setZoomLevel(zoomLevel)"
>
<option value="" selected disabled>Zoom</option>
<option v-for="z in zoomLevels" :value="z.zoom" :key="z.id">
{{ z.label.fr }}
</option>
</select>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }}/{{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</template>
</div>
<div
v-if="signature.zones.length > 1"
class="col-5 p-0 text-center turnSignature"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
<span>|</span>
<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" v-if="signedState !== 'signed'">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
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>
<button v-if="userSignatureZone === null"
:class="{ btn: true, 'btn-sm': true, 'btn-create': canvasEvent !== 'add', 'btn-chill-green': canvasEvent === 'add', active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
<template v-if="canvasEvent === 'add'">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
</button>
</div>
</div>
<div class="col-12">
<div
class="row justify-content-center border-bottom pdf-tools d-none d-md-flex"
class="row justify-content-center mb-2"
v-if="signature.zones.length > 1"
>
<div class="col-3 text-center turn-page ps-3">
<select
class="form-select form-select-sm"
id="zoomSelect"
v-model="zoomLevel"
@change="setZoomLevel(zoomLevel)"
>
<option value="" selected disabled>Zoom</option>
<option v-for="z in zoomLevels" :value="z.zoom" :key="z.id">
{{ z.label.fr }}
</option>
</select>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</template>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col-4 d-xl-none text-center turnSignature p-0"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
<span>|</span>
<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-4 d-none d-xl-flex p-0 text-center turnSignature"
>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@@ -174,7 +39,8 @@
>
{{ $t("last_sign_zone") }}
</button>
<span>|</span>
</div>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@@ -183,60 +49,39 @@
{{ $t("next_sign_zone") }}
</button>
</div>
<div class="col text-end" v-if="signedState !== 'signed'">
</div>
<div
id="turn-page"
class="row justify-content-center mb-2"
v-if="pageCount > 1"
>
<div class="col-6-sm col-3-md text-center">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
class="btn btn-light btn-sm"
:disabled="page <= 1"
@click="turnPage(-1)"
>
{{ $t("choose_another_signature") }}
</button>
<span>page {{ page }} / {{ pageCount }}</span>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
{{ $t("cancel") }}
</button>
<button v-if="userSignatureZone === null"
:class="{ btn: true, 'btn-sm': true, 'btn-create': canvasEvent !== 'add', 'btn-chill-green': canvasEvent === 'add', active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
<template v-if="canvasEvent !== 'add'">
{{ $t("add_zone") }}
</template>
<template v-else>
{{ $t("click_on_document")}}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
</button>
</div>
</div>
</div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center" :class="{onAddZone: canvasEvent === 'add'}">
<canvas class="m-auto" id="canvas" ></canvas>
<div class="col-12 text-center">
<canvas class="m-auto" id="canvas"></canvas>
</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="col d-flex">
<a
class="btn btn-cancel"
v-if="signedState !== 'signed'"
:href="getReturnPath()"
>
{{ $t("cancel") }}
</a>
<a class="btn btn-misc" v-else :href="getReturnPath()">
{{ $t("return") }}
</a>
</div>
<div class="col text-end" v-if="signedState !== 'signed'">
<div class="col-6">
<button
class="btn btn-action me-2"
:disabled="!userSignatureZone"
@@ -245,7 +90,27 @@
{{ $t("sign") }}
</button>
</div>
<div class="col-4" v-else></div>
<div class="col-6 d-flex justify-content-end">
<button
class="btn btn-misc me-2"
:hidden="!userSignatureZone"
@click="undoSign"
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") }}
</button>
</div>
</div>
</div>
</template>
@@ -254,14 +119,7 @@
import { ref, Ref, reactive } from "vue";
import { useToast } from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css";
import {
CanvasEvent,
CheckSignature,
Signature,
SignatureZone,
SignedState,
ZoomLevel,
} from "../../types";
import { Signature, SignatureZone, SignedState } from "../../types";
import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import * as pdfjsLib from "pdfjs-dist";
import {
@@ -277,64 +135,19 @@ console.log(PdfWorker); // incredible but this is needed
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {download_and_decrypt_doc, download_doc_as_pdf} from "../StoredObjectButton/helpers";
import {
download_and_decrypt_doc,
} from "../StoredObjectButton/helpers";
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
const modalOpen: 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 page: Ref<number> = ref(1);
const pageCount: Ref<number> = ref(0);
const zoom: Ref<number> = ref(1);
let zoomLevel = "";
const zoomLevels: Ref<ZoomLevel[]> = ref([
{
id: 0,
zoom: 0.75,
label: {
fr: "75%",
},
},
{
id: 1,
zoom: zoom.value,
label: {
fr: "100%",
},
},
{
id: 2,
zoom: 1.25,
label: {
fr: "125%",
},
},
{
id: 3,
zoom: 1.5,
label: {
fr: "150%",
},
},
{
id: 4,
zoom: 2,
label: {
fr: "200%",
},
},
{
id: 5,
zoom: 3,
label: {
fr: "300%",
},
},
]);
let userSignatureZone: Ref<null | SignatureZone> = ref(null);
let pdfSource: Ref<string> = ref("");
let pdf = {} as PDFDocumentProxy;
declare global {
@@ -347,21 +160,15 @@ const $toast = useToast();
const signature = window.signature;
const setZoomLevel = (zoomLevel: string) => {
zoom.value = Number.parseFloat(zoomLevel);
setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
};
const mountPdf = async (doc: ArrayBuffer) => {
const loadingTask = pdfjsLib.getDocument(doc);
const mountPdf = async (url: string) => {
const loadingTask = pdfjsLib.getDocument(url);
pdf = await loadingTask.promise;
pageCount.value = pdf.numPages;
await setPage(page.value);
await setPage(1);
};
const getRenderContext = (pdfPage: PDFPageProxy) => {
const scale = 1 * zoom.value;
const scale = 1;
const viewport = pdfPage.getViewport({ scale });
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
const context = canvas.getContext("2d") as CanvasRenderingContext2D;
@@ -380,59 +187,59 @@ const setPage = async (page: number) => {
await pdfPage.render(renderContext);
};
const init = () => downloadAndOpen().then(initPdf);
async function downloadAndOpen(): Promise<Blob> {
let raw;
try {
raw = await download_doc_as_pdf(signature.storedObject);
raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion);
} catch (e) {
console.error("error while downloading and decrypting document", e);
throw e;
}
const doc = await raw.arrayBuffer();
await mountPdf(doc);
await mountPdf(URL.createObjectURL(raw));
initPdf();
return raw;
}
const initPdf = () => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener("pointerup", canvasClick, false);
setTimeout(() => drawAllZones(page.value), 800);
canvas.addEventListener(
"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 = (
zone: SignatureZone,
xy: number[],
canvasWidth: number,
canvasHeight: number
) =>
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] &&
xy[0] <
scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) &&
zone.PDFPage.height * zoom.value -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) <
xy[1] &&
xy[1] <
scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) +
zone.PDFPage.height * zoom.value;
) => {
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));
return (
scaleXToCanvas(zone.x) < xy[0] &&
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) => {
userSignatureZone.value = z;
const ctx = canvas.getContext("2d");
if (ctx) {
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
.filter((z) => z.PDFPage.index + 1 === page.value)
.map((z) => {
@@ -449,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) => {
//userSignatureZone.value = null; // desactivate the reset of the zone when turning page
userSignatureZone.value = null;
page.value = page.value + upOrDown;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
setTimeout(() => addZones(page.value), 200);
};
const turnSignature = async (upOrDown: number) => {
@@ -490,6 +290,12 @@ const drawZone = (
) => {
const unselectedBlue = "#007bff";
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 =
userSignatureZone.value?.index === zone.index
? selectedBlue
@@ -497,56 +303,44 @@ const drawZone = (
ctx.lineWidth = 2;
ctx.lineJoin = "bevel";
ctx.strokeRect(
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width),
zone.PDFPage.height * zoom.value -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height),
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width),
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height)
scaleXToCanvas(zone.x),
scaleYToCanvas(zone.y),
scaleXToCanvas(zone.width),
scaleHeightToCanvas(zone.height)
);
ctx.font = `bold ${16 * zoom.value}px serif`;
ctx.font = "bold 16px serif";
ctx.textAlign = "center";
ctx.fillStyle = "black";
const xText =
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) +
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width) / 2;
const yText =
zone.PDFPage.height * zoom.value -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) +
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height) / 2;
const xText = scaleXToCanvas(zone.x) + scaleXToCanvas(zone.width) / 2;
const yText = scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height) / 2;
if (userSignatureZone.value?.index === zone.index) {
ctx.fillStyle = selectedBlue;
ctx.fillText("Signer ici", xText, yText);
} else {
ctx.fillStyle = unselectedBlue;
ctx.fillText("Choisir cette", xText, yText - 12 * zoom.value);
ctx.fillText("zone de signature", xText, yText + 12 * zoom.value);
ctx.fillText("Choisir cette", 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 ctx = canvas.getContext("2d");
if (ctx && signedState.value !== "signed") {
if (ctx) {
signature.zones
.filter((z) => z.PDFPage.index + 1 === page)
.map((z) => {
if (userSignatureZone.value) {
if (userSignatureZone.value?.index === z.index) {
drawZone(z, ctx, canvas.width, canvas.height);
}
} else {
drawZone(z, ctx, canvas.width, canvas.height);
}
});
.map((z) => drawZone(z, ctx, canvas.width, canvas.height));
}
};
const checkSignature = () => {
const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
return makeFetch<null, CheckSignature>("GET", url)
return makeFetch("GET", url)
.then((r) => {
signedState.value = r.state;
signature.storedObject = r.storedObject;
signedState.value = r as SignedState;
checkForReady();
})
.catch((error) => {
@@ -620,107 +414,33 @@ const confirmSign = () => {
};
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);
setTimeout(() => drawAllZones(page.value), 200);
setTimeout(() => addZones(page.value), 200);
userSignatureZone.value = null;
adding.value = false;
canvasEvent.value = "select";
};
const toggleAddZone = () => {
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();
downloadAndOpen();
</script>
<style scoped lang="scss">
#canvas {
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1);
}
.onAddZone {
cursor: not-allowed;
#canvas {
cursor: copy;
}
}
div#action-buttons {
position: sticky;
bottom: 0px;
background-color: white;
z-index: 100;
}
div.pdf-tools {
background-color: #f3f3f3;
font-size: 0.6rem;
button {
font-size: 0.75rem !important;
}
div.turnSignature {
span {
font-size: 1rem;
}
}
@media (min-width: 1400px) {
// background: none;
// border: none !important;
}
}
div.turn-page {
display: flex;
div#turn-page {
span {
font-size: 0.75rem;
margin: auto 0.4rem;
}
select {
width: 5rem;
font-size: 0.75rem;
font-size: 0.8rem;
margin: 0 0.4rem;
}
}
div.signature-modal-body {

View File

@@ -10,20 +10,13 @@ const appMessages = {
you_are_going_to_sign: 'Vous allez signer le document',
signature_confirmation: 'Confirmation de la signature',
sign: 'Signer',
choose_another_signature: 'Choisir une autre zone',
choose_another_signature: 'Choisir une autre zone de signature',
cancel: 'Annuler',
cancel_signing: 'Refuser de signer',
last_sign_zone: 'Zone de signature précédente',
next_sign_zone: 'Zone de signature suivante',
add_sign_zone: 'Ajouter une zone de signature',
click_on_document: 'Cliquer sur le document',
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...',
loading: 'Chargement...',
remove_sign_zone: 'Enlever la zone',
return: 'Retour',
loading: 'Chargement...'
}
}

View File

@@ -3,7 +3,6 @@
import {StoredObject, StoredObjectVersionCreated} from "../../types";
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
import {computed, ref, Ref} from "vue";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
interface DropFileConfig {
existingDoc?: StoredObject,
@@ -17,7 +16,6 @@ const emit = defineEmits<{
const is_dragging: Ref<boolean> = ref(false);
const uploading: Ref<boolean> = ref(false);
const display_filename: Ref<string|null> = ref(null);
const has_existing_doc = computed<boolean>(() => {
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> => {
uploading.value = true;
display_filename.value = file.name;
const type = file.type;
// create a stored_object if not exists
@@ -111,11 +108,18 @@ const handleFile = async (file: File): Promise<void> => {
<template>
<div class="drop-file">
<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">
<file-icon :type="props.existingDoc?.type"></file-icon>
<p v-if="has_existing_doc">
<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 v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
<!-- todo i18n -->
<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>
@@ -131,18 +135,9 @@ const handleFile = async (file: File): Promise<void> => {
.drop-file {
width: 100%;
.file-icon {
font-size: xx-large;
}
.display-filename {
font-variant: small-caps;
font-weight: 200;
}
& > .area, & > .waiting {
width: 100%;
height: 10rem;
height: 8rem;
display: flex;
flex-direction: column;
@@ -163,5 +158,4 @@ const handleFile = async (file: File): Promise<void> => {
}
}
}
</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>
<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>
Télécharger en pdf
</a>
@@ -9,7 +9,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive, ref} from "vue";
import {reactive} from "vue";
import {StoredObject} from "../../types";
interface ConvertButtonConfig {
@@ -24,7 +24,6 @@ interface DownloadButtonState {
const props = defineProps<ConvertButtonConfig>();
const state: DownloadButtonState = reactive({content: null});
const btn = ref<HTMLAnchorElement | null>(null);
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
@@ -42,14 +41,6 @@ async function download_and_open(event: Event): Promise<void> {
}
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>

View File

@@ -1,11 +1,11 @@
<template>
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open()" 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>
<template v-if="displayActionStringInButton">Télécharger</template>
Télécharger
</a>
<a v-else :class="props.classes" target="_blank" :type="props.atVersion.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>
<template v-if="displayActionStringInButton">Ouvrir</template>
Ouvrir
</a>
</template>
@@ -20,15 +20,6 @@ interface DownloadButtonConfig {
atVersion: StoredObjectVersion,
classes: { [k: string]: boolean },
filename?: string,
/**
* if true, display the action string into the button. If false, displays only
* the icon
*/
displayActionStringInButton?: boolean,
/**
* if true, will download directly the file on load
*/
directDownload?: boolean,
}
interface DownloadButtonState {
@@ -37,17 +28,13 @@ interface DownloadButtonState {
href_url: string,
}
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true, directDownload: false});
const props = defineProps<DownloadButtonConfig>();
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
const open_button = ref<HTMLAnchorElement | null>(null);
function buildDocumentName(): string {
let document_name = props.filename ?? props.storedObject.title;
if ('' === document_name) {
document_name = 'document';
}
const document_name = props.filename ?? props.storedObject.title ?? 'document';
const ext = mime.getExtension(props.atVersion.type);
@@ -58,7 +45,9 @@ function buildDocumentName(): string {
return document_name;
}
async function download_and_open(): Promise<void> {
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
if (state.is_running) {
console.log('state is running, aborting');
return;
@@ -85,33 +74,13 @@ async function download_and_open(): Promise<void> {
state.is_running = false;
state.is_ready = true;
if (!props.directDownload) {
await nextTick();
open_button.value?.click();
console.log('open button should have been clicked');
setTimeout(reset_state, 45000);
}
await nextTick();
open_button.value?.click();
}
function reset_state(): void {
state.href_url = '#';
state.is_ready = false;
state.is_running = false;
}
onMounted(() => {
if (props.directDownload) {
download_and_open();
}
});
</script>
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
i.fa {
margin-right: 0.5rem;
}
</style>

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,67 +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"
: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,115 +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;
}
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>(() => props.version.version > 0 && null !== props.version["from-restored"]);
const isDuplicated = computed<boolean>(() => props.version.version === 0 && 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 || isDuplicated">
<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 + 1 }}</span>
<span class="badge bg-info" v-if="isDuplicated">Dupliqué depuis un autre document</span>
</div>
<div class="col-12">
<file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <template v-if="version.createdBy !== null && version.createdAt !== null"><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') }}</template><template v-if="version.createdBy === null && version.createdAt !== null"><strong v-if="version.version == 0">Créé le</strong><strong v-else>modifié le</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template>
</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, 'btn-sm': 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,47 +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 = () => {
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

@@ -161,14 +161,7 @@ async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: n
throw new Error("no version associated to stored object");
}
// sometimes, the downloadInfo may be embedded into the storedObject
console.log('storedObject', storedObject);
let downloadInfo;
if (typeof storedObject._links !== 'undefined' && typeof storedObject._links.downloadLink !== 'undefined') {
downloadInfo = storedObject._links.downloadLink;
} else {
downloadInfo = await download_info_link(storedObject, atVersionToDownload);
}
const downloadInfo= await download_info_link(storedObject, atVersionToDownload);
const rawResponse = await window.fetch(downloadInfo.url);
@@ -197,32 +190,6 @@ async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: n
}
}
/**
* Fetch the stored object as a pdf.
*
* If the document is already in a pdf on the server side, the document is retrieved "as is" from the usual
* storage.
*/
async function download_doc_as_pdf(storedObject: StoredObject): Promise<Blob>
{
if (null === storedObject.currentVersion) {
throw new Error("the stored object does not count any version");
}
if (storedObject.currentVersion?.type === 'application/pdf') {
return download_and_decrypt_doc(storedObject, storedObject.currentVersion);
}
const convertLink = build_convert_link(storedObject.uuid);
const response = await fetch(convertLink);
if (!response.ok) {
throw new Error("Could not convert the document: " + response.status);
}
return response.blob();
}
async function is_object_ready(storedObject: StoredObject): Promise<StoredObjectStatusChange>
{
const new_status_response = await window
@@ -240,7 +207,6 @@ export {
build_wopi_editor_link,
download_and_decrypt_doc,
download_doc,
download_doc_as_pdf,
is_extension_editable,
is_extension_viewable,
is_object_ready,

View File

@@ -1 +0,0 @@
<div data-download-button-single="data-download-button-single" data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-title="{{ title|escape('html_attr') }}"></div>

View File

@@ -8,7 +8,7 @@
<table class="table table-bordered border-dark align-middle">
<thead>
<tr>
{# <th>{{ 'Creator bundle id' | trans }}</th>#}
<th>{{ 'Creator bundle id' | trans }}</th>
<th>{{ 'Internal id inside creator bundle' | trans }}</th>
<th>{{ 'Document class' | trans }}</th>
<th>{{ 'Name' | trans }}</th>
@@ -18,7 +18,7 @@
<tbody>
{% for document_category in document_categories %}
<tr>
{# <td>{{ document_category.bundleId }}</td>#}
<td>{{ document_category.bundleId }}</td>
<td>{{ document_category.idInsideBundle }}</td>
<td>{{ document_category.documentClass }}</td>
<td>{{ document_category.name | localize_translatable_string}}</td>

View File

@@ -73,15 +73,8 @@
<li>
{{ document.object|chill_document_button_group(document.title) }}
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', document.course) %}
<li>
<a href="{{ chill_path_add_return_path('chill_doc_store_accompanying_course_document_duplicate', {'id': document.id}) }}" class="btn btn-duplicate" title="{{ 'Duplicate'|trans|e('html_attr') }}"></a>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
@@ -89,9 +82,9 @@
<a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% else %}

View File

@@ -1,43 +0,0 @@
{% extends '@ChillMain/Workflow/workflow_view_send_public_layout.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_document_download_button') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_document_download_button') }}
{% endblock %}
{% block title %}{{ 'workflow.public_link.title'|trans }} - {{ title }}{% endblock %}
{% block public_content %}
<h1>{{ 'workflow.public_link.shared_doc'|trans }}</h1>
{% set previous = send.entityWorkflowStepChained.previous %}
{% if previous is not null %}
{% if previous.transitionBy is not null %}
<p>{{ 'workflow.public_link.doc_shared_by_at_explanation'|trans({'byUser': previous.transitionBy|chill_entity_render_string( { 'at_date': previous.transitionAt } ), 'at': previous.transitionAt }) }}</p>
{% else %}
<p>{{ 'workflow.public_link.doc_shared_automatically_at_explanation'|trans({'at': previous.transitionAt}) }}</p>
{% endif %}
{% endif %}
<div class="row">
<div class="col-xs-12 col-sm-6 col-md-4">
<div class="card"">
<div class="card-body">
<h2 class="card-title">{{ title }}</h2>
<h3>{{ 'workflow.public_link.main_document'|trans }}</h3>
<ul class="record_actions slim small">
<li>
{{ storedObject|chill_document_download_only_button(storedObject.title(), false) }}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -15,7 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
@@ -34,7 +34,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function __construct(
private readonly Security $security,
private readonly ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null,
) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
@@ -46,27 +46,24 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
// Retrieve the related entity
// Retrieve the related accompanying course document
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
// Determine the attribute to pass to the voter for argument
// Determine the attribute to pass to AccompanyingCourseDocumentVoter
$voterAttribute = $this->attributeToRole($attribute);
$regularPermission = $this->security->isGranted($voterAttribute, $entity);
if (!$this->canBeAssociatedWithWorkflow()) {
return $regularPermission;
if (false === $this->security->isGranted($voterAttribute, $entity)) {
return false;
}
$workflowPermission = match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($entity),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($entity),
};
if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) {
if (null === $this->workflowDocumentService) {
throw new \LogicException('Provide a workflow document service');
}
return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission,
};
return $this->workflowDocumentService->notBlockedByWorkflow($entity);
}
return true;
}
}

View File

@@ -16,7 +16,7 @@ use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter
@@ -24,7 +24,7 @@ final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredOb
public function __construct(
private readonly AccompanyingCourseDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
}

View File

@@ -16,7 +16,7 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
@@ -24,7 +24,7 @@ class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
public function __construct(
private readonly PersonDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
}

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
@@ -31,17 +30,10 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
{
use NormalizerAwareTrait;
/**
* when added to the groups, a download link is included in the normalization,
* and no webdav links are generated.
*/
public const DOWNLOAD_LINK_ONLY = 'read:download-link-only';
public function __construct(
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
) {}
public function normalize($object, ?string $format = null, array $context = [])
@@ -63,24 +55,6 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
// deprecated property
$datas['creationDate'] = $datas['createdAt'];
if (array_key_exists(AbstractNormalizer::GROUPS, $context)) {
$groupsNormalized = is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]];
} else {
$groupsNormalized = [];
}
if (in_array(self::DOWNLOAD_LINK_ONLY, $groupsNormalized, true)) {
$datas['_permissions'] = [
'canSee' => true,
'canEdit' => false,
];
$datas['_links'] = [
'downloadLink' => $this->normalizer->normalize($this->tempUrlGenerator->generate('GET', $object->getCurrentVersion()->getFilename(), 180), $format, [AbstractNormalizer::GROUPS => ['read']]),
];
return $datas;
}
$canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);

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;
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\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -22,17 +20,13 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw
{
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 = [])
{
if (!$object instanceof StoredObjectVersion) {
throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class);
}
$data = [
return [
'id' => $object->getId(),
'filename' => $object->getFilename(),
'version' => $object->getVersion(),
@@ -40,18 +34,8 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw
'keyInfos' => $object->getKeyInfos(),
'type' => $object->getType(),
'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 = [])

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,30 +17,15 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Contracts\Translation\LocaleAwareInterface;
class PDFSignatureZoneAvailable implements LocaleAwareInterface
class PDFSignatureZoneAvailable
{
private string $locale;
public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly PDFSignatureZoneParser $pdfSignatureZoneParser,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly WopiConverter $converter,
) {}
public function setLocale(string $locale)
{
$this->locale = $locale;
}
public function getLocale()
{
return $this->locale;
}
/**
* @return list<PDFSignatureZone>
*/
@@ -53,16 +38,10 @@ class PDFSignatureZoneAvailable implements LocaleAwareInterface
}
if ('application/pdf' !== $storedObject->getType()) {
$content = $this->converter->convert($this->getLocale(), $this->storedObjectManager->read($storedObject), $storedObject->getType());
} else {
$content = $this->storedObjectManager->read($storedObject);
throw new \RuntimeException('Only PDF documents are supported');
}
$zones = $this->pdfSignatureZoneParser->findSignatureZones($content);
// free some memory as soon as possible...
unset($content);
$zones = $this->pdfSignatureZoneParser->findSignatureZones($this->storedObjectManager->read($storedObject));
$signatureZonesIndexes = array_map(
fn (EntityWorkflowStepSignature $step) => $step->getZoneSignatureIndex(),
$this->collectSignaturesInUse($entityWorkflow)

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\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
/**
* Class which duplicate a stored object into a new one, recreating a stored object.
*/
class StoredObjectDuplicate
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
public function duplicate(StoredObject|StoredObjectVersion $from, bool $onlyLastKeptBeforeConversionVersion = true): StoredObject
{
$storedObject = $from instanceof StoredObjectVersion ? $from->getStoredObject() : $from;
$fromVersion = match ($storedObject->hasKeptBeforeConversionVersion() && $onlyLastKeptBeforeConversionVersion) {
true => $from->getLastKeptBeforeConversionVersion(),
false => $storedObject->getCurrentVersion(),
};
if (null === $fromVersion) {
throw new \UnexpectedValueException('could not find a version to restore');
}
$oldContent = $this->storedObjectManager->read($fromVersion);
$storedObject = new StoredObject();
$newVersion = $this->storedObjectManager->write($storedObject, $oldContent, $fromVersion->getType());
$newVersion->setCreatedFrom($fromVersion);
$this->logger->info('[StoredObjectDuplicate] Duplicated stored object from a version of a previous stored object', [
'from_stored_object_uuid' => $fromVersion->getStoredObject()->getUuid(),
'to_stored_object_uuid' => $storedObject->getUuid(),
'old_version_id' => $fromVersion->getId(),
'old_version_version' => $fromVersion->getVersion(),
'new_version_id' => $newVersion->getVersion(),
]);
return $storedObject;
}
}

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\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
/**
* Class responsible for restoring stored object versions into the same stored object.
*/
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

@@ -39,13 +39,13 @@ class StoredObjectToPdfConverter
* @param string $lang the language for the conversion context
* @param string $convertTo The target format for the conversion. Default is 'pdf'.
*
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion} contains the point in time before conversion and the new version of the stored object
*
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
* @throws \RuntimeException if the conversion or storage of the new version fails
* @throws StoredObjectManagerException
*/
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf'): array
{
$newMimeType = $this->mimeTypes->getMimeTypes($convertTo)[0] ?? null;
@@ -70,11 +70,6 @@ class StoredObjectToPdfConverter
$pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType);
if (!$includeConvertedContent) {
return [$pointInTime, $version];
}
return [$pointInTime, $version, $converted];
return [$pointInTime, $version];
}
}

View File

@@ -0,0 +1,38 @@
<?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\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Security\Core\Security;
class WorkflowStoredObjectPermissionHelper
{
public function __construct(private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager) {}
public function notBlockedByWorkflow(object $entity): bool
{
$workflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$currentUser = $this->security->getUser();
foreach ($workflows as $workflow) {
if ($workflow->isFinal()) {
return false;
}
if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) {
return false;
}
}
return true;
}
}

View File

@@ -28,10 +28,6 @@ class WopiEditTwigExtension extends AbstractExtension
'needs_environment' => true,
'is_safe' => ['html'],
]),
new TwigFilter('chill_document_download_only_button', [WopiEditTwigExtensionRuntime::class, 'renderDownloadButton'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}

View File

@@ -15,7 +15,6 @@ use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
@@ -178,17 +177,6 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
]);
}
public function renderDownloadButton(Environment $environment, StoredObject $storedObject, string $title = ''): string
{
return $environment->render(
'@ChillDocStore/Button/button_download.html.twig',
[
'document_json' => $this->normalizer->normalize($storedObject, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]]),
'title' => $title,
]
);
}
public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string
{
return $environment->render(self::TEMPLATE, [

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

@@ -12,8 +12,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
@@ -56,27 +54,4 @@ class StoredObjectTest extends KernelTestCase
self::assertNotSame($firstVersion, $version);
}
public function testHasKeptBeforeConversionVersion(): void
{
$storedObject = new StoredObject();
$version1 = $storedObject->registerVersion();
self::assertFalse($storedObject->hasKeptBeforeConversionVersion());
// add a point in time without the correct version
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BY_USER);
self::assertFalse($storedObject->hasKeptBeforeConversionVersion());
self::assertNull($storedObject->getLastKeptBeforeConversionVersion());
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
self::assertTrue($storedObject->hasKeptBeforeConversionVersion());
// add a second version
$version2 = $storedObject->registerVersion();
new StoredObjectPointInTime($version2, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
self::assertSame($version2, $storedObject->getLastKeptBeforeConversionVersion());
}
}

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Form;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
@@ -133,8 +132,7 @@ class StoredObjectTypeTest extends TypeTestCase
new StoredObjectNormalizer(
$jwtTokenProvider->reveal(),
$urlGenerator->reveal(),
$security->reveal(),
$this->createMock(TempUrlGeneratorInterface::class)
$security->reveal()
),
new StoredObjectDenormalizer($storedObjectRepository->reveal()),
new StoredObjectVersionNormalizer(),

View File

@@ -15,11 +15,10 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
/**
@@ -29,21 +28,26 @@ use Symfony\Component\Security\Core\Security;
*/
class AbstractStoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
private AssociatedEntityToStoredObjectInterface $repository;
private Security $security;
private WorkflowStoredObjectPermissionHelper $workflowDocumentService;
private function buildStoredObjectVoter(
bool $canBeAssociatedWithWorkflow,
AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
): AbstractStoredObjectVoter {
protected function setUp(): void
{
$this->repository = $this->createMock(AssociatedEntityToStoredObjectInterface::class);
$this->security = $this->createMock(Security::class);
$this->workflowDocumentService = $this->createMock(WorkflowStoredObjectPermissionHelper::class);
}
private function buildStoredObjectVoter(bool $canBeAssociatedWithWorkflow, AssociatedEntityToStoredObjectInterface $repository, Security $security, ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null): AbstractStoredObjectVoter
{
// Anonymous class extending the abstract class
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter {
public function __construct(
private readonly bool $canBeAssociatedWithWorkflow,
private readonly AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null,
) {
parent::__construct($security, $workflowDocumentService);
}
@@ -70,89 +74,95 @@ class AbstractStoredObjectVoterTest extends TestCase
};
}
private function setupMockObjects(): array
{
$user = new User();
$token = $this->createMock(TokenInterface::class);
$subject = new StoredObject();
$entity = new \stdClass();
return [$user, $token, $subject, $entity];
}
private function setupMocksForVoteOnAttribute(User $user, TokenInterface $token, bool $isGrantedForEntity, object $entity, bool $workflowAllowed): void
{
// Set up token to return user
$token->method('getUser')->willReturn($user);
// Mock the return of an AccompanyingCourseDocument by the repository
$this->repository->method('findAssociatedEntityToStoredObject')->willReturn($entity);
// Mock scenario where user is allowed to see_details of the AccompanyingCourseDocument
$this->security->method('isGranted')->willReturn($isGrantedForEntity);
// Mock case where user is blocked or not by workflow
$this->workflowDocumentService->method('notBlockedByWorkflow')->willReturn($workflowAllowed);
}
public function testSupportsOnAttribute(): void
{
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), null);
[$user, $token, $subject, $entity] = $this->setupMockObjects();
self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), null);
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), null);
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, $subject));
}
/**
* @dataProvider dataProviderVoteOnAttribute
*/
public function testVoteOnAttribute(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $canBeAssociatedWithWorkflow,
bool $isGrantedRegularPermission,
?string $isGrantedWorkflowPermissionRead,
?string $isGrantedWorkflowPermissionWrite,
string $message,
): void {
$storedObject = new StoredObject();
$dummyRepository = new DummyRepository($related = new \stdClass());
$token = new UsernamePasswordToken(new User(), 'dummy');
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
} else {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)->shouldNotBeCalled();
}
if (null !== $isGrantedWorkflowPermissionWrite) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($isGrantedWorkflowPermissionWrite)->shouldBeCalled();
} else {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)->shouldNotBeCalled();
}
$voter = $this->buildStoredObjectVoter($canBeAssociatedWithWorkflow, $dummyRepository, $security->reveal(), $workflowRelatedEntityPermissionHelper->reveal());
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderVoteOnAttribute(): iterable
public function testVoteOnAttributeAllowedAndWorkflowAllowed(): void
{
// not associated on a workflow
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];
yield [StoredObjectRoleEnum::SEE, false, false, false, null, null, 'not associated on a workflow, denied by regular access, must not rely on helper'];
[$user, $token, $subject, $entity] = $this->setupMockObjects();
// associated on a workflow, read operation
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied'];
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// association on a workflow, write operation
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied'];
}
}
class DummyRepository implements AssociatedEntityToStoredObjectInterface
{
public function __construct(private readonly ?object $relatedEntity) {}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
return $this->relatedEntity;
// The voteOnAttribute method should return True when workflow is allowed
self::assertTrue($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token));
}
public function testVoteOnAttributeNotAllowed(): void
{
[$user, $token, $subject, $entity] = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method where isGranted() returns false
$this->setupMocksForVoteOnAttribute($user, $token, false, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// The voteOnAttribute method should return True when workflow is allowed
self::assertFalse($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token));
}
public function testVoteOnAttributeAllowedWorkflowNotAllowed(): void
{
[$user, $token, $subject, $entity] = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, false);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// Test voteOnAttribute method
$attribute = StoredObjectRoleEnum::EDIT;
$result = $voter->voteOnAttribute($attribute, $subject, $token);
// Assert that access is denied when workflow is not allowed
$this->assertFalse($result);
}
public function testVoteOnAttributeAllowedWorkflowAllowedToSeeDocument(): void
{
[$user, $token, $subject, $entity] = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, false);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// Test voteOnAttribute method
$attribute = StoredObjectRoleEnum::SEE;
$result = $voter->voteOnAttribute($attribute, $subject, $token);
// Assert that access is denied when workflow is not allowed
$this->assertTrue($result);
}
}

View File

@@ -11,8 +11,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
@@ -72,9 +70,7 @@ class StoredObjectNormalizerTest extends TestCase
return ['sub' => 'sub'];
});
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security);
$normalizer->setNormalizer($globalNormalizer);
$actual = $normalizer->normalize($storedObject, 'json');
@@ -99,48 +95,4 @@ class StoredObjectNormalizerTest extends TestCase
self::assertArrayHasKey('dav_link', $actual['_links']);
self::assertEqualsCanonicalizing(['href' => $davLink, 'expiration' => $d->getTimestamp()], $actual['_links']['dav_link']);
}
public function testWithDownloadLinkOnly(): void
{
$storedObject = new StoredObject();
$storedObject->registerVersion();
$storedObject->setTitle('test');
$reflection = new \ReflectionClass(StoredObject::class);
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($storedObject, 1);
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
$jwtProvider->expects($this->never())->method('createToken')->withAnyParameters();
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(Security::class);
$security->expects($this->never())->method('isGranted');
$globalNormalizer = $this->createMock(NormalizerInterface::class);
$globalNormalizer->expects($this->exactly(4))->method('normalize')
->withAnyParameters()
->willReturnCallback(function (?object $object, string $format, array $context) {
if (null === $object) {
return null;
}
return ['sub' => 'sub'];
});
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$tempUrlGenerator->expects($this->once())->method('generate')->with('GET', $storedObject->getCurrentVersion()->getFilename(), $this->isType('int'))
->willReturn(new SignedUrl('GET', 'https://some-link/test', new \DateTimeImmutable('300 seconds'), $storedObject->getCurrentVersion()->getFilename()));
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer->setNormalizer($globalNormalizer);
$actual = $normalizer->normalize($storedObject, 'json', ['groups' => ['read', 'read:download-link-only']]);
self::assertIsArray($actual);
self::assertArrayHasKey('_links', $actual);
self::assertArrayHasKey('downloadLink', $actual['_links']);
self::assertEquals(['sub' => 'sub'], $actual['_links']['downloadLink']);
}
}

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

View File

@@ -22,7 +22,6 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use Chill\WopiBundle\Service\WopiConverter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
@@ -66,7 +65,6 @@ class PDFSignatureZoneAvailableTest extends TestCase
$entityWorkflowManager->reveal(),
$parser->reveal(),
$storedObjectManager->reveal(),
$this->prophesize(WopiConverter::class)->reveal(),
);
$actual = $filter->getAvailableSignatureZones($entityWorkflow);

View File

@@ -1,130 +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\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectDuplicateTest extends TestCase
{
use ProphecyTrait;
public function testDuplicateHappyScenario(): void
{
$storedObject = new StoredObject();
// we create multiple version, we want the last to be duplicated
$storedObject->registerVersion(type: 'application/test');
$version = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->createMock(StoredObjectManagerInterface::class);
$manager->method('read')->with($version)->willReturn('1234');
$manager
->expects($this->once())
->method('write')
->with($this->isInstanceOf(StoredObject::class), '1234', 'application/test')
->willReturnCallback(fn (StoredObject $so, $content, $type) => $so->registerVersion(type: $type));
$storedObjectDuplicate = new StoredObjectDuplicate($manager, new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version, $actual->getCurrentVersion()->getCreatedFrom());
}
public function testDuplicateWithKeptVersion(): void
{
$storedObject = new StoredObject();
// we create two versions for stored object
// the first one is "kept before conversion", and that one should
// be duplicated, not the second one
$version1 = $storedObject->registerVersion(type: $type = 'application/test');
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version2 = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->prophesize(StoredObjectManagerInterface::class);
// we create both possibilities for the method "read"
$manager->read($version1)->willReturn('1234');
$manager->read($version2)->willReturn('4567');
// we create the write method, and check that it is called with the content from version1, not version2
$manager->write(Argument::type(StoredObject::class), '1234', 'application/test')
->shouldBeCalled()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0]; // args are ordered by key, so the first one is the stored object...
$type = $args[2]; // and the last one is the string $type
return $storedObject->registerVersion(type: $type);
});
// we create the service which will duplicate things
$storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version1, $actual->getCurrentVersion()->getCreatedFrom());
}
public function testDuplicateWithKeptVersionButWeWantToDuplicateTheLastOne(): void
{
$storedObject = new StoredObject();
// we create two versions for stored object
// the first one is "kept before conversion", and that one should
// be duplicated, not the second one
$version1 = $storedObject->registerVersion(type: $type = 'application/test');
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version2 = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->prophesize(StoredObjectManagerInterface::class);
// we create both possibilities for the method "read"
$manager->read($version1)->willReturn('1234');
$manager->read($version2)->willReturn('4567');
// we create the write method, and check that it is called with the content from version1, not version2
$manager->write(Argument::type(StoredObject::class), '4567', 'application/test')
->shouldBeCalled()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0]; // args are ordered by key, so the first one is the stored object...
$type = $args[2]; // and the last one is the string $type
return $storedObject->registerVersion(type: $type);
});
// we create the service which will duplicate things
$storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject, false);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version2, $actual->getCurrentVersion()->getCreatedFrom());
}
}

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

@@ -1,70 +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\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\DocumentCategory;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class AccompanyingCourseDocumentDuplicatorTest extends TestCase
{
public function testDuplicate(): void
{
$object = new StoredObject();
$document = new AccompanyingCourseDocument();
$document
->setDate($date = new \DateTimeImmutable())
->setObject($object)
->setTitle('Title')
->setUser($user = new User())
->setCategory($category = new DocumentCategory('bundle', 10))
->setDescription($description = 'Description');
$actual = $this->buildDuplicator()->duplicate($document);
self::assertSame($date, $actual->getDate());
// FYI, the duplication of object is checked by the mock
self::assertNotNull($actual->getObject());
self::assertStringStartsWith('Title', $actual->getTitle());
self::assertSame($user, $actual->getUser());
self::assertSame($category, $actual->getCategory());
self::assertEquals($description, $actual->getDescription());
}
private function buildDuplicator(): AccompanyingCourseDocumentDuplicator
{
$storedObjectDuplicate = $this->createMock(StoredObjectDuplicate::class);
$storedObjectDuplicate->expects($this->once())->method('duplicate')
->with($this->isInstanceOf(StoredObject::class))->willReturn(new StoredObject());
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->withAnyParameters()->willReturn('duplicated');
$clock = new MockClock();
return new AccompanyingCourseDocumentDuplicator(
$storedObjectDuplicate,
$translator,
$clock
);
}
}

View File

@@ -1,94 +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\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentWorkflowHandler;
use Chill\DocStoreBundle\Workflow\WorkflowWithPublicViewDocumentHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvidePersonsAssociated;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvideThirdPartiesAssociated;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class AccompanyingCourseDocumentWorkflowHandlerTest extends TestCase
{
use ProphecyTrait;
public function testGetSuggestedUsers()
{
$accompanyingPeriod = new AccompanyingPeriod();
$document = new AccompanyingCourseDocument();
$document->setCourse($accompanyingPeriod)->setUser($user1 = new User());
$accompanyingPeriod->setUser($user = new User());
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setRelatedEntityId(1);
$handler = new AccompanyingCourseDocumentWorkflowHandler(
$this->prophesize(TranslatorInterface::class)->reveal(),
$this->prophesize(EntityWorkflowRepository::class)->reveal(),
$this->buildRepository($document, 1),
new WorkflowWithPublicViewDocumentHelper($this->prophesize(Environment::class)->reveal()),
$this->prophesize(ProvideThirdPartiesAssociated::class)->reveal(),
$this->prophesize(ProvidePersonsAssociated::class)->reveal(),
);
$users = $handler->getSuggestedUsers($entityWorkflow);
self::assertCount(2, $users);
self::assertContains($user, $users);
self::assertContains($user1, $users);
}
public function testGetSuggestedUsersWithDuplicates()
{
$accompanyingPeriod = new AccompanyingPeriod();
$document = new AccompanyingCourseDocument();
$document->setCourse($accompanyingPeriod)->setUser($user1 = new User());
$accompanyingPeriod->setUser($user1);
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setRelatedEntityId(1);
$handler = new AccompanyingCourseDocumentWorkflowHandler(
$this->prophesize(TranslatorInterface::class)->reveal(),
$this->prophesize(EntityWorkflowRepository::class)->reveal(),
$this->buildRepository($document, 1),
new WorkflowWithPublicViewDocumentHelper($this->prophesize(Environment::class)->reveal()),
$this->prophesize(ProvideThirdPartiesAssociated::class)->reveal(),
$this->prophesize(ProvidePersonsAssociated::class)->reveal(),
);
$users = $handler->getSuggestedUsers($entityWorkflow);
self::assertCount(1, $users);
self::assertContains($user1, $users);
}
private function buildRepository(AccompanyingCourseDocument $document, int $id): AccompanyingCourseDocumentRepository
{
$repository = $this->prophesize(AccompanyingCourseDocumentRepository::class);
$repository->find($id)->willReturn($document);
return $repository->reveal();
}
}

View File

@@ -0,0 +1,208 @@
<?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\Workflow;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\DocStoreBundle\Workflow\ConvertToPdfBeforeSignatureStepEventSubscriber;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
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 ConvertToPdfBeforeSignatureStepEventSubscriberTest extends \PHPUnit\Framework\TestCase
{
use ProphecyTrait;
public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignature(): void
{
$entityWorkflow = new EntityWorkflow();
$storedObject = new StoredObject();
$previousVersion = $storedObject->registerVersion();
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($storedObject, 'fr', 'pdf')
->shouldBeCalledOnce()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0];
$pointInTime = new StoredObjectPointInTime($storedObject->getCurrentVersion(), StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$newVersion = $storedObject->registerVersion(filename: 'next');
return [$pointInTime, $newVersion];
});
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('signature', $entityWorkflow->getStep());
self::assertNotSame($previousVersion, $storedObject->getCurrentVersion());
self::assertTrue($previousVersion->hasPointInTimes());
self::assertCount(2, $storedObject->getVersions());
self::assertEquals('next', $storedObject->getCurrentVersion()->getFilename());
}
public function testConvertToPdfBeforeSignatureStepEventSubscriberToNotASignatureStep(): void
{
$entityWorkflow = new EntityWorkflow();
$storedObject = new StoredObject();
$previousVersion = $storedObject->registerVersion();
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($storedObject, 'fr', 'pdf')
->shouldNotBeCalled();
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_something', ['context' => $dto, 'transition' => 'to_something', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('something', $entityWorkflow->getStep());
self::assertSame($previousVersion, $storedObject->getCurrentVersion());
self::assertFalse($previousVersion->hasPointInTimes());
self::assertCount(1, $storedObject->getVersions());
}
public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureAlreadyAPdf(): void
{
$entityWorkflow = new EntityWorkflow();
$storedObject = new StoredObject();
$previousVersion = $storedObject->registerVersion(type: 'application/pdf');
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($storedObject, 'fr', 'pdf')
->shouldNotBeCalled();
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('signature', $entityWorkflow->getStep());
self::assertSame($previousVersion, $storedObject->getCurrentVersion());
self::assertFalse($previousVersion->hasPointInTimes());
self::assertCount(1, $storedObject->getVersions());
}
public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureWithNoStoredObject(): void
{
$entityWorkflow = new EntityWorkflow();
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion(Argument::type(StoredObject::class), 'fr', 'pdf')
->shouldNotBeCalled();
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn(null);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('signature', $entityWorkflow->getStep());
}
private function buildRegistry(EventSubscriberInterface $eventSubscriber): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces('initial')
->addPlaces(['initial', 'signature', 'something'])
->addTransition(new Transition('to_something', 'initial', 'something'))
->addTransition(new Transition('to_signature', 'initial', 'signature'));
$metadataStore = new InMemoryMetadataStore([], ['signature' => ['isSignature' => ['user']]]);
$builder->setMetadataStore($metadataStore);
$eventDispatcher = new EventDispatcher();
$eventDispatcher->addSubscriber($eventSubscriber);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher, 'dummy');
$supports = new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
};
$registry = new Registry();
$registry->addWorkflow($workflow, $supports);
return $registry;
}
}

View File

@@ -1,45 +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\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Stores the logic to duplicate an AccompanyingCourseDocument associated to a workflow.
*/
class AccompanyingCourseDocumentDuplicator
{
public function __construct(
private readonly StoredObjectDuplicate $storedObjectDuplicate,
private readonly TranslatorInterface $translator,
private readonly ClockInterface $clock,
) {}
public function duplicate(AccompanyingCourseDocument $document): AccompanyingCourseDocument
{
$newDoc = new AccompanyingCourseDocument();
$newDoc
->setCourse($document->getCourse())
->setTitle($document->getTitle().' ('.$this->translator->trans('acc_course_document.duplicated_at', ['at' => $this->clock->now()]).')')
->setDate($document->getDate())
->setDescription($document->getDescription())
->setCategory($document->getCategory())
->setUser($document->getUser())
->setObject($this->storedObjectDuplicate->duplicate($document->getObject()))
;
return $newDoc;
}
}

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