Compare commits

..

19 Commits

Author SHA1 Message Date
1961bf36e2 Changie added 2024-11-18 15:06:46 +01:00
52f06e2142 Translator dependencies added to chill-bundles 2024-11-18 14:57:13 +01:00
6ded75119c Add developers documentation for the correct management of translation keys 2024-11-18 13:04:27 +01:00
89fa10cede Change namespace for detect translation duplicate command 2024-11-18 13:03:47 +01:00
b0b1a28f50 Implement trans() in activity vue components 2024-10-08 13:51:41 +02:00
760f74b386 Add translation keys from i18ns to yaml files 2024-10-08 13:25:09 +02:00
d82a3af063 Adjust more translations in mainbundle create component 2024-10-01 20:05:04 +02:00
7edd644963 wip: use script setup and new translator within vue components main bundle 2024-09-25 10:13:04 +02:00
077163a774 Transfer translations from i18n file to yaml file 2024-09-25 10:11:35 +02:00
9c963a2122 Implement translator in vue components and use script setup syntax 2024-09-24 11:04:17 +02:00
c1c632dcb0 Refactor use of translation key in vue template slightly 2024-09-20 14:02:36 +02:00
9732b80298 Implement translation with Symfony translator within vue component 2024-09-20 13:58:23 +02:00
c602c27b39 Remove empty translation key, causing errors 2024-09-20 13:57:56 +02:00
41f13e29e0 Expand detection of duplicates in translations command
Added functionality to specify key namespaces that should be exluded from duplicate detection
Added functionality to check the accidental creation of duplicates based on an md5 hash of translation duplicates.
2024-09-20 12:16:31 +02:00
611261c863 Example usage of translation keys from yaml files within vue component 2024-09-17 16:59:50 +02:00
5921404712 Remove unutilized script 2024-09-17 12:50:32 +02:00
23d882d4cd Improve script to find translation duplicates
Format the output using a table.
Use existing translator.default service to fetch translation files and extract keys and translations from the translation catalogue for a certain locale.
2024-09-17 12:49:41 +02:00
155066be13 Remove irrelevant command 2024-08-19 16:48:03 +02:00
a5329c5d69 Create commands to manage translations 2024-08-19 16:11:18 +02:00
172 changed files with 1752 additions and 5297 deletions

View File

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

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

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

View File

@@ -0,0 +1,7 @@
kind: Feature
body: "Implementation of new translation management with one source of truth for both
twig and vue component templates using YAML files. \nDuplicate translation keys
can also be detected with new command."
time: 2024-11-18T15:06:27.929549251+01:00
custom:
Issue: ""

View File

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

View File

@@ -138,4 +138,4 @@ release:
- echo "running release_job"
release:
tag_name: '$CI_COMMIT_TAG'
description: "./.changes/$CI_COMMIT_TAG.md"
description: "./.changes/v$CI_COMMIT_TAG.md"

View File

@@ -6,12 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## 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.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

View File

@@ -31,7 +31,6 @@
"phpoffice/phpspreadsheet": "^1.16",
"ramsey/uuid-doctrine": "^1.7",
"sensio/framework-extra-bundle": "^5.5",
"smalot/pdfparser": "^2.10",
"spomky-labs/base64url": "^2.0",
"symfony/asset": "^5.4",
"symfony/browser-kit": "^5.4",
@@ -66,10 +65,12 @@
"symfony/security-guard": "^5.4",
"symfony/security-http": "^5.4",
"symfony/serializer": "^5.4",
"symfony/stimulus-bundle": "^2.19",
"symfony/string": "^5.4",
"symfony/templating": "^5.4",
"symfony/translation": "^5.4",
"symfony/twig-bundle": "^5.4",
"symfony/ux-translator": "^2.19",
"symfony/validator": "^5.4",
"symfony/webpack-encore-bundle": "^1.11",
"symfony/workflow": "^5.4",

View File

@@ -0,0 +1,31 @@
Translations
*************
Translator-UX: one source of truth
==================================
The Translator-ux integration streamlines the process of managing and using translation keys dynamically in our views, whether they be in twig or vue components. The goal is to have one source of truth
for all translations and avoid having to add translation keys in the YAML files as well as in i18ns files.
To add new translation keys, you can define them in your translation YAML files. Running `symfony console cache:clear` will subsequently update the compiled translation keys which can then also be imported and
used within any vue component. For use within a twig template they can be leveraged by using the |trans function.
Within vue components you will have to import the translation keys you require and then they can be used in the template with the trans() function.
It is advisable, before adding a translation key to do a search on the existing translation keys of the translation you require. An IDE will allow you to do so easily.
However to avoid the creation of duplicate translation keys a command also exists to detect them. We also strongly advise you to use this command as explained below.
Detect duplicates command
=========================
The DetectTranslationDuplicatesCommand `chill:detect-duplicate-translations` is a Symfony console command designed to identify duplicate translations across YAML files in a project.
It checks for repeated translation values linked to different keys within a specified locale.
The command accepts two main options:
1. `--locale`: to specify the language locale to check (defaulting to 'en')
2. `--exclude-namespaces`: to list namespaces to ignore during the check.
3. [optional] `--verify-hash`: can be used to ensure that the hash of current duplicates matches a given expected value,
aiding in maintaining translation integrity.
When duplicates are detected, they are displayed in a table format, listing the repeated translations alongside the keys where they are found.
If a mismatch occurs between the computed and expected hash values, an error message is displayed to signal a potential issue in translation consistency.
This command is useful for maintaining clean and consistent translations, avoiding redundancy in your YAML files.

View File

@@ -53,7 +53,6 @@
"marked": "^12.0.2",
"masonry-layout": "^4.2.2",
"mime": "^4.0.0",
"pdfjs-dist": "^4.3.136",
"swagger-ui": "^4.15.5",
"vis-network": "^9.1.0",
"vue": "^3.2.37",

View File

@@ -69,8 +69,9 @@ return static function (RectorConfig $rectorConfig): void {
// skip some path...
$rectorConfig->skip([
// waiting for fixing this bug: https://github.com/rectorphp/rector-doctrine/issues/342
\Rector\Doctrine\CodeQuality\Rector\Property\ImproveDoctrineCollectionDocTypeInEntityRector::class,
// we must adapt service definition
\Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class,
\Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class,
]);
$rectorConfig->ruleWithConfiguration(AnnotationToAttributeRector::class, [

View File

@@ -80,7 +80,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
private \DateTime $date;
/**
* @var Collection<int, StoredObject>
* @var Collection<StoredObject>
*/
#[Assert\Valid(traverse: true)]
#[ORM\ManyToMany(targetEntity: StoredObject::class, cascade: ['persist'])]
@@ -107,7 +107,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
private ?Person $person = null;
/**
* @var Collection<int, \Chill\PersonBundle\Entity\Person>
* @var Collection<Person>
*/
#[Groups(['read', 'docgen:read'])]
#[ORM\ManyToMany(targetEntity: Person::class)]
@@ -117,7 +117,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
private PrivateCommentEmbeddable $privateComment;
/**
* @var Collection<int, ActivityReason>
* @var Collection<ActivityReason>
*/
#[Groups(['docgen:read'])]
#[ORM\ManyToMany(targetEntity: ActivityReason::class)]
@@ -132,7 +132,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
private string $sentReceived = '';
/**
* @var Collection<int, \Chill\PersonBundle\Entity\SocialWork\SocialAction>
* @var Collection<SocialAction>
*/
#[Groups(['read', 'docgen:read'])]
#[ORM\ManyToMany(targetEntity: SocialAction::class)]
@@ -140,7 +140,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
private Collection $socialActions;
/**
* @var Collection<int, SocialIssue>
* @var Collection<SocialIssue>
*/
#[Groups(['read', 'docgen:read'])]
#[ORM\ManyToMany(targetEntity: SocialIssue::class)]
@@ -148,7 +148,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
private Collection $socialIssues;
/**
* @var Collection<int, ThirdParty>
* @var Collection<ThirdParty>
*/
#[Groups(['read', 'docgen:read'])]
#[ORM\ManyToMany(targetEntity: ThirdParty::class)]
@@ -162,7 +162,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
private ?User $user = null;
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[Groups(['read', 'docgen:read'])]
#[ORM\ManyToMany(targetEntity: User::class)]

View File

@@ -40,9 +40,9 @@ class ActivityReasonCategory implements \Stringable
/**
* Array of ActivityReason.
*
* @var Collection<int, ActivityReason>
* @var Collection<ActivityReason>
*/
#[ORM\OneToMany(mappedBy: 'category', targetEntity: ActivityReason::class)]
#[ORM\OneToMany(targetEntity: ActivityReason::class, mappedBy: 'category')]
private Collection $reasons;
/**

View File

@@ -12,8 +12,6 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Repository;
use Chill\ActivityBundle\Entity\Activity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -25,7 +23,7 @@ use Doctrine\Persistence\ManagerRegistry;
* @method Activity[] findAll()
* @method Activity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ActivityRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
class ActivityRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
@@ -99,16 +97,4 @@ class ActivityRepository extends ServiceEntityRepository implements AssociatedEn
return $qb->getQuery()->getResult();
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Activity
{
$qb = $this->createQueryBuilder('a');
$query = $qb
->leftJoin('a.documents', 'ad')
->where('ad.id = :storedObjectId')
->setParameter('storedObjectId', $storedObject->getId())
->getQuery();
return $query->getOneOrNullResult();
}
}

View File

@@ -22,8 +22,8 @@
<ul class="record_actions">
<li class="add-persons">
<add-persons
buttonTitle="activity.add_persons"
modalTitle="activity.add_persons"
:buttonTitle="trans(ACTIVITY_ADD_PERSONS)"
:modalTitle="trans(ACTIVITY_ADD_PERSONS)"
v-bind:key="addPersons.key"
v-bind:options="addPersonsOptions"
@addNewPersons="addNewPersons"
@@ -40,6 +40,20 @@ import { mapState, mapGetters } from 'vuex';
import AddPersons from 'ChillPersonAssets/vuejs/_components/AddPersons.vue';
import PersonsBloc from './ConcernedGroups/PersonsBloc.vue';
import PersonText from 'ChillPersonAssets/vuejs/_components/Entity/PersonText.vue';
import {
ACTIVITY_BLOC_PERSONS,
ACTIVITY_BLOC_PERSONS_ASSOCIATED,
ACTIVITY_BLOC_PERSONS_NOT_ASSOCIATED,
ACTIVITY_BLOC_THIRDPARTY,
ACTIVITY_BLOC_USERS,
ACTIVITY_ADD_PERSONS,
ACTIVITY_LOCATION,
ACTIVITY_CHOOSE_LOCATION,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
MULTISELECT_SELECTED_LABEL,
trans,
} from "../../../../../../../../../../../assets/translator";
export default {
name: "ConcernedGroups",
@@ -48,16 +62,22 @@ export default {
PersonsBloc,
PersonText
},
setup() {
return {
trans,
ACTIVITY_ADD_PERSONS
};
},
data() {
return {
personsBlocs: [
{ key: 'persons',
title: 'activity.bloc_persons',
title: trans(ACTIVITY_BLOC_PERSONS),
persons: [],
included: false
},
{ key: 'personsAssociated',
title: 'activity.bloc_persons_associated',
title: trans(ACTIVITY_BLOC_PERSONS_ASSOCIATED),
persons: [],
included: window.activity ? window.activity.activityType.personsVisible !== 0 : true
},
@@ -67,12 +87,12 @@ export default {
included: window.activity ? window.activity.activityType.personsVisible !== 0 : true
},
{ key: 'thirdparty',
title: 'activity.bloc_thirdparty',
title: trans(ACTIVITY_BLOC_THIRDPARTY),
persons: [],
included: window.activity ? window.activity.activityType.thirdPartiesVisible !== 0 : true
},
{ key: 'users',
title: 'activity.bloc_users',
title: trans(ACTIVITY_BLOC_USERS),
persons: [],
included: window.activity ? window.activity.activityType.usersVisible !== 0 : true
},

View File

@@ -2,7 +2,7 @@
<teleport to="#location">
<div class="mb-3 row">
<label :class="locationClassList">
{{ $t("activity.location") }}
{{ trans(ACTIVITY_LOCATION) }}
</label>
<div class="col-sm-8">
<VueMultiselect
@@ -13,11 +13,11 @@
open-direction="top"
:multiple="false"
:searchable="true"
:placeholder="$t('activity.choose_location')"
:placeholder="trans(ACTIVITY_CHOOSE_LOCATION)"
:custom-label="customLabel"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
:select-label="trans(MULTISELECT_SELECT_LABEL)"
:deselect-label="trans(MULTISELECT_DESELECT_LABEL)"
:selected-label="trans(MULTISELECT_SELECTED_LABEL)"
:options="availableLocations"
group-values="locations"
group-label="locationGroup"
@@ -34,6 +34,14 @@
import { mapState, mapGetters } from "vuex";
import VueMultiselect from "vue-multiselect";
import NewLocation from "./Location/NewLocation.vue";
import {
trans,
ACTIVITY_LOCATION,
ACTIVITY_CHOOSE_LOCATION,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
MULTISELECT_SELECTED_LABEL
} from '../../../../../../../../../../../assets/translator'
export default {
name: "Location",
@@ -41,6 +49,16 @@ export default {
NewLocation,
VueMultiselect,
},
setup() {
return {
trans,
ACTIVITY_LOCATION,
ACTIVITY_CHOOSE_LOCATION,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
MULTISELECT_SELECTED_LABEL
};
},
data() {
return {
locationClassList:

View File

@@ -3,7 +3,7 @@
<ul class="record_actions">
<li>
<a class="btn btn-sm btn-create" @click="openModal">
{{ $t('activity.create_new_location') }}
{{ trans(ACTIVITY_CREATE_NEW_LOCATION) }}
</a>
</li>
</ul>
@@ -14,7 +14,7 @@
@close="modal.showModal = false">
<template v-slot:header>
<h3 class="modal-title">{{ $t('activity.create_new_location') }}</h3>
<h3 class="modal-title">{{ trans(ACTIVITY_CREATE_NEW_LOCATION) }}</h3>
</template>
<template v-slot:body>
<form>
@@ -26,17 +26,17 @@
<div class="form-floating mb-3">
<select class="form-select form-select-lg" id="type" required v-model="selectType">
<option selected disabled value="">{{ $t('activity.choose_location_type') }}</option>
<option selected disabled value="">{{ trans(ACTIVITY_CHOOSE_LOCATION_TYPE) }}</option>
<option v-for="t in locationTypes" :value="t" :key="t.id">
{{ t.title.fr }}
</option>
</select>
<label>{{ $t('activity.location_fields.type') }}</label>
<label>{{ trans(ACTIVITY_LOCATION_FIELDS_TYPE) }}</label>
</div>
<div class="form-floating mb-3">
<input class="form-control form-control-lg" id="name" v-model="inputName" placeholder />
<label for="name">{{ $t('activity.location_fields.name') }}</label>
<label for="name">{{ trans(ACTIVITY_LOCATION_FIELDS_NAME) }}</label>
</div>
<add-address
@@ -49,15 +49,15 @@
<div class="form-floating mb-3" v-if="showContactData">
<input class="form-control form-control-lg" id="phonenumber1" v-model="inputPhonenumber1" placeholder />
<label for="phonenumber1">{{ $t('activity.location_fields.phonenumber1') }}</label>
<label for="phonenumber1">{{ trans(ACTIVITY_LOCATION_FIELDS_PHONENUMBER1) }}</label>
</div>
<div class="form-floating mb-3" v-if="hasPhonenumber1">
<input class="form-control form-control-lg" id="phonenumber2" v-model="inputPhonenumber2" placeholder />
<label for="phonenumber2">{{ $t('activity.location_fields.phonenumber2') }}</label>
<label for="phonenumber2">{{ trans(ACTIVITY_LOCATION_FIELDS_PHONENUMBER2) }}</label>
</div>
<div class="form-floating mb-3" v-if="showContactData">
<input class="form-control form-control-lg" id="email" v-model="inputEmail" placeholder />
<label for="email">{{ $t('activity.location_fields.email') }}</label>
<label for="email">{{ trans(ACTIVITY_LOCATION_FIELDS_EMAIL) }}</label>
</div>
</form>
@@ -66,7 +66,7 @@
<button class="btn btn-save"
@click.prevent="saveNewLocation"
>
{{ $t('action.save') }}
{{ trans(SAVE) }}
</button>
</template>
@@ -81,6 +81,13 @@ import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue"
import { mapState } from "vuex";
import { getLocationTypes } from "../../api";
import { makeFetch } from 'ChillMainAssets/lib/api/apiMethods';
import {
SAVE,
ACTIVITY_LOCATION_FIELDS_EMAIL, ACTIVITY_LOCATION_FIELDS_PHONENUMBER1,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER2, ACTIVITY_LOCATION_FIELDS_NAME,
ACTIVITY_LOCATION_FIELDS_TYPE, ACTIVITY_CHOOSE_LOCATION_TYPE, ACTIVITY_CREATE_NEW_LOCATION,
trans
} from "../../../../../../../../../../../../assets/translator";
export default {
name: "NewLocation",
@@ -88,6 +95,19 @@ export default {
Modal,
AddAddress,
},
setup() {
return {
trans,
SAVE,
ACTIVITY_LOCATION_FIELDS_EMAIL,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER1,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER2,
ACTIVITY_LOCATION_FIELDS_NAME,
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
};
},
props: ['availableLocations'],
data() {
return {

View File

@@ -3,7 +3,7 @@
<div class="mb-3 row">
<div class="col-4">
<label :class="socialIssuesClassList">{{ $t('activity.social_issues') }}</label>
<label :class="socialIssuesClassList">{{ trans(ACTIVITY_SOCIAL_ISSUES) }}</label>
</div>
<div class="col-8">
@@ -31,7 +31,7 @@
:allow-empty="true"
:show-labels="false"
:loading="issueIsLoading"
:placeholder="$t('activity.choose_other_social_issue')"
:placeholder="trans(ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE)"
:options="socialIssuesOther"
@select="addIssueInList">
</VueMultiselect>
@@ -42,7 +42,7 @@
<div class="mb-3 row">
<div class="col-4">
<label :class="socialActionsClassList">{{ $t('activity.social_actions') }}</label>
<label :class="socialActionsClassList">{{ trans(ACTIVITY_SOCIAL_ACTIONS) }}</label>
</div>
<div class="col-8">
@@ -51,7 +51,7 @@
</div>
<span v-else-if="socialIssuesSelected.length === 0" class="inline-choice chill-no-data-statement mt-3">
{{ $t('activity.select_first_a_social_issue') }}
{{ trans(ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE) }}
</span>
<template v-else-if="socialActionsList.length > 0">
@@ -66,7 +66,7 @@
</template>
<span v-else-if="actionAreLoaded && socialActionsList.length === 0" class="inline-choice chill-no-data-statement mt-3">
{{ $t('activity.social_action_list_empty') }}
{{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }}
</span>
@@ -81,6 +81,11 @@ import VueMultiselect from 'vue-multiselect';
import CheckSocialIssue from './SocialIssuesAcc/CheckSocialIssue.vue';
import CheckSocialAction from './SocialIssuesAcc/CheckSocialAction.vue';
import { getSocialIssues, getSocialActionByIssue } from '../api.js';
import {
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE, ACTIVITY_SOCIAL_ACTIONS,
ACTIVITY_SOCIAL_ISSUES, ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE, trans
} from "../../../../../../../../../../../assets/translator";
export default {
name: "SocialIssuesAcc",
@@ -89,6 +94,16 @@ export default {
CheckSocialAction,
VueMultiselect
},
setup() {
return {
trans,
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
ACTIVITY_SOCIAL_ACTIONS,
ACTIVITY_SOCIAL_ISSUES,
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE
};
},
data() {
return {
issueIsLoading: false,

View File

@@ -1,54 +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\Security\Authorization;
use Chill\ActivityBundle\Entity\Activity;
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\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly ActivityRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return Activity::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => ActivityVoter::UPDATE,
StoredObjectRoleEnum::SEE => ActivityVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return false;
}
}

View File

@@ -101,6 +101,31 @@ activity:
Insert a document: Insérer un document
Remove a document: Supprimer le document
comment: Commentaire
errors: Le formulaire contient des erreurs
social_issues: Problématiques sociales
choose_other_social_issue: Ajouter une autre problématique sociale...
social_actions: Actions d'accompagnement
select_first_a_social_issue: Sélectionnez d'abord une problématique sociale
social_action_list_empty: Aucune action sociale disponible
add_persons: Ajouter des personnes concernées
bloc_persons: Usagers
bloc_persons_associated: Usagers du parcours
bloc_persons_not_associated: Tiers non-pro.
bloc_thirdparty: Tiers professionnels
bloc_users: T(M)S
location: Localisation
choose_location: Choisissez une localisation
choose_location_type: Choisissez un type de localisation
create_new_location: Créer une nouvelle localisation
location_fields:
name: Nom
type: Type
phonenumber1: Téléphone
phonenumber2: Autre téléphone
email: Adresse courriel
create_address: Créer une adresse
edit_address: Modifier l'adresse
No documents: Aucun document
# activity filter in list page

View File

@@ -22,9 +22,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
class AsideActivityCategory
{
/**
* @var Collection<int, AsideActivityCategory>
* @var Collection<AsideActivityCategory>
*/
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: AsideActivityCategory::class)]
#[ORM\OneToMany(targetEntity: AsideActivityCategory::class, mappedBy: 'parent')]
private Collection $children;
#[ORM\Id]

View File

@@ -103,7 +103,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
private int $dateTimeVersion = 0;
/**
* @var Collection<int, \Chill\CalendarBundle\Entity\CalendarDoc>
* @var Collection<CalendarDoc>
*/
#[ORM\OneToMany(mappedBy: 'calendar', targetEntity: CalendarDoc::class, orphanRemoval: true)]
private Collection $documents;
@@ -120,7 +120,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
private ?int $id = null;
/**
* @var \Doctrine\Common\Collections\Collection<int, \Chill\CalendarBundle\Entity\Invite>&Selectable
* @var Collection&Selectable<int, Invite>
*/
#[Serializer\Groups(['read', 'docgen:read'])]
#[ORM\OneToMany(mappedBy: 'calendar', targetEntity: Invite::class, cascade: ['persist', 'remove', 'merge', 'detach'], orphanRemoval: true)]
@@ -143,7 +143,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
private ?Person $person = null;
/**
* @var Collection<int, Person>
* @var Collection<Person>
*/
#[Serializer\Groups(['calendar:read', 'read', 'calendar:light', 'docgen:read'])]
#[Assert\Count(min: 1, minMessage: 'calendar.At least {{ limit }} person is required.')]
@@ -157,7 +157,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
private PrivateCommentEmbeddable $privateComment;
/**
* @var Collection<int, ThirdParty>
* @var Collection<ThirdParty>
*/
#[Serializer\Groups(['calendar:read', 'read', 'calendar:light', 'docgen:read'])]
#[ORM\ManyToMany(targetEntity: ThirdParty::class)]

View File

@@ -47,7 +47,7 @@ final class CalendarContextTest extends TestCase
{
$expected =
[
'trackDatetime' => true,
'track_datetime' => true,
'askMainPerson' => true,
'mainPersonLabel' => 'docgen.calendar.Destinee',
'askThirdParty' => false,
@@ -61,7 +61,7 @@ final class CalendarContextTest extends TestCase
{
$expected =
[
'trackDatetime' => true,
'track_datetime' => true,
'askMainPerson' => true,
'mainPersonLabel' => 'docgen.calendar.Destinee',
'askThirdParty' => false,

View File

@@ -23,9 +23,9 @@ class Option
private bool $active = true;
/**
* @var Collection<int, Option>
* @var Collection<Option>
*/
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: Option::class)]
#[ORM\OneToMany(targetEntity: Option::class, mappedBy: 'parent')]
private Collection $children;
#[ORM\Id]

View File

@@ -32,9 +32,9 @@ class CustomFieldsGroup
* The custom fields of the group.
* The custom fields are asc-ordered regarding to their property "ordering".
*
* @var Collection<int, CustomField>
* @var Collection<CustomField>
*/
#[ORM\OneToMany(mappedBy: 'customFieldGroup', targetEntity: CustomField::class)]
#[ORM\OneToMany(targetEntity: CustomField::class, mappedBy: 'customFieldGroup')]
#[ORM\OrderBy(['ordering' => \Doctrine\Common\Collections\Criteria::ASC])]
private Collection $customFields;

View File

@@ -89,7 +89,6 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
$g = new SignedUrlPost(
$url = $this->generateUrl($object_name),
$expires,
$object_name,
$this->max_post_file_size,
$max_file_count,
$submit_delay,
@@ -128,7 +127,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
];
$url = $url.'?'.\http_build_query($args);
$signature = new SignedUrl(strtoupper($method), $url, $expires, $object_name);
$signature = new SignedUrl(strtoupper($method), $url, $expires);
$this->event_dispatcher->dispatch(
new TempUrlGenerateEvent($signature)

View File

@@ -21,8 +21,6 @@ readonly class SignedUrl
#[Serializer\Groups(['read'])]
public string $url,
public \DateTimeImmutable $expires,
#[Serializer\Groups(['read'])]
public string $object_name,
) {}
#[Serializer\Groups(['read'])]

View File

@@ -18,7 +18,6 @@ readonly class SignedUrlPost extends SignedUrl
public function __construct(
string $url,
\DateTimeImmutable $expires,
string $object_name,
#[Serializer\Groups(['read'])]
public int $max_file_size,
#[Serializer\Groups(['read'])]
@@ -32,6 +31,6 @@ readonly class SignedUrlPost extends SignedUrl
#[Serializer\Groups(['read'])]
public string $signature,
) {
parent::__construct('POST', $url, $expires, $object_name);
parent::__construct('POST', $url, $expires);
}
}

View File

@@ -26,8 +26,6 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
/**
* Class DocumentPersonController.
@@ -42,8 +40,6 @@ class DocumentPersonController extends AbstractController
protected TranslatorInterface $translator,
protected EventDispatcherInterface $eventDispatcher,
protected AuthorizationHelper $authorizationHelper,
protected PDFSignatureZoneParser $PDFSignatureZoneParser,
protected StoredObjectManagerInterface $storedObjectManagerInterface,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry
) {}
@@ -201,36 +197,4 @@ 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

@@ -1,67 +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\Service\Signature\Driver\BaseSigner\RequestPdfSignMessage;
use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
class SignatureRequestController
{
public function __construct(
private readonly MessageBusInterface $messageBus,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly EntityWorkflowManager $entityWorkflowManager,
) {}
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse
{
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
$content = $this->storedObjectManager->read($storedObject);
$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'],
$data['zone']['y'],
$data['zone']['height'],
$data['zone']['width'],
new PDFPage($data['zone']['PDFPage']['index'], $data['zone']['PDFPage']['width'], $data['zone']['PDFPage']['height'])
);
$this->messageBus->dispatch(new RequestPdfSignMessage(
$signature->getId(),
$zone,
$data['zone']['index'],
'test signature', // reason (string)
'Mme Caroline Diallo', // signerText (string)
$content
));
return new JsonResponse(null, JsonResponse::HTTP_OK, []);
}
#[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')]
public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse
{
return new JsonResponse($signature->getState(), JsonResponse::HTTP_OK, []);
}
}

View File

@@ -14,7 +14,6 @@ namespace Chill\DocStoreBundle\DependencyInjection;
use Chill\DocStoreBundle\Controller\StoredObjectApiController;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
@@ -36,8 +35,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$container->setParameter('chill_doc_store', $config);
$container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter');
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
$loader->load('services/controller.yaml');
@@ -45,7 +42,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$loader->load('services/fixtures.yaml');
$loader->load('services/form.yaml');
$loader->load('services/templating.yaml');
$loader->load('services/security.yaml');
}
public function prepend(ContainerBuilder $container)

View File

@@ -12,14 +12,13 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
class AccompanyingCourseDocumentRepository implements ObjectRepository
{
private readonly EntityRepository $repository;
@@ -46,16 +45,6 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
return $qb->getQuery()->getSingleScalarResult();
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->repository->createQueryBuilder('d');
$query = $qb->where('d.object = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery();
return $query->getOneOrNullResult();
}
public function find($id): ?AccompanyingCourseDocument
{
return $this->repository->find($id);
@@ -66,7 +55,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
@@ -76,7 +65,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
return $this->findOneBy($criteria);
}
public function getClassName(): string
public function getClassName()
{
return AccompanyingCourseDocument::class;
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
interface AssociatedEntityToStoredObjectInterface
{
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object;
}

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
@@ -20,7 +19,7 @@ use Doctrine\Persistence\ObjectRepository;
/**
* @template ObjectRepository<PersonDocument::class>
*/
readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
readonly class PersonDocumentRepository implements ObjectRepository
{
private EntityRepository $repository;
@@ -54,14 +53,4 @@ readonly class PersonDocumentRepository implements ObjectRepository, AssociatedE
{
return PersonDocument::class;
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->repository->createQueryBuilder('d');
$query = $qb->where('d.object = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery();
return $query->getOneOrNullResult();
}
}

View File

@@ -62,26 +62,3 @@ export interface PostStoreObjectSignature {
signature: string,
}
export interface PDFPage {
index: number,
width: number,
height: number,
}
export interface SignatureZone {
index: number | null,
x: number,
y: number,
width: number,
height: number,
PDFPage: PDFPage,
}
export interface Signature {
id: number,
storedObject: StoredObject,
zones: SignatureZone[],
}
export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error';
export type CanvasEvent = 'select' | 'add';

View File

@@ -1,559 +0,0 @@
<template>
<teleport to="body">
<modal v-if="modalOpen" @close="modalOpen = false">
<template v-slot:header>
<h2>{{ $t("signature_confirmation") }}</h2>
</template>
<template v-slot:body>
<div class="signature-modal-body text-center" v-if="loading">
<p>{{ $t("electronic_signature_in_progress") }}</p>
<div class="loading">
<i
class="fa fa-circle-o-notch fa-spin fa-3x"
:title="$t('loading')"
></i>
</div>
</div>
<div class="signature-modal-body text-center" v-else>
<p>{{ $t("you_are_going_to_sign") }}</p>
<p>{{ $t("are_you_sure") }}</p>
</div>
</template>
<template v-slot:footer>
<button class="btn btn-action" @click.prevent="confirmSign">
{{ $t("yes") }}
</button>
</template>
</modal>
</teleport>
<div class="col-12">
<div
class="row justify-content-center mb-2"
v-if="signature.zones.length > 1"
>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_sign_zone") }}
</button>
</div>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_sign_zone") }}
</button>
</div>
</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-light btn-sm"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>page {{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</div>
</div>
</div>
<div v-if="multiPage" class="col-12 text-center">
<canvas
v-for="p in pageCount"
:key="p"
class="m-auto"
:id="`canvas-${p}`"
></canvas>
</div>
<div v-else class="col-12 text-center">
<canvas class="m-auto" :id="canvas"></canvas>
</div>
<div class="col-12 p-4" id="action-buttons" v-if="signedState !== 'signed'">
<div class="row mb-3">
<div class="col-12 d-flex justify-content-end">
<div class="col-4 col-xl-3 gap-2 d-grid">
<button
v-if="adding"
class="btn btn-misc btn-cancel me-2 btn-sm"
@click="removeNewZone()"
>
{{ $t("remove_sign_zone") }}
</button>
</div>
<div class="col-4 gap-2 d-grid">
<button
class="btn btn-create btn-sm"
:class="{ active: canvasEvent === 'add' }"
@click="toggleAddZone()"
>
{{ $t("add_sign_zone") }}
</button>
</div>
</div>
</div>
<div class="row">
<div class="col-4">
<button
class="btn btn-action me-2"
:disabled="!userSignatureZone"
@click="sign"
>
{{ $t("sign") }}
</button>
</div>
<div class="col-8 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>
<script setup lang="ts">
import { ref, Ref, reactive } from "vue";
import { useToast } from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css";
import {
CanvasEvent,
Signature,
SignatureZone,
SignedState,
} from "../../types";
import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import * as pdfjsLib from "pdfjs-dist";
import {
PDFDocumentProxy,
PDFPageProxy,
} from "pdfjs-dist/types/src/display/api";
// @ts-ignore
import * as PdfWorker from "pdfjs-dist/build/pdf.worker.mjs";
console.log(PdfWorker); // incredible but this is needed
// import { PdfWorker } from 'pdfjs-dist/build/pdf.worker.mjs'
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {
build_download_info_link,
download_and_decrypt_doc,
} from "../StoredObjectButton/helpers";
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
const multiPage: Ref<boolean> = ref(true);
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);
let userSignatureZone: Ref<null | SignatureZone> = ref(null);
let pdf = {} as PDFDocumentProxy;
declare global {
interface Window {
signature: Signature;
}
}
const $toast = useToast();
const signature = window.signature;
const urlInfo = build_download_info_link(signature.storedObject.filename);
console.log(signature);
const mountPdf = async (url: string) => {
const loadingTask = pdfjsLib.getDocument(url);
pdf = await loadingTask.promise;
pageCount.value = pdf.numPages;
if (multiPage.value) {
await setAllPages();
} else {
await setPage(1);
}
};
const getRenderContext = (pdfPage: PDFPageProxy) => {
const scale = 1;
const viewport = pdfPage.getViewport({ scale });
let canvas;
if (multiPage.value) {
canvas = document.getElementById(
`canvas-${pdfPage.pageNumber}`
) as HTMLCanvasElement;
} else {
canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
}
const context = canvas.getContext("2d") as CanvasRenderingContext2D;
canvas.height = viewport.height;
canvas.width = viewport.width;
return {
canvasContext: context,
viewport: viewport,
};
};
const setAllPages = async () =>
Array.from(Array(pageCount.value).keys()).map((p) => setPage(p + 1));
const setPage = async (page: number) => {
const pdfPage = await pdf.getPage(page);
const renderContext = getRenderContext(pdfPage);
await pdfPage.render(renderContext);
};
async function downloadAndOpen(): Promise<Blob> {
let raw;
try {
raw = await download_and_decrypt_doc(
urlInfo,
signature.storedObject.keyInfos,
new Uint8Array(signature.storedObject.iv)
);
} catch (e) {
console.error("error while downloading and decrypting document", e);
throw e;
}
await mountPdf(URL.createObjectURL(raw));
initPdf();
return raw;
}
const initPdf = () => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener("pointerup", canvasClick, 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 -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) <
xy[1] &&
xy[1] <
scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) +
zone.PDFPage.height;
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
userSignatureZone.value = z;
const ctx = canvas.getContext("2d");
if (ctx) {
setPage(page.value);
setTimeout(() => drawZone(z, ctx, canvas.width, canvas.height), 200);
}
};
const selectZoneOnCanvas = (e: PointerEvent, canvas: HTMLCanvasElement) =>
signature.zones
.filter((z) => z.PDFPage.index + 1 === page.value)
.map((z) => {
if (
hitSignature(z, [e.offsetX, e.offsetY], canvas.width, canvas.height)
) {
if (userSignatureZone.value === null) {
selectZone(z, canvas);
} else {
if (userSignatureZone.value.index === z.index) {
sign();
}
}
}
});
const canvasClick = (e: PointerEvent) => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvasEvent.value === "select"
? selectZoneOnCanvas(e, canvas)
: addZoneOnCanvas(e, canvas);
};
const turnPage = async (upOrDown: number) => {
userSignatureZone.value = null;
page.value = page.value + upOrDown;
await setPage(page.value);
setTimeout(() => addZones(page.value), 200);
};
const turnSignature = async (upOrDown: number) => {
let zoneIndex = userSignatureZone.value?.index ?? -1;
if (zoneIndex < -1) {
zoneIndex = -1;
}
if (zoneIndex < signature.zones.length) {
zoneIndex = zoneIndex + upOrDown;
} else {
zoneIndex = 0;
}
let currentZone = signature.zones[zoneIndex];
if (currentZone) {
page.value = currentZone.PDFPage.index + 1;
userSignatureZone.value = currentZone;
const canvas = document.querySelectorAll("canvas")[0];
selectZone(currentZone, canvas);
}
};
const drawZone = (
zone: SignatureZone,
ctx: CanvasRenderingContext2D,
canvasWidth: number,
canvasHeight: number
) => {
const unselectedBlue = "#007bff";
const selectedBlue = "#034286";
ctx.strokeStyle =
userSignatureZone.value?.index === zone.index
? selectedBlue
: unselectedBlue;
ctx.lineWidth = 2;
ctx.lineJoin = "bevel";
ctx.strokeRect(
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width),
zone.PDFPage.height -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height),
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width),
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height)
);
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 -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) +
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.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);
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 addZones = (page: number) => {
const canvas = document.querySelectorAll("canvas")[0];
const ctx = canvas.getContext("2d");
if (ctx) {
signature.zones
.filter((z) => z.PDFPage.index + 1 === page)
.map((z) => drawZone(z, ctx, canvas.width, canvas.height));
}
};
const checkSignature = () => {
const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
return makeFetch("GET", url)
.then((r) => {
signedState.value = r as SignedState;
checkForReady();
})
.catch((error) => {
signedState.value = "error";
console.log("Error while checking the signature", error);
$toast.error(
`Erreur lors de la vérification de la signature: ${error.txt}`
);
});
};
const maxTryForReady = 60; //2 minutes for trying to sign
let tryForReady = 0;
const stopTrySigning = () => {
loading.value = false;
modalOpen.value = false;
};
const checkForReady = () => {
if (tryForReady > maxTryForReady) {
stopTrySigning();
tryForReady = 0;
console.log("Reached the maximum number of tentative to try signing");
$toast.error(
"Le nombre maximum de tentatives pour essayer de signer est atteint"
);
}
if (signedState.value === "rejected") {
stopTrySigning();
console.log("Signature rejected by the server");
$toast.error("Signature rejetée par le serveur");
}
if (signedState.value === "canceled") {
stopTrySigning();
console.log("Signature canceled");
$toast.error("Signature annulée");
}
if (signedState.value === "pending") {
tryForReady = tryForReady + 1;
setTimeout(() => checkSignature(), 2000);
} else {
stopTrySigning();
if (signedState.value === "signed") {
userSignatureZone.value = null;
downloadAndOpen();
}
}
};
const sign = () => (modalOpen.value = true);
const confirmSign = () => {
loading.value = true;
const url = `/api/1.0/document/workflow/${signature.id}/signature-request`;
const body = {
storedObject: signature.storedObject,
zone: userSignatureZone.value,
};
makeFetch("POST", url, body)
.then((r) => {
checkForReady();
})
.catch((error) => {
console.log("Error while posting the signature", error);
stopTrySigning();
$toast.error(
`Erreur lors de la soumission de la signature: ${error.txt}`
);
});
};
const undoSign = async () => {
signature.zones = signature.zones.filter((z) => z.index !== null);
await setPage(page.value);
setTimeout(() => addZones(page.value), 200);
userSignatureZone.value = null;
adding.value = false;
};
const toggleAddZone = () => {
canvasEvent.value === "select"
? (canvasEvent.value = "add")
: (canvasEvent.value = "select");
};
const addZoneOnCanvas = (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);
setTimeout(() => addZones(page.value), 200);
canvasEvent.value = "select";
adding.value = true;
};
const removeNewZone = async () => {
signature.zones = signature.zones.filter((z) => z.index !== null);
userSignatureZone.value = null;
await setPage(page.value);
setTimeout(() => addZones(page.value), 200);
canvasEvent.value = "select";
adding.value = false;
};
downloadAndOpen();
</script>
<style scoped lang="scss">
canvas {
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1);
}
div#action-buttons {
position: sticky;
bottom: 0px;
background-color: white;
z-index: 100;
}
div#turn-page {
span {
font-size: 0.8rem;
margin: 0 0.4rem;
}
}
div.signature-modal-body {
height: 8rem;
}
</style>

View File

@@ -1,32 +0,0 @@
import { createApp } from "vue";
// @ts-ignore
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
import App from "./App.vue";
const appMessages = {
fr: {
yes: 'Oui',
are_you_sure: 'Êtes-vous sûr·e?',
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 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',
electronic_signature_in_progress: 'Signature électronique en cours...',
loading: 'Chargement...',
add_sign_zone: 'Ajouter une zone de signature',
remove_sign_zone: 'Enlever la zone',
}
}
const i18n = _createI18n(appMessages);
const app = createApp({
template: `<app></app>`,
})
.use(i18n)
.component("app", App)
.mount("#document-signature");

View File

@@ -71,7 +71,7 @@
</li>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ document.object|chill_document_button_group(document.title) }}
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }}
</li>
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
@@ -90,7 +90,7 @@
{% else %}
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ document.object|chill_document_button_group(document.title) }}
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }}
</li>
<li>
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>

View File

@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon">
<title>Signature</title>
{{ encore_entry_link_tags('mod_bootstrap') }}
{{ encore_entry_link_tags('mod_forkawesome') }}
{{ encore_entry_link_tags('chill') }}
{{ encore_entry_link_tags('vue_document_signature') }}
</head>
<body>
{% block js %}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
<script type="text/javascript">
window.signature = {{ signature|json_encode|raw }};
</script>
{{ encore_entry_script_tags('vue_document_signature') }}
{% endblock %}
<div class="content" id="content">
<div class="container-xxl">
<div class="row">
<div class="col-xs-12 col-md-12 col-lg-9 my-5 m-auto">
<h4>{{ 'Document %title%' | trans({ '%title%': document.title }) }}</h4>
<div class="row" id="document-signature"></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -70,12 +70,12 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov
return [];
}
public function supports($attribute, $subject): bool
protected function supports($attribute, $subject): bool
{
return $this->voterHelper->supports($attribute, $subject);
}
public function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
if (!$token->getUser() instanceof User) {
return false;

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
@@ -23,7 +22,6 @@ final class AsyncUploadVoter extends Voter
public function __construct(
private readonly Security $security,
private readonly StoredObjectRepository $storedObjectRepository
) {}
protected function supports($attribute, $subject): bool
@@ -34,16 +32,10 @@ final class AsyncUploadVoter extends Voter
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var SignedUrl $subject */
if (!in_array($subject->method, ['POST', 'GET', 'HEAD', 'PUT'], true)) {
if (!in_array($subject->method, ['POST', 'GET', 'HEAD'], true)) {
return false;
}
$storedObject = $this->storedObjectRepository->findOneBy(['filename' => $subject->object_name]);
return match ($subject->method) {
'GET', 'HEAD' => $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject),
'PUT' => $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject),
'POST' => $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')
};
return $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN');
}
}

View File

@@ -12,10 +12,9 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Psr\Log\LoggerInterface;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
/**
* Voter for the content of a stored object.
@@ -24,10 +23,6 @@ use Symfony\Component\Security\Core\Security;
*/
class StoredObjectVoter extends Voter
{
public const LOG_PREFIX = '[stored object voter] ';
public function __construct(private readonly Security $security, private readonly iterable $storedObjectVoters, private readonly LoggerInterface $logger) {}
protected function supports($attribute, $subject): bool
{
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
@@ -37,28 +32,24 @@ class StoredObjectVoter extends Voter
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var StoredObject $subject */
$attributeAsEnum = StoredObjectRoleEnum::from($attribute);
// Loop through context-specific voters
foreach ($this->storedObjectVoters as $storedObjectVoter) {
if ($storedObjectVoter->supports($attributeAsEnum, $subject)) {
$grant = $storedObjectVoter->voteOnAttribute($attributeAsEnum, $subject, $token);
if (false === $grant) {
$this->logger->debug(self::LOG_PREFIX.'deny access by storedObjectVoter', ['stored_object_voter' => $storedObjectVoter::class]);
}
return $grant;
}
}
// User role-based fallback
if ($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')) {
// TODO: this maybe considered as a security issue, as all authenticated users can reach a stored object which
// is potentially detached from an existing entity.
return true;
}
if (
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
) {
return false;
}
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
return false;
}
$askedRole = StoredObjectRoleEnum::from($attribute);
$tokenRoleAuthorization =
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
return match ($askedRole) {
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
};
}
}

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\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
{
abstract protected function getRepository(): AssociatedEntityToStoredObjectInterface;
/**
* @return class-string
*/
abstract protected function getClass(): string;
abstract protected function attributeToRole(StoredObjectRoleEnum $attribute): string;
abstract protected function canBeAssociatedWithWorkflow(): bool;
public function __construct(
private readonly Security $security,
private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null,
) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
{
$class = $this->getClass();
return $this->getRepository()->findAssociatedEntityToStoredObject($subject) instanceof $class;
}
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
// Retrieve the related accompanying course document
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
// Determine the attribute to pass to AccompanyingCourseDocumentVoter
$voterAttribute = $this->attributeToRole($attribute);
if (false === $this->security->isGranted($voterAttribute, $entity)) {
return false;
}
if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) {
if (null === $this->workflowDocumentService) {
throw new \LogicException('Provide a workflow document service');
}
return $this->workflowDocumentService->notBlockedByWorkflow($entity);
}
return true;
}
}

View File

@@ -1,54 +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\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly AccompanyingCourseDocumentRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => AccompanyingCourseDocumentVoter::UPDATE,
StoredObjectRoleEnum::SEE => AccompanyingCourseDocumentVoter::SEE_DETAILS,
};
}
protected function getClass(): string
{
return AccompanyingCourseDocument::class;
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
}

View File

@@ -1,54 +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\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly PersonDocumentRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return PersonDocument::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => PersonDocumentVoter::UPDATE,
StoredObjectRoleEnum::SEE => PersonDocumentVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
}

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\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
interface StoredObjectVoterInterface
{
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool;
}

View File

@@ -15,7 +15,6 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -33,8 +32,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
public function __construct(
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security
private readonly UrlGeneratorInterface $urlGenerator
) {}
public function normalize($object, ?string $format = null, array $context = [])
@@ -57,13 +55,13 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
// deprecated property
$datas['creationDate'] = $datas['createdAt'];
$canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
if ($canSee || $canEdit) {
if ($canDavSee || $canDavEdit) {
$accessToken = $this->JWTDavTokenProvider->createToken(
$object,
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
);
$datas['_links'] = [

View File

@@ -1,23 +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\Signature\Driver\BaseSigner;
/**
* Message which is received when a pdf is signed.
*/
final readonly class PdfSignedMessage
{
public function __construct(
public readonly int $signatureId,
public readonly string $content
) {}
}

View File

@@ -1,61 +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\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 Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
{
/**
* log prefix.
*/
private const P = '[pdf signed message] ';
public function __construct(
private LoggerInterface $logger,
private EntityWorkflowManager $entityWorkflowManager,
private StoredObjectManagerInterface $storedObjectManager,
private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
) {}
public function __invoke(PdfSignedMessage $message): void
{
$this->logger->info(self::P.'a message is received', ['signaturedId' => $message->signatureId]);
$signature = $this->entityWorkflowStepSignatureRepository->find($message->signatureId);
if (null === $signature) {
throw new \RuntimeException('no signature found');
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($signature->getStep()->getEntityWorkflow());
if (null === $storedObject) {
throw new \RuntimeException('no stored object found');
}
$this->storedObjectManager->write($storedObject, $message->content);
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now());
$this->entityManager->flush();
$this->entityManager->clear();
}
}

View File

@@ -1,66 +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\Signature\Driver\BaseSigner;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
/**
* Decode (and requeue) @see{PdfSignedMessage}, which comes from an external producer.
*/
final readonly class PdfSignedMessageSerializer implements SerializerInterface
{
public function decode(array $encodedEnvelope): Envelope
{
$body = $encodedEnvelope['body'];
try {
$decoded = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new MessageDecodingFailedException('Could not deserialize message', previous: $e);
}
if (!array_key_exists('signatureId', $decoded) || !array_key_exists('content', $decoded)) {
throw new MessageDecodingFailedException('Could not find expected keys: signatureId or content');
}
$content = base64_decode((string) $decoded['content'], true);
if (false === $content) {
throw new MessageDecodingFailedException('Invalid character found in the base64 encoded content');
}
$message = new PdfSignedMessage($decoded['signatureId'], $content);
return new Envelope($message);
}
public function encode(Envelope $envelope): array
{
$message = $envelope->getMessage();
if (!$message instanceof PdfSignedMessage) {
throw new MessageDecodingFailedException('Expected a PdfSignedMessage');
}
$data = [
'signatureId' => $message->signatureId,
'content' => base64_encode($message->content),
];
return [
'body' => json_encode($data, JSON_THROW_ON_ERROR),
'headers' => [],
];
}
}

View File

@@ -1,29 +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\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
/**
* Message which is sent when we request a signature on a pdf.
*/
final readonly class RequestPdfSignMessage
{
public function __construct(
public int $signatureId,
public PDFSignatureZone $PDFSignatureZone,
public int $signatureZoneIndex,
public string $reason,
public string $signerText,
public string $content,
) {}
}

View File

@@ -1,105 +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\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
use Symfony\Component\Messenger\Stamp\NonSendableStampInterface;
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Serialize a RequestPdfSignMessage, for external consumer.
*/
final readonly class RequestPdfSignMessageSerializer implements SerializerInterface
{
public function __construct(
private NormalizerInterface $normalizer,
private DenormalizerInterface $denormalizer,
) {}
public function decode(array $encodedEnvelope): Envelope
{
$body = $encodedEnvelope['body'];
$headers = $encodedEnvelope['headers'];
if (RequestPdfSignMessage::class !== ($headers['Message'] ?? null)) {
throw new MessageDecodingFailedException('serializer does not support this message');
}
$data = json_decode((string) $body, true);
$zoneSignature = $this->denormalizer->denormalize($data['signatureZone'], PDFSignatureZone::class, 'json', [
AbstractNormalizer::GROUPS => ['write'],
]);
$content = base64_decode((string) $data['content'], true);
if (false === $content) {
throw new MessageDecodingFailedException('the content could not be converted from base64 encoding');
}
$message = new RequestPdfSignMessage(
$data['signatureId'],
$zoneSignature,
$data['signatureZoneIndex'],
$data['reason'],
$data['signerText'],
$content,
);
// in case of redelivery, unserialize any stamps
$stamps = [];
if (isset($headers['stamps'])) {
$stamps = unserialize($headers['stamps']);
}
return new Envelope($message, $stamps);
}
public function encode(Envelope $envelope): array
{
$message = $envelope->getMessage();
if (!$message instanceof RequestPdfSignMessage) {
throw new MessageDecodingFailedException('Message is not a RequestPdfSignMessage');
}
$data = [
'signatureId' => $message->signatureId,
'signatureZoneIndex' => $message->signatureZoneIndex,
'signatureZone' => $this->normalizer->normalize($message->PDFSignatureZone, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'reason' => $message->reason,
'signerText' => $message->signerText,
'content' => base64_encode($message->content),
];
$allStamps = [];
foreach ($envelope->all() as $stamp) {
if ($stamp instanceof NonSendableStampInterface) {
continue;
}
$allStamps = [...$allStamps, ...$stamp];
}
return [
'body' => json_encode($data, JSON_THROW_ON_ERROR, 512),
'headers' => [
'stamps' => serialize($allStamps),
'Message' => RequestPdfSignMessage::class,
],
];
}
}

View File

@@ -1,33 +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\Signature;
use Symfony\Component\Serializer\Annotation\Groups;
final readonly class PDFPage
{
public function __construct(
#[Groups(['read'])]
public int $index,
#[Groups(['read'])]
public float $width,
#[Groups(['read'])]
public float $height,
) {}
public function equals(self $page): bool
{
return $page->index === $this->index
&& round($page->width, 2) === round($this->width, 2)
&& round($page->height, 2) === round($this->height, 2);
}
}

View File

@@ -1,43 +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\Signature;
use Symfony\Component\Serializer\Annotation\Groups;
final readonly class PDFSignatureZone
{
public function __construct(
#[Groups(['read'])]
public int $index,
#[Groups(['read'])]
public float $x,
#[Groups(['read'])]
public float $y,
#[Groups(['read'])]
public float $height,
#[Groups(['read'])]
public float $width,
#[Groups(['read'])]
public PDFPage $PDFPage,
) {}
public function equals(self $other): bool
{
return
$this->index == $other->index
&& $this->x == $other->x
&& $this->y == $other->y
&& $this->height == $other->height
&& $this->width == $other->width
&& $this->PDFPage->equals($other->PDFPage);
}
}

View File

@@ -1,60 +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\Signature;
use Smalot\PdfParser\Parser;
class PDFSignatureZoneParser
{
public const ZONE_SIGNATURE_START = 'signature_zone';
private readonly Parser $parser;
public function __construct(
public float $defaultHeight = 90.0,
public float $defaultWidth = 180.0,
) {
$this->parser = new Parser();
}
/**
* @return list<PDFSignatureZone>
*/
public function findSignatureZones(string $fileContent): array
{
$pdf = $this->parser->parseContent($fileContent);
$zones = [];
$defaults = $pdf->getObjectsByType('Pages');
$defaultPage = reset($defaults);
$defaultPageDetails = $defaultPage->getDetails();
$zoneIndex = 0;
foreach ($pdf->getPages() as $index => $page) {
$details = $page->getDetails();
$pdfPage = new PDFPage(
$index,
(float) ($details['MediaBox'][2] ?? $defaultPageDetails['MediaBox'][2]),
(float) ($details['MediaBox'][3] ?? $defaultPageDetails['MediaBox'][3]),
);
foreach ($page->getDataTm() as $dataTm) {
if (str_starts_with((string) $dataTm[1], self::ZONE_SIGNATURE_START)) {
$zones[] = new PDFSignatureZone($zoneIndex, (float) $dataTm[0][4], (float) $dataTm[0][5], $this->defaultHeight, $this->defaultWidth, $pdfPage);
++$zoneIndex;
}
}
}
return $zones;
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\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

@@ -16,7 +16,6 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment;
@@ -129,7 +128,6 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
private NormalizerInterface $normalizer,
private JWTDavTokenProviderInterface $davTokenProvider,
private UrlGeneratorInterface $urlGenerator,
private Security $security,
) {}
/**
@@ -150,10 +148,8 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $showEditButtons = true, array $options = []): string
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
{
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $document) && $showEditButtons;
$accessToken = $this->davTokenProvider->createToken(
$document,
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE

View File

@@ -122,8 +122,7 @@ class TempUrlOpenstackGeneratorTest extends TestCase
$signedUrl = new SignedUrl(
'GET',
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
\DateTimeImmutable::createFromFormat('U', '1702043543'),
$objectName
\DateTimeImmutable::createFromFormat('U', '1702043543')
);
foreach ($baseUrls as $baseUrl) {
@@ -154,7 +153,6 @@ class TempUrlOpenstackGeneratorTest extends TestCase
$signedUrl = new SignedUrlPost(
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
\DateTimeImmutable::createFromFormat('U', '1702043543'),
$objectName,
150,
1,
1800,

View File

@@ -35,7 +35,7 @@ class AsyncUploadExtensionTest extends KernelTestCase
{
$generator = $this->prophesize(TempUrlGeneratorInterface::class);
$generator->generate(Argument::in(['GET', 'POST']), Argument::type('string'), Argument::any())
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'), $args[1]));
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours')));
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate('async_upload.generate_url', Argument::type('array'))

View File

@@ -73,7 +73,6 @@ class AsyncUploadControllerTest extends TestCase
return new SignedUrlPost(
'https://object.store.example',
new \DateTimeImmutable('1 hour'),
'abc',
150,
1,
1800,
@@ -88,8 +87,7 @@ class AsyncUploadControllerTest extends TestCase
return new SignedUrl(
$method,
'https://object.store.example',
new \DateTimeImmutable('1 hour'),
$object_name
new \DateTimeImmutable('1 hour')
);
}
};

View File

@@ -23,7 +23,6 @@ use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Serializer;
@@ -81,15 +80,11 @@ class StoredObjectTypeTest extends TypeTestCase
$urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL)
->willReturn('http://url/fake');
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::cetera())->willReturn(true);
$serializer = new Serializer(
[
new StoredObjectNormalizer(
$jwtTokenProvider->reveal(),
$urlGenerator->reveal(),
$security->reveal()
),
],
[

View File

@@ -1,168 +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\Security\Authorization;
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 PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class AbstractStoredObjectVoterTest extends TestCase
{
private AssociatedEntityToStoredObjectInterface $repository;
private Security $security;
private WorkflowStoredObjectPermissionHelper $workflowDocumentService;
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,
?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null
) {
parent::__construct($security, $workflowDocumentService);
}
protected function attributeToRole($attribute): string
{
return 'SOME_ROLE';
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return \stdClass::class;
}
protected function canBeAssociatedWithWorkflow(): bool
{
return $this->canBeAssociatedWithWorkflow;
}
};
}
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
{
[$user, $token, $subject, $entity] = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, $subject));
}
public function testVoteOnAttributeAllowedAndWorkflowAllowed(): void
{
[$user, $token, $subject, $entity] = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// 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

@@ -14,14 +14,11 @@ namespace Chill\DocStoreBundle\Tests\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Entity\User;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Security;
/**
* @internal
@@ -30,93 +27,97 @@ use Symfony\Component\Security\Core\Security;
*/
class StoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataVote
*/
public function testVote(array $storedObjectVotersDefinition, object $subject, string $attribute, bool $fallbackSecurityExpected, bool $securityIsGrantedResult, mixed $expected): void
public function testVote(TokenInterface $token, ?object $subject, string $attribute, mixed $expected): void
{
$storedObjectVoters = array_map(fn (array $definition) => $this->buildStoredObjectVoter($definition[0], $definition[1], $definition[2]), $storedObjectVotersDefinition);
$token = new UsernamePasswordToken(new User(), 'chill_main', ['ROLE_USER']);
$security = $this->createMock(Security::class);
$security->expects($fallbackSecurityExpected ? $this->atLeastOnce() : $this->never())
->method('isGranted')
->with($this->logicalOr($this->identicalTo('ROLE_USER'), $this->identicalTo('ROLE_ADMIN')))
->willReturn($securityIsGrantedResult);
$voter = new StoredObjectVoter($security, $storedObjectVoters, new NullLogger());
$voter = new StoredObjectVoter();
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
}
private function buildStoredObjectVoter(bool $supportsIsCalled, bool $supports, bool $voteOnAttribute): StoredObjectVoterInterface
{
$storedObjectVoter = $this->createMock(StoredObjectVoterInterface::class);
$storedObjectVoter->expects($supportsIsCalled ? $this->once() : $this->never())->method('supports')
->with(self::isInstanceOf(StoredObjectRoleEnum::class), $this->isInstanceOf(StoredObject::class))
->willReturn($supports);
$storedObjectVoter->expects($supportsIsCalled && $supports ? $this->once() : $this->never())->method('voteOnAttribute')
->with(self::isInstanceOf(StoredObjectRoleEnum::class), $this->isInstanceOf(StoredObject::class), $this->isInstanceOf(TokenInterface::class))
->willReturn($voteOnAttribute);
return $storedObjectVoter;
}
public static function provideDataVote(): iterable
public function provideDataVote(): iterable
{
yield [
// we try with something else than a SToredObject, the voter should abstain
[[false, false, false]],
$this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()),
new \stdClass(),
'SOMETHING',
false,
false,
VoterInterface::ACCESS_ABSTAIN,
];
yield [
// we try with an unsupported attribute, the voter must abstain
[[false, false, false]],
new StoredObject(),
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
'SOMETHING',
false,
false,
VoterInterface::ACCESS_ABSTAIN,
];
yield [
// happy scenario: there is a role voter
[[true, true, true]],
new StoredObject(),
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
false,
false,
VoterInterface::ACCESS_GRANTED,
];
yield [
// there is a role voter, but not allowed to see the stored object
[[true, true, false]],
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
false,
false,
VoterInterface::ACCESS_DENIED,
];
yield [
// there is no role voter, fallback to security, which does not grant access
[[true, false, false]],
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
true,
false,
VoterInterface::ACCESS_DENIED,
];
yield [
// there is no role voter, fallback to security, which does grant access
[[true, false, false]],
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
true,
true,
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
}
private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface
{
$token = $this->prophesize(TokenInterface::class);
if (null !== $storedObjectRoleEnum) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum);
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException());
}
if (null !== $storedObject) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString());
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException());
}
return $token->reveal();
}
}

View File

@@ -38,8 +38,7 @@ class SignedUrlNormalizerTest extends KernelTestCase
$signedUrl = new SignedUrl(
'GET',
'https://object.store.example/container/object',
\DateTimeImmutable::createFromFormat('U', '1700000'),
'object'
\DateTimeImmutable::createFromFormat('U', '1700000')
);
$actual = self::$normalizer->normalize($signedUrl, 'json', [AbstractNormalizer::GROUPS => ['read']]);
@@ -49,7 +48,6 @@ class SignedUrlNormalizerTest extends KernelTestCase
'method' => 'GET',
'expires' => 1_700_000,
'url' => 'https://object.store.example/container/object',
'object_name' => 'object',
],
$actual
);

View File

@@ -38,7 +38,6 @@ class SignedUrlPostNormalizerTest extends KernelTestCase
$signedUrl = new SignedUrlPost(
'https://object.store.example/container/object',
\DateTimeImmutable::createFromFormat('U', '1700000'),
'abc',
15000,
1,
180,
@@ -60,7 +59,6 @@ class SignedUrlPostNormalizerTest extends KernelTestCase
'method' => 'POST',
'expires' => 1_700_000,
'url' => 'https://object.store.example/container/object',
'object_name' => 'abc',
],
$actual
);

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\DocStoreBundle\Tests\Service\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessage;
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessageHandler;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
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\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
/**
* @internal
*
* @coversNothing
*/
class PdfSignedMessageHandlerTest extends TestCase
{
public function testThatObjectIsWrittenInStoredObjectManagerHappyScenario(): void
{
// a dummy stored object
$storedObject = new StoredObject();
// build the associated EntityWorkflow, with one step with a person signature
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('new_step', $dto, 'new_transition', new \DateTimeImmutable(), new User());
$step = $entityWorkflow->getCurrentStep();
$signature = $step->getSignatures()->first();
$handler = new PdfSignedMessageHandler(
new NullLogger(),
$this->buildEntityWorkflowManager($storedObject),
$this->buildStoredObjectManager($storedObject, $expectedContent = '1234'),
$this->buildSignatureRepository($signature),
$this->buildEntityManager(true),
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, $expectedContent));
self::assertEquals('signed', $signature->getState()->value);
}
private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository
{
$entityWorkflowStepSignatureRepository = $this->createMock(EntityWorkflowStepSignatureRepository::class);
$entityWorkflowStepSignatureRepository->method('find')->with($this->isType('int'))->willReturn($signature);
return $entityWorkflowStepSignatureRepository;
}
private function buildEntityWorkflowManager(?StoredObject $associatedStoredObject): EntityWorkflowManager
{
$entityWorkflowManager = $this->createMock(EntityWorkflowManager::class);
$entityWorkflowManager->method('getAssociatedStoredObject')->willReturn($associatedStoredObject);
return $entityWorkflowManager;
}
private function buildStoredObjectManager(StoredObject $expectedStoredObject, string $expectedContent): StoredObjectManagerInterface
{
$storedObjectManager = $this->createMock(StoredObjectManagerInterface::class);
$storedObjectManager->expects($this->once())
->method('write')
->with($this->identicalTo($expectedStoredObject), $expectedContent);
return $storedObjectManager;
}
private function buildEntityManager(bool $willFlush): EntityManagerInterface
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects($willFlush ? $this->once() : $this->never())->method('flush');
$em->expects($willFlush ? $this->once() : $this->never())->method('clear');
return $em;
}
}

View File

@@ -1,63 +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\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessage;
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessageSerializer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
/**
* @internal
*
* @coversNothing
*/
class PdfSignedMessageSerializerTest extends TestCase
{
public function testDecode(): void
{
$asString = <<<'JSON'
{"signatureId": 0, "content": "dGVzdAo="}
JSON;
$actual = $this->buildSerializer()->decode(['body' => $asString]);
self::assertInstanceOf(Envelope::class, $actual);
$message = $actual->getMessage();
self::assertInstanceOf(PdfSignedMessage::class, $message);
self::assertEquals("test\n", $message->content);
self::assertEquals(0, $message->signatureId);
}
public function testEncode(): void
{
$envelope = new Envelope(
new PdfSignedMessage(0, "test\n")
);
$actual = $this->buildSerializer()->encode($envelope);
self::assertIsArray($actual);
self::assertArrayHasKey('body', $actual);
self::assertArrayHasKey('headers', $actual);
self::assertEquals([], $actual['headers']);
self::assertEquals(<<<'JSON'
{"signatureId":0,"content":"dGVzdAo="}
JSON, $actual['body']);
}
private function buildSerializer(): PdfSignedMessageSerializer
{
return new PdfSignedMessageSerializer();
}
}

View File

@@ -1,137 +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\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessage;
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessageSerializer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class RequestPdfSignMessageSerializerTest extends TestCase
{
public function testEncode(): void
{
$serializer = $this->buildSerializer();
$envelope = new Envelope(
$request = new RequestPdfSignMessage(
0,
new PDFSignatureZone(0, 10.0, 10.0, 180.0, 180.0, new PDFPage(0, 500.0, 800.0)),
0,
'metadata to add to the signature',
'Mme Caroline Diallo',
'abc'
),
);
$actual = $serializer->encode($envelope);
$expectedBody = json_encode([
'signatureId' => $request->signatureId,
'signatureZoneIndex' => $request->signatureZoneIndex,
'signatureZone' => ['x' => 10.0],
'reason' => $request->reason,
'signerText' => $request->signerText,
'content' => base64_encode($request->content),
]);
self::assertIsArray($actual);
self::assertArrayHasKey('body', $actual);
self::assertArrayHasKey('headers', $actual);
self::assertEquals($expectedBody, $actual['body']);
}
public function testDecode(): void
{
$serializer = $this->buildSerializer();
$request = new RequestPdfSignMessage(
0,
new PDFSignatureZone(0, 10.0, 10.0, 180.0, 180.0, new PDFPage(0, 500.0, 800.0)),
0,
'metadata to add to the signature',
'Mme Caroline Diallo',
'abc'
);
$bodyAsString = json_encode([
'signatureId' => $request->signatureId,
'signatureZoneIndex' => $request->signatureZoneIndex,
'signatureZone' => ['x' => 10.0],
'reason' => $request->reason,
'signerText' => $request->signerText,
'content' => base64_encode($request->content),
], JSON_THROW_ON_ERROR);
$actual = $serializer->decode([
'body' => $bodyAsString,
'headers' => [
'Message' => RequestPdfSignMessage::class,
],
]);
self::assertInstanceOf(RequestPdfSignMessage::class, $actual->getMessage());
self::assertEquals($request->signatureId, $actual->getMessage()->signatureId);
self::assertEquals($request->signatureZoneIndex, $actual->getMessage()->signatureZoneIndex);
self::assertEquals($request->reason, $actual->getMessage()->reason);
self::assertEquals($request->signerText, $actual->getMessage()->signerText);
self::assertEquals($request->content, $actual->getMessage()->content);
self::assertNotNull($actual->getMessage()->PDFSignatureZone);
}
private function buildSerializer(): RequestPdfSignMessageSerializer
{
$normalizer =
new class () implements NormalizerInterface {
public function normalize($object, ?string $format = null, array $context = []): array
{
if (!$object instanceof PDFSignatureZone) {
throw new UnexpectedValueException('expected RequestPdfSignMessage');
}
return [
'x' => $object->x,
];
}
public function supportsNormalization($data, ?string $format = null): bool
{
return $data instanceof PDFSignatureZone;
}
};
$denormalizer = new class () implements DenormalizerInterface {
public function denormalize($data, string $type, ?string $format = null, array $context = [])
{
return new PDFSignatureZone(0, 10.0, 10.0, 180.0, 180.0, new PDFPage(0, 500.0, 800.0));
}
public function supportsDenormalization($data, string $type, ?string $format = null)
{
return PDFSignatureZone::class === $type;
}
};
$serializer = new Serializer([$normalizer, $denormalizer]);
return new RequestPdfSignMessageSerializer($serializer, $serializer);
}
}

View File

@@ -1,79 +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 Tests\Service\Signature;
use Chill\DocStoreBundle\Service\Signature\PDFPage;
use PHPUnit\Framework\TestCase;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
/**
* @internal
*
* @coversNothing
*/
class PDFSignatureZoneParserTest extends TestCase
{
private static PDFSignatureZoneParser $parser;
public static function setUpBeforeClass(): void
{
self::$parser = new PDFSignatureZoneParser();
}
/**
* @dataProvider provideFiles
*
* @param list<PDFSignatureZone> $expected
*/
public function testFindSignatureZones(string $filePath, array $expected): void
{
$content = file_get_contents($filePath);
if (false === $content) {
throw new \LogicException("Unable to read file {$filePath}");
}
$actual = self::$parser->findSignatureZones($content);
self::assertEquals(count($expected), count($actual));
foreach ($actual as $index => $signatureZone) {
self::assertObjectEquals($expected[$index], $signatureZone);
}
}
public static function provideFiles(): iterable
{
yield [
__DIR__.'/data/signature_2_signature_page_1.pdf',
[
new PDFSignatureZone(
0,
127.7,
95.289,
90.0,
180.0,
$page = new PDFPage(0, 595.30393, 841.8897)
),
new PDFSignatureZone(
1,
269.5,
95.289,
90.0,
180.0,
$page,
),
],
];
}
}

View File

@@ -202,8 +202,7 @@ final class StoredObjectManagerTest extends TestCase
$response = new SignedUrl(
'PUT',
'https://example.com/'.$storedObject->getFilename(),
new \DateTimeImmutable('1 hours'),
$storedObject->getFilename()
new \DateTimeImmutable('1 hours')
);
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);

View File

@@ -43,7 +43,7 @@ class AsyncFileExistsValidatorTest extends ConstraintValidatorTestCase
$generator = $this->prophesize(TempUrlGeneratorInterface::class);
$generator->generate(Argument::in(['GET', 'HEAD']), Argument::type('string'), Argument::any())
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'), $args[1]));
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours')));
return new AsyncFileExistsValidator($generator->reveal(), $client);
}

View File

@@ -12,25 +12,27 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @implements EntityWorkflowWithStoredObjectHandlerInterface<AccompanyingCourseDocument>
*/
readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface
class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandlerInterface
{
private readonly EntityRepository $repository;
/**
* TODO: injecter le repository directement.
*/
public function __construct(
private TranslatorInterface $translator,
private EntityWorkflowRepository $workflowRepository,
private AccompanyingCourseDocumentRepository $repository
) {}
EntityManagerInterface $em,
private readonly TranslatorInterface $translator
) {
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
}
public function getDeletionRoles(): array
{
@@ -71,6 +73,8 @@ readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkfl
}
/**
* @param AccompanyingCourseDocument $object
*
* @return array[]
*/
public function getRelatedObjects(object $object): array
@@ -118,22 +122,8 @@ readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkfl
return AccompanyingCourseDocument::class === $entityWorkflow->getRelatedEntityClass();
}
public function getAssociatedStoredObject(EntityWorkflow $entityWorkflow): ?StoredObject
{
return $this->getRelatedEntity($entityWorkflow)?->getObject();
}
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool
{
return false;
}
public function findByRelatedEntity(object $object): array
{
if (!$object instanceof AccompanyingCourseDocument) {
return [];
}
return $this->workflowRepository->findByRelatedEntity(AccompanyingCourseDocument::class, $object->getId());
}
}

View File

@@ -5,5 +5,4 @@ module.exports = function(encore)
});
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts');
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index.ts');
};

View File

@@ -1,13 +0,0 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter:
arguments:
$storedObjectVoters: !tagged_iterator stored_object_voter
tags:
- { name: security.voter }
Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter:
tags:
- { name: security.voter }

View File

@@ -31,7 +31,7 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* Class Event.
*/
#[ORM\Entity]
#[ORM\Entity(repositoryClass: \Chill\EventBundle\Repository\EventRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'chill_event_event')]
class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface
@@ -62,9 +62,9 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
private ?string $name = null;
/**
* @var Collection<int, Participation>
* @var Collection<Participation>
*/
#[ORM\OneToMany(mappedBy: 'event', targetEntity: Participation::class)]
#[ORM\OneToMany(targetEntity: Participation::class, mappedBy: 'event')]
private Collection $participations;
#[Assert\NotNull]
@@ -79,7 +79,7 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
private ?Location $location = null;
/**
* @var Collection<int, StoredObject>
* @var Collection<StoredObject>
*/
#[ORM\ManyToMany(targetEntity: StoredObject::class, cascade: ['persist', 'refresh'])]
#[ORM\JoinTable('chill_event_event_documents')]
@@ -192,7 +192,7 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
{
$iterator = iterator_to_array($this->participations->getIterator());
uasort($iterator, static fn ($first, $second) => strnatcasecmp($first->getPerson()->getFirstName(), $second->getPerson()->getFirstName()));
uasort($iterator, static fn ($first, $second) => strnatcasecmp((string) $first->getPerson()->getFirstName(), (string) $second->getPerson()->getFirstName()));
return new \ArrayIterator($iterator);
}

View File

@@ -38,13 +38,13 @@ class EventType
private $name;
/**
* @var Collection<int, Role>
* @var Collection<Role>
*/
#[ORM\OneToMany(mappedBy: 'type', targetEntity: Role::class)]
#[ORM\OneToMany(targetEntity: Role::class, mappedBy: 'type')]
private Collection $roles;
/**
* @var Collection<int, Status>
* @var Collection<Status>
*/
#[ORM\OneToMany(targetEntity: Status::class, mappedBy: 'type')]
private Collection $statuses;

View File

@@ -12,7 +12,7 @@ declare(strict_types=1);
namespace Chill\EventBundle\Form\ChoiceLoader;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Repository\EventRepository;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
@@ -26,6 +26,9 @@ class EventChoiceLoader implements ChoiceLoaderInterface
*/
protected $centers = [];
/**
* @var EntityRepository
*/
protected $eventRepository;
/**
@@ -37,7 +40,7 @@ class EventChoiceLoader implements ChoiceLoaderInterface
* EventChoiceLoader constructor.
*/
public function __construct(
EventRepository $eventRepository,
EntityRepository $eventRepository,
?array $centers = null
) {
$this->eventRepository = $eventRepository;

View File

@@ -11,65 +11,17 @@ declare(strict_types=1);
namespace Chill\EventBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\EventBundle\Entity\Event;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class EventRepository.
*/
class EventRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
class EventRepository extends ServiceEntityRepository
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(ManagerRegistry $registry)
{
$this->repository = $entityManager->getRepository(Event::class);
}
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder
{
return $this->repository->createQueryBuilder($alias, $indexBy);
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->createQueryBuilder('e');
$query = $qb
->join('e.documents', 'ed')
->where('ed.id = :storedObjectId')
->setParameter('storedObjectId', $storedObject->getId())
->getQuery();
return $query->getOneOrNullResult();
}
public function find($id)
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return Event::class;
parent::__construct($registry, Event::class);
}
}

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\EventBundle\Security\Authorization;
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\EventBundle\Entity\Event;
use Chill\EventBundle\Repository\EventRepository;
use Chill\EventBundle\Security\EventVoter;
use Symfony\Component\Security\Core\Security;
class EventStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly EventRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return Event::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => EventVoter::UPDATE,
StoredObjectRoleEnum::SEE => EventVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return false;
}
}

View File

@@ -123,7 +123,6 @@ Role: Rôles
Role creation: Nouveau rôle
Role edit: Modifier un rôle
'': ''
xlsx: xlsx
ods: ods
csv: csv

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Console\Helper\Table;
class DetectTranslationDuplicatesCommand extends Command
{
protected static $defaultName = 'chill:detect-duplicate-translations';
public function __construct(private readonly TranslatorInterface $translator, private readonly KernelInterface $kernel)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setDescription('Detects duplicate translations in YAML files.')
->addOption('locale', null, InputOption::VALUE_REQUIRED, 'Locale to check for duplicate translations', 'en')
->addOption('exclude-namespaces', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, 'Namespaces to exclude from duplicate detection', [])
->addArgument('verify-hash', InputArgument::OPTIONAL, 'The expected hash to verify translation integrity');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$locale = $input->getOption('locale');
$excludedNamespaces = $input->getOption('exclude-namespaces');
$expectedHash = $input->getArgument('verify-hash');
// Loop through all bundles and get the translation directories
foreach ($this->kernel->getBundles() as $bundle) {
$bundlePath = $bundle->getPath();
$translationDir = $this->getTranslationDirectory($bundle->getName(), $bundlePath);
if ($translationDir && is_dir($translationDir)) {
foreach (glob($translationDir . '/*.yaml') as $file) {
$this->translator->addResource('yaml', $file, $locale);
}
}
}
$catalogue = $this->translator->getCatalogue($locale);
$allTranslations = [];
// Iterate through each domain in the catalogue
foreach ($catalogue->all() as $domain => $translations) {
foreach ($translations as $key => $value) {
if ($this->isExcludedNamespace("$domain.$key", $excludedNamespaces)) {
continue;
}
if (is_array($value)) {
$this->flattenTranslation($value, "$domain.$key", $allTranslations);
} else {
if (!isset($allTranslations[$value])) {
$allTranslations[$value] = [];
}
$allTranslations[$value][] = "$domain.$key";
}
}
}
// Detect values that appear in more than one key
$duplicates = array_filter($allTranslations, function ($keys) {
return count($keys) > 1;
});
$duplicatesHash = $this->generateDuplicatesHash($duplicates);
if ($expectedHash) {
if ($duplicatesHash === $expectedHash) {
$output->writeln('<info>Translations are consistent with the expected hash.</info>');
$output->writeln("<info>Current duplicate hash: $duplicatesHash</info>");
return Command::SUCCESS;
} else {
$output->writeln('<error>Translation hash mismatch! Potential duplicate added.</error>');
$this->renderDuplicatesTable($output, $duplicates, $locale);
$output->writeln("<info>Current duplicate hash: $duplicatesHash</info>");
return Command::FAILURE;
}
}
$this->renderDuplicatesTable($output, $duplicates, $locale);
$output->writeln("<info>Current duplicate hash: $duplicatesHash</info>");
return Command::SUCCESS;
}
private function flattenTranslation(array $translations, string $prefix, array &$allTranslations): void
{
foreach ($translations as $key => $value) {
$fullKey = "$prefix.$key";
if (is_array($value)) {
$this->flattenTranslation($value, $fullKey, $allTranslations);
} else {
if (!isset($allTranslations[$value])) {
$allTranslations[$value] = [];
}
$allTranslations[$value][] = $fullKey;
}
}
}
private function getTranslationDirectory(string $bundleName, string $bundlePath): ?string
{
$translationDir = $bundlePath . '/translations';
if ($bundleName === 'ChillAsideActivityBundle') {
$translationDir = $bundlePath . '/src/translations';
}
return is_dir($translationDir) ? $translationDir : null;
}
private function wrapText(string $text, int $width): string
{
return wordwrap($text, $width, "\n", true);
}
private function isExcludedNamespace(string $key, array $excludedNamespaces): bool
{
foreach ($excludedNamespaces as $namespace) {
if (str_starts_with($key, $namespace)) {
return true;
}
}
return false;
}
private function generateDuplicatesHash(array $duplicates): string
{
ksort($duplicates);
foreach ($duplicates as $translation => $keys) {
sort($keys);
}
return hash('md5', serialize($duplicates));
}
private function renderDuplicatesTable(OutputInterface $output, array $duplicates, string $locale): void
{
if (empty($duplicates)) {
$output->writeln("<info>No duplicate translations found for locale '$locale'.</info>");
return;
}
$output->writeln("<comment>Duplicate translations found for locale '$locale':</comment>");
$table = new Table($output);
$table->setHeaders(['Translation', 'Used in Keys']);
foreach ($duplicates as $translation => $keys) {
$wrappedTranslation = $this->wrapText($translation, 40);
$wrappedKeys = $this->wrapText(implode(', ', $keys), 80);
$table->addRow([$wrappedTranslation, $wrappedKeys]);
}
$table->render();
}
}

View File

@@ -96,7 +96,7 @@ class SearchController extends AbstractController
return $this->render('@ChillMain/Search/choose_list.html.twig');
}
#[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json', '_locale' => '[a-z]{1,3}'], defaults: ['_format' => 'html'])]
#[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json'], defaults: ['_format' => 'html'])]
public function searchAction(Request $request, mixed $_format)
{
$pattern = trim((string) $request->query->get('q', ''));

View File

@@ -11,30 +11,25 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowComment;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Form\WorkflowSignatureMetadataType;
use Chill\MainBundle\Form\EntityWorkflowCommentType;
use Chill\MainBundle\Form\WorkflowStepType;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Chill\MainBundle\Security\ChillSecurity;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Registry;
@@ -43,20 +38,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class WorkflowController extends AbstractController
{
public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowRepository $entityWorkflowRepository,
private readonly ValidatorInterface $validator,
private readonly StoredObjectManagerInterface $storedObjectManagerInterface,
private readonly PaginatorFactory $paginatorFactory,
private readonly Registry $registry,
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator,
private readonly ChillSecurity $security,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
private readonly ClockInterface $clock,
private readonly PDFSignatureZoneParser $PDFSignatureZoneParser,
) {}
public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly ValidatorInterface $validator, private readonly PaginatorFactory $paginatorFactory, private readonly Registry $registry, private readonly EntityManagerInterface $entityManager, private readonly TranslatorInterface $translator, private readonly ChillSecurity $security, private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry) {}
#[Route(path: '/{_locale}/main/workflow/create', name: 'chill_main_workflow_create')]
public function create(Request $request): Response
@@ -294,11 +276,10 @@ class WorkflowController extends AbstractController
$handler = $this->entityWorkflowManager->getHandler($entityWorkflow);
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$errors = [];
$signatures = $entityWorkflow->getCurrentStep()->getSignatures();
if (\count($workflow->getEnabledTransitions($entityWorkflow)) > 0) {
// possible transition
$stepDTO = new WorkflowTransitionContextDTO($entityWorkflow);
$usersInvolved = $entityWorkflow->getUsersInvolved();
$currentUserFound = array_search($this->security->getUser(), $usersInvolved, true);
@@ -308,8 +289,9 @@ class WorkflowController extends AbstractController
$transitionForm = $this->createForm(
WorkflowStepType::class,
$stepDTO,
$entityWorkflow->getCurrentStep(),
[
'transition' => true,
'entity_workflow' => $entityWorkflow,
'suggested_users' => $usersInvolved,
]
@@ -328,14 +310,12 @@ class WorkflowController extends AbstractController
throw $this->createAccessDeniedException(sprintf("not allowed to apply transition {$transition}: %s", implode(', ', $msgs)));
}
$byUser = $this->security->getUser();
// TODO symfony 5: add those "future" on context ($workflow->apply($entityWorkflow, $transition, $context)
$entityWorkflow->futureCcUsers = $transitionForm['future_cc_users']->getData() ?? [];
$entityWorkflow->futureDestUsers = $transitionForm['future_dest_users']->getData() ?? [];
$entityWorkflow->futureDestEmails = $transitionForm['future_dest_emails']->getData() ?? [];
$workflow->apply($entityWorkflow, $transition, [
'context' => $stepDTO,
'byUser' => $byUser,
'transition' => $transition,
'transitionAt' => $this->clock->now(),
]);
$workflow->apply($entityWorkflow, $transition);
$this->entityManager->flush();
@@ -347,6 +327,22 @@ class WorkflowController extends AbstractController
}
}
/*
$commentForm = $this->createForm(EntityWorkflowCommentType::class, $newComment = new EntityWorkflowComment());
$commentForm->handleRequest($request);
if ($commentForm->isSubmitted() && $commentForm->isValid()) {
$this->entityManager->persist($newComment);
$this->entityManager->flush();
$this->addFlash('success', $this->translator->trans('workflow.Comment added'));
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
} elseif ($commentForm->isSubmitted() && !$commentForm->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
*/
return $this->render(
'@ChillMain/Workflow/index.html.twig',
[
@@ -356,7 +352,7 @@ class WorkflowController extends AbstractController
'transition_form' => isset($transitionForm) ? $transitionForm->createView() : null,
'entity_workflow' => $entityWorkflow,
'transition_form_errors' => $errors,
'signatures' => $signatures,
// 'comment_form' => $commentForm->createView(),
]
);
}
@@ -375,78 +371,4 @@ class WorkflowController extends AbstractController
return $lines;
}
#[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/metadata', name: 'chill_main_workflow_signature_metadata')]
public function addSignatureMetadata(int $signature_id, Request $request): Response
{
$signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id);
if ($signature->getSigner() instanceof User) {
return $this->redirectToRoute('chill_main_workflow_signature', ['signature_id' => $signature_id]);
}
$metadataForm = $this->createForm(WorkflowSignatureMetadataType::class);
$metadataForm->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]);
$metadataForm->handleRequest($request);
if ($metadataForm->isSubmitted() && $metadataForm->isValid()) {
$data = $metadataForm->getData();
$signature->setSignatureMetadata(
[
'base_signer' => [
'document_type' => $data['documentType'],
'document_number' => $data['documentNumber'],
'expiration_date' => $data['expirationDate'],
],
]
);
$this->entityManager->persist($signature);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_workflow_signature', ['signature_id' => $signature_id]);
}
return $this->render(
'@ChillMain/Workflow/_signature_metadata.html.twig',
[
'metadata_form' => $metadataForm->createView(),
'person' => $signature->getSigner(),
]
);
}
#[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/sign', name: 'chill_main_workflow_signature', methods: 'GET')]
public function addSignature(int $signature_id, Request $request): Response
{
$signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id);
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
throw new NotFoundHttpException('No stored object found');
}
$zones = [];
$content = $this->storedObjectManagerInterface->read($storedObject);
if (null != $content) {
$zones = $this->PDFSignatureZoneParser->findSignatureZones($content);
}
$signatureClient = [];
$signatureClient['id'] = $signature->getId();
$signatureClient['storedObject'] = [
'filename' => $storedObject->getFilename(),
'iv' => $storedObject->getIv(),
'keyInfos' => $storedObject->getKeyInfos(),
];
$signatureClient['zones'] = $zones;
return $this->render(
'@ChillMain/Workflow/_signature_sign.html.twig',
['signature' => $signatureClient]
);
}
}

View File

@@ -85,29 +85,6 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end() // end of notifications
->arrayNode('workflow_signature')
->children()
->arrayNode('base_signer')
->children()
->arrayNode('document_kinds')
->arrayPrototype()
->children()
->scalarNode('key')->cannotBeEmpty()->end()
->arrayNode('labels')
->arrayPrototype()
->children()
->scalarNode('lang')->cannotBeEmpty()->end()
->scalarNode('label')->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end() // end of workflow signature document types
->arrayNode('phone_helper')
->canBeUnset()
->children()

View File

@@ -92,7 +92,7 @@ class Address implements TrackCreationInterface, TrackUpdateInterface
* This list is computed by a materialized view. It won't be populated until a refresh is done
* on the materialized view.
*
* @var Collection<int, GeographicalUnit>
* @var Collection<GeographicalUnit>
*
* @readonly
*/

View File

@@ -21,9 +21,9 @@ use Symfony\Component\Serializer\Annotation as Serializer;
class Center implements HasCenterInterface, \Stringable
{
/**
* @var Collection<int, GroupCenter>
* @var Collection<GroupCenter>
*/
#[ORM\OneToMany(mappedBy: 'center', targetEntity: GroupCenter::class)]
#[ORM\OneToMany(targetEntity: GroupCenter::class, mappedBy: 'center')]
private Collection $groupCenters;
#[Serializer\Groups(['docgen:read'])]
@@ -40,7 +40,7 @@ class Center implements HasCenterInterface, \Stringable
private bool $isActive = true;
/**
* @var Collection<int, Regroupment>
* @var Collection<Regroupment>
*/
#[ORM\ManyToMany(targetEntity: Regroupment::class, mappedBy: 'centers')]
private Collection $regroupments;

View File

@@ -36,9 +36,9 @@ class GeographicalUnitLayer
private string $refId = '';
/**
* @var Collection<int, GeographicalUnit>
* @var Collection<GeographicalUnit>
*/
#[ORM\OneToMany(mappedBy: 'layer', targetEntity: GeographicalUnit::class)]
#[ORM\OneToMany(targetEntity: GeographicalUnit::class, mappedBy: 'layer')]
private Collection $units;
public function __construct()

View File

@@ -34,7 +34,7 @@ class GroupCenter
private ?PermissionsGroup $permissionsGroup = null;
/**
* @var Collection<int, User>
* @var Collection<User::class>
*/
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'groupCenters')]
private Collection $users;

View File

@@ -30,7 +30,7 @@ class Notification implements TrackUpdateInterface
private array $addedAddresses = [];
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_notification_addresses_user')]
@@ -54,9 +54,9 @@ class Notification implements TrackUpdateInterface
private ?ArrayCollection $addressesOnLoad = null;
/**
* @var Collection<int, NotificationComment>
* @var Collection<NotificationComment>
*/
#[ORM\OneToMany(mappedBy: 'notification', targetEntity: NotificationComment::class, orphanRemoval: true)]
#[ORM\OneToMany(targetEntity: NotificationComment::class, mappedBy: 'notification', orphanRemoval: true)]
#[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])]
private Collection $comments;
@@ -88,7 +88,7 @@ class Notification implements TrackUpdateInterface
private string $title = '';
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')]

View File

@@ -28,9 +28,9 @@ class PermissionsGroup
private array $flags = [];
/**
* @var Collection<int, GroupCenter>
* @var Collection<GroupCenter>
*/
#[ORM\OneToMany(mappedBy: 'permissionsGroup', targetEntity: GroupCenter::class)]
#[ORM\OneToMany(targetEntity: GroupCenter::class, mappedBy: 'permissionsGroup')]
private Collection $groupCenters;
#[ORM\Id]
@@ -42,7 +42,7 @@ class PermissionsGroup
private string $name = '';
/**
* @var Collection<int, RoleScope>
* @var Collection<RoleScope>
*/
#[ORM\ManyToMany(targetEntity: RoleScope::class, inversedBy: 'permissionsGroups', cascade: ['persist'])]
#[ORM\Cache(usage: 'NONSTRICT_READ_WRITE')]

View File

@@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM;
class Regroupment
{
/**
* @var Collection<int, Center>
* @var Collection<Center>
*/
#[ORM\ManyToMany(targetEntity: Center::class, inversedBy: 'regroupments')]
#[ORM\Id]

View File

@@ -26,7 +26,7 @@ class RoleScope
private ?int $id = null;
/**
* @var Collection<int, PermissionsGroup>
* @var Collection<PermissionsGroup>
*/
#[ORM\ManyToMany(targetEntity: PermissionsGroup::class, mappedBy: 'roleScopes')]
private Collection $permissionsGroups;

View File

@@ -42,9 +42,9 @@ class Scope
private array $name = [];
/**
* @var Collection<int, RoleScope>
* @var Collection<RoleScope>
*/
#[ORM\OneToMany(mappedBy: 'scope', targetEntity: RoleScope::class)]
#[ORM\OneToMany(targetEntity: RoleScope::class, mappedBy: 'scope')]
#[ORM\Cache(usage: 'NONSTRICT_READ_WRITE')]
private Collection $roleScopes;

View File

@@ -64,7 +64,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
private bool $enabled = true;
/**
* @var Collection<int, \Chill\MainBundle\Entity\GroupCenter>
* @var Collection<GroupCenter>
*/
#[ORM\ManyToMany(targetEntity: GroupCenter::class, inversedBy: 'users')]
#[ORM\Cache(usage: 'NONSTRICT_READ_WRITE')]
@@ -83,9 +83,9 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
private ?Location $mainLocation = null;
/**
* @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\User\UserScopeHistory>&Selectable
* @var Collection&Selectable<int, UserScopeHistory>
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserScopeHistory::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OneToMany(targetEntity: UserScopeHistory::class, mappedBy: 'user', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection&Selectable $scopeHistories;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
@@ -98,9 +98,9 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
private ?string $salt = null;
/**
* @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\User\UserJobHistory>&Selectable
* @var Collection&Selectable<int, UserJobHistory>
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserJobHistory::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OneToMany(targetEntity: UserJobHistory::class, mappedBy: 'user', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection&Selectable $jobHistories;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 80)]

View File

@@ -17,9 +17,9 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Order;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
@@ -35,7 +35,36 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
use TrackUpdateTrait;
/**
* @var Collection<int, \Chill\MainBundle\Entity\Workflow\EntityWorkflowComment>
* a list of future cc users for the next steps.
*
* @var array|User[]
*/
public array $futureCcUsers = [];
/**
* a list of future dest emails for the next steps.
*
* This is in used in order to let controller inform who will be the future emails which will validate
* the next step. This is necessary to perform some computation about the next emails, before they are
* associated to the entity EntityWorkflowStep.
*
* @var array|string[]
*/
public array $futureDestEmails = [];
/**
* a list of future dest users for the next steps.
*
* This is in used in order to let controller inform who will be the future users which will validate
* the next step. This is necessary to perform some computation about the next users, before they are
* associated to the entity EntityWorkflowStep.
*
* @var array|User[]
*/
public array $futureDestUsers = [];
/**
* @var Collection<EntityWorkflowComment>
*/
#[ORM\OneToMany(targetEntity: EntityWorkflowComment::class, mappedBy: 'entityWorkflow', orphanRemoval: true)]
private Collection $comments;
@@ -52,10 +81,10 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
private int $relatedEntityId;
/**
* @var Collection<int, EntityWorkflowStep>
* @var Collection<EntityWorkflowStep>
*/
#[Assert\Valid(traverse: true)]
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist'], orphanRemoval: true)]
#[ORM\OneToMany(targetEntity: EntityWorkflowStep::class, mappedBy: 'entityWorkflow', orphanRemoval: true, cascade: ['persist'])]
#[ORM\OrderBy(['transitionAt' => \Doctrine\Common\Collections\Criteria::ASC, 'id' => 'ASC'])]
private Collection $steps;
@@ -65,14 +94,14 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
private ?array $stepsChainedCache = null;
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_subscriber_to_final')]
private Collection $subscriberToFinal;
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_subscriber_to_step')]
@@ -247,16 +276,12 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return $this->steps;
}
/**
* @throws \Exception
*/
public function getStepsChained(): array
{
if (\is_array($this->stepsChainedCache)) {
return $this->stepsChainedCache;
}
/** @var \ArrayIterator $iterator */
$iterator = $this->steps->getIterator();
$current = null;
$steps = [];
@@ -417,43 +442,11 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
*
* @return $this
*/
public function setStep(
string $step,
WorkflowTransitionContextDTO $transitionContextDTO,
string $transition,
\DateTimeImmutable $transitionAt,
?User $byUser = null
): self {
$previousStep = $this->getCurrentStep();
$previousStep
->setTransitionAfter($transition)
->setTransitionAt($transitionAt)
->setTransitionBy($byUser);
public function setStep(string $step): self
{
$newStep = new EntityWorkflowStep();
$newStep->setCurrentStep($step);
foreach ($transitionContextDTO->futureCcUsers as $user) {
$newStep->addCcUser($user);
}
foreach ($transitionContextDTO->futureDestUsers as $user) {
$newStep->addDestUser($user);
}
foreach ($transitionContextDTO->futureDestEmails as $email) {
$newStep->addDestEmail($email);
}
if (null !== $transitionContextDTO->futureUserSignature) {
new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature);
} else {
foreach ($transitionContextDTO->futurePersonSignatures as $personSignature) {
new EntityWorkflowStepSignature($newStep, $personSignature);
}
}
// copy the freeze
if ($this->isFreeze()) {
$newStep->setFreezeAfter(true);

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity\Workflow;
enum EntityWorkflowSignatureStateEnum: string
{
case PENDING = 'pending';
case SIGNED = 'signed';
case REJECTED = 'rejected';
case CANCELED = 'canceled';
}

View File

@@ -26,7 +26,7 @@ class EntityWorkflowStep
private string $accessKey;
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_cc_user')]
@@ -42,25 +42,19 @@ class EntityWorkflowStep
private array $destEmail = [];
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user')]
private Collection $destUser;
/**
* @var Collection<int, User>
* @var Collection<User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user_by_accesskey')]
private Collection $destUserByAccessKey;
/**
* @var Collection <int, EntityWorkflowStepSignature>
*/
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepSignature::class, cascade: ['persist'], orphanRemoval: true)]
private Collection $signatures;
#[ORM\ManyToOne(targetEntity: EntityWorkflow::class, inversedBy: 'steps')]
private ?EntityWorkflow $entityWorkflow = null;
@@ -103,7 +97,6 @@ class EntityWorkflowStep
$this->ccUser = new ArrayCollection();
$this->destUser = new ArrayCollection();
$this->destUserByAccessKey = new ArrayCollection();
$this->signatures = new ArrayCollection();
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(32));
}
@@ -143,27 +136,6 @@ class EntityWorkflowStep
return $this;
}
/**
* @internal use @see{EntityWorkflowStepSignature}'s constructor instead
*/
public function addSignature(EntityWorkflowStepSignature $signature): self
{
if (!$this->signatures->contains($signature)) {
$this->signatures[] = $signature;
}
return $this;
}
public function removeSignature(EntityWorkflowStepSignature $signature): self
{
if ($this->signatures->contains($signature)) {
$this->signatures->removeElement($signature);
}
return $this;
}
public function getAccessKey(): string
{
return $this->accessKey;
@@ -226,14 +198,6 @@ class EntityWorkflowStep
return $this->entityWorkflow;
}
/**
* @return Collection<int, EntityWorkflowStepSignature>
*/
public function getSignatures(): Collection
{
return $this->signatures;
}
public function getId(): ?int
{
return $this->id;

View File

@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_workflow_entity_step_signature')]
class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, unique: true)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
private ?User $userSigner = null;
#[ORM\ManyToOne(targetEntity: Person::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Person $personSigner = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 50, nullable: false, enumType: EntityWorkflowSignatureStateEnum::class)]
private EntityWorkflowSignatureStateEnum $state = EntityWorkflowSignatureStateEnum::PENDING;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIMETZ_IMMUTABLE, nullable: true, options: ['default' => null])]
private ?\DateTimeImmutable $stateDate = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
private array $signatureMetadata = [];
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true, options: ['default' => null])]
private ?int $zoneSignatureIndex = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class, inversedBy: 'signatures')]
private EntityWorkflowStep $step,
User|Person $signer,
) {
$this->step->addSignature($this);
$this->setSigner($signer);
}
private function setSigner(User|Person $signer): void
{
if ($signer instanceof User) {
$this->userSigner = $signer;
} else {
$this->personSigner = $signer;
}
}
public function getId(): ?int
{
return $this->id;
}
public function getStep(): EntityWorkflowStep
{
return $this->step;
}
public function getSigner(): User|Person
{
if (null !== $this->userSigner) {
return $this->userSigner;
}
return $this->personSigner;
}
public function getSignatureMetadata(): array
{
return $this->signatureMetadata;
}
public function setSignatureMetadata(array $signatureMetadata): EntityWorkflowStepSignature
{
$this->signatureMetadata = $signatureMetadata;
return $this;
}
public function getState(): EntityWorkflowSignatureStateEnum
{
return $this->state;
}
public function setState(EntityWorkflowSignatureStateEnum $state): EntityWorkflowStepSignature
{
$this->state = $state;
return $this;
}
public function getStateDate(): ?\DateTimeImmutable
{
return $this->stateDate;
}
public function setStateDate(?\DateTimeImmutable $stateDate): EntityWorkflowStepSignature
{
$this->stateDate = $stateDate;
return $this;
}
public function getZoneSignatureIndex(): ?int
{
return $this->zoneSignatureIndex;
}
public function setZoneSignatureIndex(?int $zoneSignatureIndex): EntityWorkflowStepSignature
{
$this->zoneSignatureIndex = $zoneSignatureIndex;
return $this;
}
}

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class WorkflowSignatureMetadataType extends AbstractType
{
public function __construct(private readonly ParameterBagInterface $parameterBag, private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$documentTypeChoices = $this->parameterBag->get('chill_main')['workflow_signature']['base_signer']['document_kinds'];
$choices = [];
foreach ($documentTypeChoices as $documentType) {
$labels = [];
foreach ($documentType['labels'] as $label) {
$labels[$label['lang']] = $label['label'];
}
$localizedLabel = $this->translatableStringHelper->localize($labels);
if (null !== $localizedLabel) {
$choices[$localizedLabel] = $documentType['key'];
}
}
$builder
->add('documentType', ChoiceType::class, [
'label' => 'workflow.signature_zone.metadata.docType',
'expanded' => false,
'required' => true,
'choices' => $choices,
])
->add('documentNumber', TextType::class, [
'required' => true,
'label' => 'workflow.signature_zone.metadata.docNumber',
])
->add('expirationDate', ChillDateType::class, [
'required' => true,
'input' => 'datetime_immutable',
'label' => 'workflow.signature_zone.metadata.docExpiration',
]);
}
}

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