Compare commits

..

35 Commits

Author SHA1 Message Date
8bc46d4af3 Add changie 2025-10-06 13:38:18 +02:00
09aa8bc829 Translations: change 'centre' into 'territoire' throughout application 2025-10-06 12:38:27 +02:00
6cc6cf3a71 Translations: change 'cercle' into 'service' throughout application 2025-10-06 12:25:37 +02:00
bc2fbee5c6 Fix: notification edit template
form field addressesEmail removed
2025-10-06 12:14:00 +02:00
ebd10ca522 Merge branch 'fix/history-of-versions-stored-object' into 'master'
Fix the rendering of storedObject's history

See merge request Chill-Projet/chill-bundles!893
2025-10-03 20:47:06 +00:00
d3a31be412 Fix re-ordering of StoredObjectVersion in the list of versions
As some intermediate versions are remove, this may lead to situation where the indexes are not continous. In that case, the array is not a list, and is rendered as an array with numeric indexes, instead of a list of elements. The HistoryListItem component fails to render.

- Ensured proper handling of removed versions by using `array_values` to reindex items.
- Added test case to validate the result after removing a version.
- Asserted the results are a proper list in the API response.
2025-10-03 22:40:59 +02:00
d159a82f88 Update import paths in HistoryButtonListItem.vue to use aliases
- Changed types import to use `ChillDocStoreAssets/types`.
- Updated `ISOToDatetime` import to use `ChillMainAssets/chill/js/date`.
2025-10-03 22:20:51 +02:00
c2d9c73fd4 Release v4.5.1 2025-10-03 14:11:41 +02:00
0d6d15fcf7 Merge branch 'fix/conversion-exception' into 'master'
Introduce `ConversionWithSameMimeTypeException` for improved error handling in document conversion.

See merge request Chill-Projet/chill-bundles!892
2025-10-03 12:10:24 +00:00
f9ad96c78b Introduce ConversionWithSameMimeTypeException for improved error handling in document conversion.
- Added the `ConversionWithSameMimeTypeException` to handle cases where document conversion is requested for the same MIME type.
- Updated `StoredObjectToPdfConverter` to throw the new exception when encountering such cases.
- Enhanced error logging in `PostSendExternalMessageHandler` to capture these specific conversion errors.
2025-10-03 13:57:06 +02:00
fcc9529a20 Add missing javascript dependency in package.json 2025-10-03 13:56:20 +02:00
955cb817c4 Release v4.5.0 2025-10-03 12:09:17 +02:00
823f9546b9 Merge branch '421-signature-fixes' into 'master'
Signature fixes

Closes #421

See merge request Chill-Projet/chill-bundles!887
2025-10-03 09:49:34 +00:00
be39fa16e7 Signature fixes 2025-10-03 09:49:33 +00:00
c8bb7575e7 Merge branch '426-increase_nb_chars_to_14_chill_password' into 'master'
#426 Increased the number of required characters when setting a new password in Chill

Closes #426

See merge request Chill-Projet/chill-bundles!883
2025-09-19 07:03:51 +00:00
juminet
80a3734171 #426 Increased the number of required characters when setting a new password in Chill 2025-09-19 07:03:51 +00:00
ab98f3a102 Release v4.4.2 2025-09-12 12:47:06 +02:00
7516e68d77 Merge branch 'fix/docgen-after-accp-work-refacto' into 'master'
Fix document generation and workflow generation do not work on accompanying period work documents

See merge request Chill-Projet/chill-bundles!880
2025-09-12 10:42:34 +00:00
7b60b7a8af Fix document generation and workflow generation do not work on accompanying period work documents 2025-09-12 10:42:34 +00:00
d984dec7db Release v4.4.1 2025-09-11 16:26:51 +02:00
46a4dedab8 Merge branch 'missing_commit_duplicate_evaluation' into 'master'
Fix translations and close button modal for duplicate evaluation document

See merge request Chill-Projet/chill-bundles!878
2025-09-11 14:21:05 +00:00
db98519e65 Fix translations and close button modal for duplicate evaluation document 2025-09-11 14:21:05 +00:00
c39637180a Release v4.4.0 2025-09-11 13:04:50 +02:00
15f9409bc8 Merge branch '369-duplicate-evaluation-document' into 'master'
Resolve "Dupliquer une document d'une évaluation vers une autre" + "Déplacer un document vers une autre évaluation"

Closes #369

See merge request Chill-Projet/chill-bundles!813
2025-09-11 11:01:16 +00:00
5b90d23367 Resolve "Dupliquer une document d'une évaluation vers une autre" + "Déplacer un document vers une autre évaluation" 2025-09-11 11:01:16 +00:00
c48625d1cd Merge branch 'bug/1607-the-user-preferences-for-notification-in-profile-are-not-shown-correctly' into 'master'
Resolve "user notification preferences are not displayed correctly"

See merge request Chill-Projet/chill-bundles!877
2025-09-10 16:28:45 +00:00
1195b54a68 Resolve "user notification preferences are not displayed correctly" 2025-09-10 16:28:45 +00:00
2a280b814f Refactor view templates: relocate 'merge' action block and standardize 'duplicate link' block handling 2025-09-09 17:36:46 +02:00
230c758255 Update bundles to v4.3.0 2025-09-08 16:05:09 +02:00
eafda987ae Merge branch '412-absence-enddate' into 'master'
Resolve "Absence user: add end date"

Closes #412

See merge request Chill-Projet/chill-bundles!865
2025-09-08 13:47:14 +00:00
7db8a371fc Resolve "Absence user: add end date" 2025-09-08 13:47:14 +00:00
0d0649dd31 Change route URL to avoid clash with person duplicate controller method 2025-09-08 14:51:54 +02:00
ac12b8cdcf Merge branch 'add-permission-list-command' into 'master'
Add `RoleDumper` and `DumpListPermissionsCommand` to generate a markdown list of permissions

See merge request Chill-Projet/chill-bundles!874
2025-09-05 16:55:45 +00:00
9c1611d052 Add RoleDumper and DumpListPermissionsCommand to generate a markdown list of permissions 2025-09-05 16:55:45 +00:00
90e3043c3d Junie guidelines: fix grammar and typos in development guidelines 2025-09-04 17:26:55 +02:00
145 changed files with 4235 additions and 1416 deletions

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
time: 2025-10-03T22:40:44.685474863+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: 'Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists'
time: 2025-10-06T12:13:15.45905994+02:00
custom:
Issue: "434"
SchemaChange: No schema change

View File

@@ -0,0 +1,6 @@
kind: UX
body: Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively.
time: 2025-10-06T12:39:32.514056818+02:00
custom:
Issue: "425"
SchemaChange: No schema change

View File

@@ -16,5 +16,5 @@
- ajout d'un filtre et regroupement par usager participant sur les échanges - ajout d'un filtre et regroupement par usager participant sur les échanges
- ajout d'un regroupement: par type d'activité associé au parcours; - ajout d'un regroupement: par type d'activité associé au parcours;
- trie les filtre et regroupements par ordre alphabétique dans els exports - trie les filtre et regroupements par ordre alphabétique dans els exports
- ajout d'un paramètre qui permet de désactiver le filtre par centre dans les exports - ajout d'un paramètre qui permet de désactiver le filtre par territoire dans les exports
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date" - correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"

View File

@@ -29,7 +29,7 @@
- ajout d'un regroupement par métier des intervenants sur un parcours; - ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours; - ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours - ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par centre de l'usager"; - ajout d'un regroupement "par territoire de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours"; - ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours"; - ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot); - création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);

10
.changes/v4.3.0.md Normal file
View File

@@ -0,0 +1,10 @@
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method

8
.changes/v4.4.0.md Normal file
View File

@@ -0,0 +1,8 @@
## v4.4.0 - 2025-09-11
### Feature
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
### Fixed
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
* Fix saving notification preferences in user's profile

3
.changes/v4.4.1.md Normal file
View File

@@ -0,0 +1,3 @@
## v4.4.1 - 2025-09-11
### Fixed
* fix translations in duplicate evaluation document modal and realign close modal button

3
.changes/v4.4.2.md Normal file
View File

@@ -0,0 +1,3 @@
## v4.4.2 - 2025-09-12
### Fixed
* Fix document generation and workflow generation do not work on accompanying period work documents

13
.changes/v4.5.0.md Normal file
View File

@@ -0,0 +1,13 @@
## v4.5.0 - 2025-10-03
### Feature
* Only allow delete of attachment on workflows that are not final
* Move up signature buttons on index workflow page for easier access
* Filter out document from attachment list if it is the same as the workflow document
* Block edition on attached document on workflow, if the workflow is finalized or sent external
* Convert workflow's attached document to pdf while sending them external
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
### Fixed
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
* Fix permissions on storedObject which are subject by a workflow
### DX
* Introduce a WaitingScreen component to display a waiting screen

4
.changes/v4.5.1.md Normal file
View File

@@ -0,0 +1,4 @@
## v4.5.1 - 2025-10-03
### Fixed
* Add missing javascript dependency
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf

View File

@@ -27,11 +27,11 @@ Chill is a comprehensive web application built as a set of Symfony bundles. It i
## Project Structure ## Project Structure
Note: This is a project which exists from a long time ago, and we found multiple structure inside each bundle. When having the choice, the developers should choose the new structure. Note: This is a project that's existed for a long time, and throughout the years we've used multiple structures inside each bundle. When having the choice, the developers should choose the new structure.
The project follows a standard Symfony bundle structure: The project follows a standard Symfony bundle structure:
- `/src/Bundle/`: Contains all the Chill bundles. The code is either at the root of the bundle directory, or within a `src/` directory (preferred). See psr4 mapping at the root's `composer.json`. - `/src/Bundle/`: Contains all the Chill bundles. The code is either at the root of the bundle directory, or within a `src/` directory (preferred). See psr4 mapping at the root's `composer.json`.
- each bundle come with his own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside to the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way). - each bundle comes with its own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way).
- `/docs/`: Contains project documentation - `/docs/`: Contains project documentation
Each bundle typically has the following structure: Each bundle typically has the following structure:
@@ -46,13 +46,13 @@ Each bundle typically has the following structure:
### A special word about TicketBundle ### A special word about TicketBundle
The ticket bundle is developed using a kind of "Command" pattern. The controller fill a "Command", and a "CommandHandler" handle this command. They are savec in the `src/Bundle/ChillTicketBundle/src/Action` directory. The ticket bundle is developed using a kind of "Command" pattern. The controller fills a "Command," and a "CommandHandler" handles this command. They are saved in the `src/Bundle/ChillTicketBundle/src/Action` directory.
## Development Guidelines ## Development Guidelines
### Building and Configuration Instructions ### Building and Configuration Instructions
All the command should be run through the `symfony` command, which will configure the required variables. All the commands should be run through the `symfony` command, which will configure the required variables.
For assets, we must ensure that we use node at version `^20.0.0`. This is done using `nvm use 20`. For assets, we must ensure that we use node at version `^20.0.0`. This is done using `nvm use 20`.
@@ -87,7 +87,7 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u
docker compose up -d docker compose up -d
``` ```
5. **Set Up the Database**: 6. **Set Up the Database**:
```bash ```bash
# Create the database # Create the database
symfony console doctrine:database:create symfony console doctrine:database:create
@@ -99,20 +99,20 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u
symfony console doctrine:fixtures:load symfony console doctrine:fixtures:load
``` ```
6. **Build Assets**: 7. **Build Assets**:
```bash ```bash
nvm use 20 nvm use 20
yarn run encore dev yarn run encore dev
``` ```
7. **Start the Development Server**: 8. **Start the Development Server**:
```bash ```bash
symfony server:start -d symfony server:start -d
``` ```
#### Docker Setup #### Docker Setup
The project includes Docker configuration for easier development: The project includes a Docker configuration for easier development:
1. **Start Docker Services**: 1. **Start Docker Services**:
```bash ```bash
@@ -153,9 +153,9 @@ Key configuration files:
Each time a doctrine entity is created, we generate migration to adapt the database. Each time a doctrine entity is created, we generate migration to adapt the database.
The migration are created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace <namespace>`, where the namespace is the relevant namespace for migration. As this is a bash script, do not forget to quote the `\` (`\` must become `\\` in your command). The migration is created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace <namespace>`, where the namespace is the relevant namespace for migration. As this is a bash script, remember to quote the `\` (`\` must become `\\` in your command).
Each bundle has his own namespace for migration (always ask me to confirm that command, with a list of updated / created entities so that I can confirm you that it is ok): Each bundle has his own namespace for migration (always ask me to confirm that command with a list of updated / created entities so that I can confirm to you that it is ok):
- `Chill\Bundle\ActivityBundle` writes migrations to `Chill\Migrations\Activity`; - `Chill\Bundle\ActivityBundle` writes migrations to `Chill\Migrations\Activity`;
- `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`; - `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`;
@@ -183,7 +183,7 @@ Once created the, comment's classes should be removed and a description of the c
When we need to use a DateTime or DateTimeImmutable that need to express "now", we prefer the usage of When we need to use a DateTime or DateTimeImmutable that need to express "now", we prefer the usage of
`Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities, `Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities,
where injection does not work when restoring an entity from database, but usually possible in services. where injection does not work when restoring an entity from a database, but usually possible in services.
In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface` In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface`
where we have full and easy control of the date. where we have full and easy control of the date.
@@ -198,9 +198,9 @@ The project uses PHPUnit for testing. Each bundle has its own test suite, and th
For creating mock, we prefer using prophecy (library phpspec/prophecy). For creating mock, we prefer using prophecy (library phpspec/prophecy).
##### Useful helpers and tips that avoid create a mock ##### Useful helpers and tips that avoid creating a mock
Some notable implementations that are tests helper, and avoid to create a mock: Some notable implementations that are test helpers and avoid creating a mock:
- `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`; - `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`;
- `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above); - `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above);
@@ -297,7 +297,7 @@ class TicketTest extends TestCase
#### Test Database #### Test Database
For tests that require a database, the project uses postgresql database filled by fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file. For tests that require a database, the project uses a postgresql database filled with fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file.
### Code Quality Tools ### Code Quality Tools

View File

@@ -6,6 +6,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.5.1 - 2025-10-03
### Fixed
* Add missing javascript dependency
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf
## v4.5.0 - 2025-10-03
### Feature
* Only allow delete of attachment on workflows that are not final
* Move up signature buttons on index workflow page for easier access
* Filter out document from attachment list if it is the same as the workflow document
* Block edition on attached document on workflow, if the workflow is finalized or sent external
* Convert workflow's attached document to pdf while sending them external
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
### Fixed
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
* Fix permissions on storedObject which are subject by a workflow
### DX
* Introduce a WaitingScreen component to display a waiting screen
## v4.4.2 - 2025-09-12
### Fixed
* Fix document generation and workflow generation do not work on accompanying period work documents
## v4.4.1 - 2025-09-11
### Fixed
* fix translations in duplicate evaluation document modal and realign close modal button
## v4.4.0 - 2025-09-11
### Feature
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
### Fixed
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
* Fix saving notification preferences in user's profile
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method
## v4.2.1 - 2025-09-03 ## v4.2.1 - 2025-09-03
### Fixed ### Fixed
* Fix exports to work with DirectExportInterface * Fix exports to work with DirectExportInterface
@@ -714,7 +761,7 @@ Fix color of Chill footer
- ajout d'un filtre et regroupement par usager participant sur les échanges - ajout d'un filtre et regroupement par usager participant sur les échanges
- ajout d'un regroupement: par type d'activité associé au parcours; - ajout d'un regroupement: par type d'activité associé au parcours;
- trie les filtre et regroupements par ordre alphabétique dans els exports - trie les filtre et regroupements par ordre alphabétique dans els exports
- ajout d'un paramètre qui permet de désactiver le filtre par centre dans les exports - ajout d'un paramètre qui permet de désactiver le filtre par territoire dans les exports
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date" - correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"
## v2.9.2 - 2023-10-17 ## v2.9.2 - 2023-10-17
@@ -894,7 +941,7 @@ error when trying to reedit a saved export
- ajout d'un regroupement par métier des intervenants sur un parcours; - ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours; - ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours - ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par centre de l'usager"; - ajout d'un regroupement "par territoire de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours"; - ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours"; - ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot); - création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);

View File

@@ -23,8 +23,8 @@ class "Document" {
- text description - text description
- ArrayCollection_DocumentCategory categories - ArrayCollection_DocumentCategory categories
- varchar_150 content #link to openstack - varchar_150 content #link to openstack
- Center center - Territoire territoire
- Cercle cercle - Service service
- User user - User user
- DateTime date # Creation date - DateTime date # Creation date
} }

View File

@@ -38,7 +38,7 @@ Certaines données sont historisées:
- les référents d'un parcours; - les référents d'un parcours;
- les statuts d'un parcours; - les statuts d'un parcours;
- la liaison entre les centres et les usagers; - la liaison entre les territoires et les usagers;
- etc. - etc.
Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable. Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable.

View File

@@ -1,6 +1,6 @@
order,table_schema,table_name,commentaire order,table_schema,table_name,commentaire
1,chill_3party,party_category,Catégorie de tiers 1,chill_3party,party_category,Catégorie de tiers
2,chill_3party,party_center,Association entre les tiers et les centres (déprécié) 2,chill_3party,party_center,Association entre les tiers et les territoires (déprécié)
3,chill_3party,party_profession,Profession du tiers (déprécié) 3,chill_3party,party_profession,Profession du tiers (déprécié)
4,chill_3party,third_party,Tiers 4,chill_3party,third_party,Tiers
5,chill_3party,thirdparty_category,association tiers - catégories 5,chill_3party,thirdparty_category,association tiers - catégories
@@ -54,7 +54,7 @@ order,table_schema,table_name,commentaire
53,public,activitytpresence,Présence aux échanges 53,public,activitytpresence,Présence aux échanges
54,public,activitytype,Types d'échanges 54,public,activitytype,Types d'échanges
55,public,activitytypecategory,Catégories de types d'échanges 55,public,activitytypecategory,Catégories de types d'échanges
56,public,centers,"Centres (territoires, agences, etc.)" 56,public,centers,"Territoires (territoires, agences, etc.)"
57,public,chill_activity_activity_chill_person_socialaction, 57,public,chill_activity_activity_chill_person_socialaction,
58,public,chill_activity_activity_chill_person_socialissue 58,public,chill_activity_activity_chill_person_socialissue
59,public,chill_docgen_template,Gabarits de documents 59,public,chill_docgen_template,Gabarits de documents
@@ -111,7 +111,7 @@ order,table_schema,table_name,commentaire
110,public,chill_person_marital_status,Etats civils 110,public,chill_person_marital_status,Etats civils
111,public,chill_person_not_duplicate, 111,public,chill_person_not_duplicate,
112,public,chill_person_person,Usagers 112,public,chill_person_person,Usagers
113,public,chill_person_person_center_history,Historique des centres d'un usagers 113,public,chill_person_person_center_history,Historique des territoires d'un usagers
114,public,chill_person_persons_to_addresses,Déprécié 114,public,chill_person_persons_to_addresses,Déprécié
115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager 115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager
116,public,chill_person_relations,Types de relations de filiation 116,public,chill_person_relations,Types de relations de filiation
@@ -142,7 +142,7 @@ order,table_schema,table_name,commentaire
141,public,permission_groups 141,public,permission_groups
142,public,permissionsgroup_rolescope 142,public,permissionsgroup_rolescope
143,public,persons_spoken_languages 143,public,persons_spoken_languages
144,public,regroupment,Regroupement de centres 144,public,regroupment,Regroupement de territoires
145,public,regroupment_center, 145,public,regroupment_center,
146,public,role_scopes, 146,public,role_scopes,
147,public,scopes,Services 147,public,scopes,Services
Can't render this file because it has a wrong number of fields in line 28.

View File

@@ -55,6 +55,7 @@
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.3", "@types/leaflet": "^1.9.3",
"@vueuse/core": "^13.9.0",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"dropzone": "^5.7.6", "dropzone": "^5.7.6",
"es6-promise": "^4.2.8", "es6-promise": "^4.2.8",

View File

@@ -10,7 +10,7 @@ Attendee: Présence de l'usager
attendee: présence de l'usager attendee: présence de l'usager
list_reasons: liste des sujets list_reasons: liste des sujets
user_username: nom de l'utilisateur user_username: nom de l'utilisateur
circle_name: nom du cercle circle_name: nom du service
Remark: Commentaire Remark: Commentaire
No comments: Aucun commentaire No comments: Aucun commentaire
Add a new activity: Ajouter une nouvel échange Add a new activity: Ajouter une nouvel échange
@@ -20,7 +20,7 @@ not present: absent
Delete: Supprimer Delete: Supprimer
Update: Mettre à jour Update: Mettre à jour
Update activity: Modifier l'échange Update activity: Modifier l'échange
Scope: Cercle Scope: Service
Activity data: Données de l'échange Activity data: Données de l'échange
Activity location: Localisation de l'échange Activity location: Localisation de l'échange
No reason associated: Aucun sujet No reason associated: Aucun sujet
@@ -398,7 +398,7 @@ export:
sent received: Envoyé ou reçu sent received: Envoyé ou reçu
emergency: Urgence emergency: Urgence
accompanying course id: Identifiant du parcours accompanying course id: Identifiant du parcours
course circles: Cercles du parcours course circles: Services du parcours
travelTime: Durée de déplacement travelTime: Durée de déplacement
durationTime: Durée durationTime: Durée
id: Identifiant id: Identifiant

View File

@@ -55,5 +55,6 @@
</dl> </dl>
{% endblock %} {% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% endembed %} {% endembed %}
{% endblock %} {% endblock %}

View File

@@ -177,7 +177,7 @@ export:
agent_id: Utilisateur agent_id: Utilisateur
creator_id: Créateur creator_id: Créateur
main_scope: Service principal de l'utilisateur main_scope: Service principal de l'utilisateur
main_center: Centre principal de l'utilisateur main_center: Territoire principal de l'utilisateur
aside_activity_type: Catégorie d'activité annexe aside_activity_type: Catégorie d'activité annexe
date: Date date: Date
duration: Durée duration: Durée

View File

@@ -70,6 +70,8 @@
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option> <option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option> <option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select> </select>
<label class="input-group-text" for="slotMinTime">De</label> <label class="input-group-text" for="slotMinTime">De</label>
<select <select

View File

@@ -32,6 +32,8 @@
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option> <option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option> <option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select> </select>
<label class="input-group-text" for="slotMinTime">De</label> <label class="input-group-text" for="slotMinTime">De</label>
<select <select
@@ -102,7 +104,8 @@
event.title event.title
}}</b> }}</b>
<b v-else-if="event.extendedProps.is === 'range'" <b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr) }} - >{{ formatDate(event.startStr, "time") }} -
{{ formatDate(event.endStr, "time") }}:
{{ event.extendedProps.locationName }}</b {{ event.extendedProps.locationName }}</b
> >
<b v-else-if="event.extendedProps.is === 'local'">{{ <b v-else-if="event.extendedProps.is === 'local'">{{
@@ -294,9 +297,26 @@ const nextWeeks = computed((): Weeks[] =>
}), }),
); );
const formatDate = (datetime: string) => { const formatDate = (datetime: string, format: null | "time" = null) => {
console.log(typeof datetime); const date = ISOToDate(datetime);
return ISOToDate(datetime); if (!date) return "";
if (format === "time") {
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}
// French date formatting
return date.toLocaleDateString("fr-FR", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}; };
const baseOptions = ref<CalendarOptions>({ const baseOptions = ref<CalendarOptions>({

View File

@@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController
return new JsonResponse( return new JsonResponse(
$this->serializer->serialize( $this->serializer->serialize(
new Collection($items, $paginator), new Collection(array_values($items->toArray()), $paginator),
'json', 'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]] [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
), ),

View File

@@ -0,0 +1,20 @@
<?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\Exception;
class ConversionWithSameMimeTypeException extends \RuntimeException
{
public function __construct(string $mimeType, ?\Throwable $previous = null)
{
parent::__construct("Conversion to same MIME type '{$mimeType}' is not allowed: already at the same MIME type", 0, $previous);
}
}

View File

@@ -25,7 +25,7 @@ export interface GenericDoc {
type: "doc_store_generic_doc"; type: "doc_store_generic_doc";
uniqueKey: string; uniqueKey: string;
key: string; key: string;
identifiers: object; identifiers: { id: number };
context: "person" | "accompanying-period"; context: "person" | "accompanying-period";
doc_date: DateTime; doc_date: DateTime;
metadata: GenericDocMetadata; metadata: GenericDocMetadata;

View File

@@ -4,6 +4,7 @@ import { StoredObject, StoredObjectVersion } from "../../types";
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue"; import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import { useToast } from "vue-toast-notification"; import { useToast } from "vue-toast-notification";
import { DOCUMENT_ADD, trans } from "translator";
interface DropFileConfig { interface DropFileConfig {
allowRemove: boolean; allowRemove: boolean;
@@ -75,11 +76,9 @@ function closeModal(): void {
@click="openModal" @click="openModal"
class="btn btn-create" class="btn btn-create"
> >
Ajouter un document {{ trans(DOCUMENT_ADD) }}
</button>
<button v-else @click="openModal" class="btn btn-edit">
Remplacer le document
</button> </button>
<button v-else @click="openModal" class="btn btn-edit"></button>
<modal <modal
v-if="state.showModal" v-if="state.showModal"
:modal-dialog-class="modalClasses" :modal-dialog-class="modalClasses"

View File

@@ -3,9 +3,9 @@ import {
StoredObject, StoredObject,
StoredObjectPointInTime, StoredObjectPointInTime,
StoredObjectVersionWithPointInTime, StoredObjectVersionWithPointInTime,
} from "./../../../types"; } from "ChillDocStoreAssets/types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import { ISOToDatetime } from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date"; import { ISOToDatetime } from "ChillMainAssets/chill/js/date";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue"; import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue"; import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue"; import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";

View File

@@ -46,6 +46,16 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{ {
// we first try to get the permission from the workflow, as attachement (this is the less intensive query)
$workflowPermissionAsAttachment = match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject),
};
if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) {
return false;
}
// Retrieve the related entity // Retrieve the related entity
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
@@ -66,7 +76,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
return match ($workflowPermission) { return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission, WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
}; };
} }
} }

View File

@@ -14,6 +14,12 @@ namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* Interface for voting on stored object permissions.
*
* Each time a stored object is attached to a document, the voter is responsible for determining
* whether the user has the necessary permissions to access or modify the stored object.
*/
interface StoredObjectVoterInterface interface StoredObjectVoterInterface
{ {
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;

View File

@@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\WopiBundle\Service\WopiConverter; use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Component\Mime\MimeTypesInterface; use Symfony\Component\Mime\MimeTypesInterface;
@@ -44,6 +45,7 @@ class StoredObjectToPdfConverter
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found * @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
* @throws \RuntimeException if the conversion or storage of the new version fails * @throws \RuntimeException if the conversion or storage of the new version fails
* @throws StoredObjectManagerException * @throws StoredObjectManagerException
* @throws ConversionWithSameMimeTypeException if the document has already the same mime type79*
*/ */
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array
{ {
@@ -56,7 +58,7 @@ class StoredObjectToPdfConverter
$currentVersion = $storedObject->getCurrentVersion(); $currentVersion = $storedObject->getCurrentVersion();
if ($currentVersion->getType() === $newMimeType) { if ($currentVersion->getType() === $newMimeType) {
throw new \UnexpectedValueException('Already at the same mime type'); throw new ConversionWithSameMimeTypeException($newMimeType);
} }
$content = $this->storedObjectManager->read($currentVersion); $content = $this->storedObjectManager->read($currentVersion);

View File

@@ -40,6 +40,10 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
$storedObject->registerVersion(); $storedObject->registerVersion();
} }
// remove one version in the history
$v5 = $storedObject->getVersions()->get(5);
$storedObject->removeVersion($v5);
$security = $this->prophesize(Security::class); $security = $this->prophesize(Security::class);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject) $security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
->willReturn(true) ->willReturn(true)
@@ -53,6 +57,7 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
self::assertEquals($response->getStatusCode(), 200); self::assertEquals($response->getStatusCode(), 200);
self::assertIsArray($body); self::assertIsArray($body);
self::assertArrayHasKey('results', $body); self::assertArrayHasKey('results', $body);
self::assertIsList($body['results']);
self::assertCount(10, $body['results']); self::assertCount(10, $body['results']);
} }

View File

@@ -86,9 +86,165 @@ class AbstractStoredObjectVoterTest extends TestCase
} }
/** /**
* @dataProvider dataProviderVoteOnAttribute * @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission
*/ */
public function testVoteOnAttribute( public function testVoteOnAttributeWithStoredObjectPermission(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $isGrantedRegularPermission,
string $isGrantedWorkflowPermission,
string $isGrantedStoredObjectAttachment,
): void {
$storedObject = new StoredObject();
$repository = new DummyRepository($related = new \stdClass());
$token = new UsernamePasswordToken(new User(), 'dummy');
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermission);
} elseif (StoredObjectRoleEnum::EDIT === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($isGrantedWorkflowPermission);
} else {
throw new \LogicException('Invalid attribute for StoredObjectVoter');
}
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter {
public function __construct(private $repository, $helper, $security)
{
parent::__construct($security, $helper);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return \stdClass::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return 'SOME_ROLE';
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
};
$actual = $storedObjectVoter->voteOnAttribute($attribute, $storedObject, $token);
self::assertEquals($expected, $actual);
}
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable
{
foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) {
yield 'Not related to any workflow nor attachment ('.$action.')' => [
$attribute,
true,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
}
}
/**
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
*/
public function testVoteOnAttributeWithoutStoredObjectPermission(
StoredObjectRoleEnum $attribute, StoredObjectRoleEnum $attribute,
bool $expected, bool $expected,
bool $canBeAssociatedWithWorkflow, bool $canBeAssociatedWithWorkflow,
@@ -105,6 +261,10 @@ class AbstractStoredObjectVoterTest extends TestCase
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
if (null !== $isGrantedWorkflowPermissionRead) { if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
@@ -123,7 +283,7 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
} }
public static function dataProviderVoteOnAttribute(): iterable public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
{ {
// not associated on a workflow // not associated on a workflow
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper']; yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];

View File

@@ -23,6 +23,8 @@ See the document: Voir le document
document: document:
Any title: Aucun titre Any title: Aucun titre
replace: Remplacer
Add: Ajouter un document
generic_doc: generic_doc:
filter: filter:

View File

@@ -246,7 +246,7 @@ final class EventController extends AbstractController
'class' => Center::class, 'class' => Center::class,
'choices' => $centers, 'choices' => $centers,
'placeholder' => $this->translator->trans('Pick a center'), 'placeholder' => $this->translator->trans('Pick a center'),
'label' => 'To which centre should the event be associated ?', 'label' => 'To which territory should the event be associated ?',
]) ])
->add('submit', SubmitType::class, [ ->add('submit', SubmitType::class, [
'label' => 'Next step', 'label' => 'Next step',

View File

@@ -64,7 +64,7 @@ CHILL_EVENT_PARTICIPATION_SEE_DETAILS: Voir le détail d'une participation
# TODO check place to put this # TODO check place to put this
Next step: Étape suivante Next step: Étape suivante
To which centre should the event be associated ?: À quel centre doit être associé l'événement ? To which territory should the event be associated ?: À quel territoire doit être associé l'événement ?
# timeline # timeline
past: passé past: passé
@@ -151,7 +151,7 @@ event:
filter: filter:
event_types: Par types d'événement event_types: Par types d'événement
event_dates: Par date d'événement event_dates: Par date d'événement
center: Par centre center: Par territoire
by_responsable: Par responsable by_responsable: Par responsable
pick_responsable: Filtrer par responsables pick_responsable: Filtrer par responsables
budget: budget:
@@ -188,7 +188,7 @@ event_id: Identifiant
event_name: Nom event_name: Nom
event_date: Date event_date: Date
event_type: Type d'évenement event_type: Type d'évenement
event_center: Centre event_center: Territoire
event_moderator: Responsable event_moderator: Responsable
event_participants_count: Nombre de participants event_participants_count: Nombre de participants
event_location: Localisation event_location: Localisation

View File

@@ -118,7 +118,7 @@
{{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }} {{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }}
{% endblock crud_content_view_details %} {% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %} {% block content_view_actions_back %}
<li class="cancel"> <li class="cancel">
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}"> <a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}">

View File

@@ -46,6 +46,7 @@
</dd> </dd>
</dl> </dl>
{% endblock crud_content_view_details %} {% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %} {% block content_view_actions_back %}
<li class="cancel"> <li class="cancel">

View File

@@ -206,6 +206,8 @@
</a> </a>
</li> </li>
{% endblock %} {% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_after %} {% block content_view_actions_after %}
<li> <li>
<a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}"> <a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}">

View File

@@ -94,6 +94,7 @@
{% endblock crud_content_view_details %} {% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %} {% block content_view_actions_back %}
<li class="cancel"> <li class="cancel">

View File

@@ -0,0 +1,64 @@
<?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\Action\User\UpdateProfile;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\NotificationFlagManager;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use libphonenumber\PhoneNumber;
final class UpdateProfileCommand
{
public array $notificationFlags = [];
public function __construct(
#[PhonenumberConstraint]
public ?PhoneNumber $phonenumber,
) {}
public static function create(User $user, NotificationFlagManager $flagManager): self
{
$updateProfileCommand = new self($user->getPhonenumber());
foreach ($flagManager->getAllNotificationFlagProviders() as $provider) {
$updateProfileCommand->setNotificationFlag(
$provider->getFlag(),
User::NOTIF_FLAG_IMMEDIATE_EMAIL,
$user->isNotificationSendImmediately($provider->getFlag())
);
$updateProfileCommand->setNotificationFlag(
$provider->getFlag(),
User::NOTIF_FLAG_DAILY_DIGEST,
$user->isNotificationDailyDigest($provider->getFlag())
);
}
return $updateProfileCommand;
}
/**
* @param User::NOTIF_FLAG_IMMEDIATE_EMAIL|User::NOTIF_FLAG_DAILY_DIGEST $kind
*/
private function setNotificationFlag(string $type, string $kind, bool $value): void
{
if (!array_key_exists($type, $this->notificationFlags)) {
$this->notificationFlags[$type] = ['immediate_email' => true, 'daily_digest' => false];
}
$k = match ($kind) {
User::NOTIF_FLAG_IMMEDIATE_EMAIL => 'immediate_email',
User::NOTIF_FLAG_DAILY_DIGEST => 'daily_digest',
};
$this->notificationFlags[$type][$k] = $value;
}
}

View File

@@ -0,0 +1,27 @@
<?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\Action\User\UpdateProfile;
use Chill\MainBundle\Entity\User;
final readonly class UpdateProfileCommandHandler
{
public function updateProfile(User $user, UpdateProfileCommand $command): void
{
$user->setPhonenumber($command->phonenumber);
foreach ($command->notificationFlags as $flag => $values) {
$user->setNotificationImmediately($flag, $values['immediate_email']);
$user->setNotificationDailyDigest($flag, $values['daily_digest']);
}
}
}

View File

@@ -0,0 +1,35 @@
<?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 Chill\MainBundle\Security\RoleDumper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'chill:main:dump-list-permissions', description: 'Print a markdown reference of permissions (roles) grouped by title with dependencies).')]
final class DumpListPermissionsCommand extends Command
{
public function __construct(private readonly RoleDumper $roleDumper)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$markdown = $this->roleDumper->dumpAsMarkdown();
$output->writeln($markdown);
return Command::SUCCESS;
}
}

View File

@@ -48,6 +48,7 @@ class AbsenceController extends AbstractController
$user = $this->security->getUser(); $user = $this->security->getUser();
$user->setAbsenceStart(null); $user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
$em = $this->managerRegistry->getManager(); $em = $this->managerRegistry->getManager();
$em->flush(); $em->flush();

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\MainBundle\Controller;
use Chill\MainBundle\Form\UserProfileType;
use Chill\MainBundle\Security\ChillSecurity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Routing\Annotation\Route;
final class UserProfileController extends AbstractController
{
public function __construct(
private readonly TranslatorInterface $translator,
private readonly ChillSecurity $security,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
) {}
/**
* User profile that allows editing of phonenumber and visualization of certain data.
*/
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
public function __invoke(Request $request)
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
$user = $this->security->getUser();
$editForm = $this->createForm(UserProfileType::class, $user);
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$notificationFlagsData = $editForm->get('notificationFlags')->getData();
$user->setNotificationFlags($notificationFlagsData);
$em = $this->managerRegistry->getManager();
$em->flush();
$this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
return $this->redirectToRoute('chill_main_user_profile');
}
return $this->render('@ChillMain/User/profile.html.twig', [
'user' => $user,
'form' => $editForm->createView(),
]);
}
}

View File

@@ -0,0 +1,75 @@
<?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\Controller;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\UpdateProfileType;
use Chill\MainBundle\Notification\NotificationFlagManager;
use Chill\MainBundle\Security\ChillSecurity;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class UserUpdateProfileController
{
public function __construct(
private TranslatorInterface $translator,
private ChillSecurity $security,
private EntityManagerInterface $entityManager,
private NotificationFlagManager $notificationFlagManager,
private FormFactoryInterface $formFactory,
private UrlGeneratorInterface $urlGenerator,
private Environment $twig,
private UpdateProfileCommandHandler $updateProfileCommandHandler,
) {}
/**
* User profile that allows editing of phonenumber and visualization of certain data.
*/
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
public function __invoke(Request $request, Session $session)
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
$user = $this->security->getUser();
$command = UpdateProfileCommand::create($user, $this->notificationFlagManager);
$editForm = $this->formFactory->create(UpdateProfileType::class, $command);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$this->updateProfileCommandHandler->updateProfile($user, $command);
$this->entityManager->flush();
$session->getFlashBag()->add('success', $this->translator->trans('user.profile.Profile successfully updated!'));
return new RedirectResponse($this->urlGenerator->generate('chill_main_user_profile'));
}
return new Response($this->twig->render('@ChillMain/User/profile.html.twig', [
'user' => $user,
'form' => $editForm->createView(),
]));
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
@@ -27,7 +28,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
class WorkflowApiController class WorkflowApiController extends ApiController
{ {
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {} public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {}

View File

@@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController
$signature, $signature,
$request, $request,
EntityWorkflowStepSignatureVoter::CANCEL, EntityWorkflowStepSignatureVoter::CANCEL,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); }, fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature),
'@ChillMain/WorkflowSignature/cancel.html.twig', '@ChillMain/WorkflowSignature/cancel.html.twig',
); );
} }
@@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController
$signature, $signature,
$request, $request,
EntityWorkflowStepSignatureVoter::REJECT, EntityWorkflowStepSignatureVoter::REJECT,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); }, fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature),
'@ChillMain/WorkflowSignature/reject.html.twig', '@ChillMain/WorkflowSignature/reject.html.twig',
); );
} }
/**
* @param callable(EntityWorkflowStepSignature): string $markSignature
*
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
private function markSignatureAction( private function markSignatureAction(
EntityWorkflowStepSignature $signature, EntityWorkflowStepSignature $signature,
Request $request, Request $request,
@@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) { $expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature));
$markSignature($signature);
});
return new RedirectResponse( return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]) $this->chillUrlGenerator->forwardReturnPath(
'chill_main_workflow_wait',
['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep]
)
); );
} }

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class WorkflowWaitStepChangeController
{
public function __construct(
private ChillUrlGeneratorInterface $chillUrlGenerator,
private Environment $twig,
) {}
#[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])]
public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response
{
if ($entityWorkflow->getStep() === $expectedStep) {
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
);
}
return new Response(
$this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep])
);
}
}

View File

@@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController;
use Chill\MainBundle\Controller\UserGroupApiController; use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController; use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController; use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\Controller\WorkflowApiController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Chill\MainBundle\Doctrine\DQL\Age; use Chill\MainBundle\Doctrine\DQL\Age;
use Chill\MainBundle\Doctrine\DQL\Extract; use Chill\MainBundle\Doctrine\DQL\Extract;
@@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Form\CenterType; use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType; use Chill\MainBundle\Form\CivilityType;
use Chill\MainBundle\Form\CountryType; use Chill\MainBundle\Form\CountryType;
@@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType; use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType; use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Security\Authorization\ChillExportVoter; use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
use Ramsey\Uuid\Doctrine\UuidType; use Ramsey\Uuid\Doctrine\UuidType;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
@@ -940,6 +943,21 @@ class ChillMainExtension extends Extension implements
], ],
], ],
], ],
[
'class' => EntityWorkflow::class,
'name' => 'workflow',
'base_path' => '/api/1.0/main/workflow',
'base_role' => EntityWorkflowVoter::SEE,
'controller' => WorkflowApiController::class,
'actions' => [
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
], ],
]); ]);
} }

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
/** /**
* User. * User.
@@ -45,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceStart = null; private ?\DateTimeImmutable $absenceStart = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceEnd = null;
/** /**
* Array where SAML attributes's data are stored. * Array where SAML attributes's data are stored.
*/ */
@@ -157,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this->absenceStart; return $this->absenceStart;
} }
public function getAbsenceEnd(): ?\DateTimeImmutable
{
return $this->absenceEnd;
}
/** /**
* Get attributes. * Get attributes.
* *
@@ -336,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
public function isAbsent(): bool public function isAbsent(): bool
{ {
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new \DateTimeImmutable('now'); $now = new \DateTimeImmutable('now');
$absenceStart = $this->getAbsenceStart();
$absenceEnd = $this->getAbsenceEnd();
return null !== $absenceStart
&& $absenceStart <= $now
&& (null === $absenceEnd || $now <= $absenceEnd);
} }
/** /**
@@ -410,6 +424,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
$this->absenceStart = $absenceStart; $this->absenceStart = $absenceStart;
} }
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
{
$this->absenceEnd = $absenceEnd;
}
public function setAttributeByDomain(string $domain, string $key, $value): self public function setAttributeByDomain(string $domain, string $key, $value): self
{ {
$this->attributes[$domain][$key] = $value; $this->attributes[$domain][$key] = $value;
@@ -633,46 +652,82 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return true; return true;
} }
public function getNotificationFlags(): array private function getNotificationFlagData(string $flag): array
{ {
return $this->notificationFlags; return $this->notificationFlags[$flag] ?? [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
}
public function setNotificationFlags(array $notificationFlags)
{
$this->notificationFlags = $notificationFlags;
}
public function getNotificationFlagData(string $flag): array
{
return $this->notificationFlags[$flag] ?? [];
}
public function setNotificationFlagData(string $flag, array $data): void
{
$this->notificationFlags[$flag] = $data;
} }
public function isNotificationSendImmediately(string $type): bool public function isNotificationSendImmediately(string $type): bool
{ {
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) { return $this->isNotificationForElement($type, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
return true;
} }
return false; public function setNotificationImmediately(string $type, bool $active): void
{
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
}
public function setNotificationDailyDigest(string $type, bool $active): void
{
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_DAILY_DIGEST);
}
/**
* @param self::NOTIF_FLAG_IMMEDIATE_EMAIL|self::NOTIF_FLAG_DAILY_DIGEST $kind
*/
private function setNotificationFlagElement(string $type, bool $active, string $kind): void
{
$notificationFlags = [...$this->notificationFlags];
$changed = false;
if (!isset($notificationFlags[$type])) {
$notificationFlags[$type] = [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
$changed = true;
}
if ($active) {
if (!in_array($kind, $notificationFlags[$type], true)) {
$notificationFlags[$type] = [...$notificationFlags[$type], $kind];
$changed = true;
}
} else {
if (in_array($kind, $notificationFlags[$type], true)) {
$notificationFlags[$type] = array_values(
array_filter($notificationFlags[$type], static fn ($k) => $k !== $kind)
);
$changed = true;
}
}
if ($changed) {
$this->notificationFlags = [...$notificationFlags];
}
}
private function isNotificationForElement(string $type, string $kind): bool
{
return in_array($kind, $this->getNotificationFlagData($type), true);
} }
public function isNotificationDailyDigest(string $type): bool public function isNotificationDailyDigest(string $type): bool
{ {
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) { return $this->isNotificationForElement($type, self::NOTIF_FLAG_DAILY_DIGEST);
return true;
}
return false;
} }
public function getLocale(): string public function getLocale(): string
{ {
return 'fr'; return 'fr';
} }
#[Assert\Callback]
public function validateAbsenceDates(ExecutionContextInterface $context): void
{
if (null !== $this->getAbsenceEnd() && null === $this->getAbsenceStart()) {
$context->buildViolation(
'user.absence_end_requires_start'
)
->atPath('absenceEnd')
->addViolation();
}
}
} }

View File

@@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\RegroupmentRepositoryInterface; use Chill\MainBundle\Repository\RegroupmentRepositoryInterface;
/** /**
* @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter: string, formatter: array{form: array<string, mixed>, version: int}} * @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter?: string, formatter: array{form: array<string, mixed>, version: int}}
*/ */
class ExportConfigNormalizer class ExportConfigNormalizer
{ {

View File

@@ -23,9 +23,14 @@ class AbsenceType extends AbstractType
{ {
$builder $builder
->add('absenceStart', ChillDateType::class, [ ->add('absenceStart', ChillDateType::class, [
'required' => true, 'required' => false,
'input' => 'datetime_immutable', 'input' => 'datetime_immutable',
'label' => 'absence.Absence start', 'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]); ]);
} }

View File

@@ -1,75 +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\DataMapper;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Form\DataMapperInterface;
final readonly class NotificationFlagDataMapper implements DataMapperInterface
{
public function __construct(private array $notificationFlagProviders) {}
public function mapDataToForms($viewData, $forms): void
{
if (null === $viewData) {
$viewData = [];
}
$formsArray = iterator_to_array($forms);
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
if (isset($formsArray[$flag])) {
$flagForm = $formsArray[$flag];
$immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true)
|| !array_key_exists($flag, $viewData);
$dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true);
if ($flagForm->has('immediate_email')) {
$flagForm->get('immediate_email')->setData($immediateEmailChecked);
}
if ($flagForm->has('daily_email')) {
$flagForm->get('daily_email')->setData($dailyEmailChecked);
}
}
}
}
public function mapFormsToData($forms, &$viewData): void
{
$formsArray = iterator_to_array($forms);
$viewData = [];
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
if (isset($formsArray[$flag])) {
$flagForm = $formsArray[$flag];
$viewData[$flag] = [];
if (true === $flagForm['immediate_email']->getData()) {
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
}
if (true === $flagForm['daily_email']->getData()) {
$viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST;
}
if ([] === $viewData[$flag]) {
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
}
}
}
}
}

View File

@@ -11,11 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type; namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
use Chill\MainBundle\Notification\NotificationFlagManager; use Chill\MainBundle\Notification\NotificationFlagManager;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -30,27 +28,24 @@ class NotificationFlagsType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders));
foreach ($this->notificationFlagProviders as $flagProvider) { foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag(); $flag = $flagProvider->getFlag();
$builder->add($flag, FormType::class, [ $flagBuilder = $builder->create($flag, options: [
'label' => $flagProvider->getLabel(), 'label' => $flagProvider->getLabel(),
'required' => false, 'compound' => true,
]); ]);
$builder->get($flag) $flagBuilder
->add('immediate_email', CheckboxType::class, [ ->add('immediate_email', CheckboxType::class, [
'label' => false, 'label' => false,
'required' => false, 'required' => false,
'mapped' => false,
]) ])
->add('daily_email', CheckboxType::class, [ ->add('daily_digest', CheckboxType::class, [
'label' => false, 'label' => false,
'required' => false, 'required' => false,
'mapped' => false,
]) ])
; ;
$builder->add($flagBuilder);
} }
} }
@@ -58,6 +53,7 @@ class NotificationFlagsType extends AbstractType
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => null, 'data_class' => null,
'compound' => true,
]); ]);
} }
} }

View File

@@ -11,31 +11,29 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form; namespace Chill\MainBundle\Form;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType; use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\NotificationFlagsType; use Chill\MainBundle\Form\Type\NotificationFlagsType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class UserProfileType extends AbstractType class UpdateProfileType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('phonenumber', ChillPhoneNumberType::class, [ ->add('phonenumber', ChillPhoneNumberType::class, [
'required' => false, 'required' => false,
]) ])
->add('notificationFlags', NotificationFlagsType::class, [ ->add('notificationFlags', NotificationFlagsType::class)
'label' => false,
'mapped' => false,
])
; ;
} }
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => \Chill\MainBundle\Entity\User::class, 'data_class' => UpdateProfileCommand::class,
]); ]);
} }
} }

View File

@@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType
'invalid_message' => 'The password fields must match', 'invalid_message' => 'The password fields must match',
'constraints' => [ 'constraints' => [
new Length([ new Length([
'min' => 9, 'min' => 14,
'minMessage' => 'The password must be greater than {{ limit }} characters', 'minMessage' => 'The password must be greater than {{ limit }} characters',
]), ]),
new NotBlank(), new NotBlank(),

View File

@@ -105,6 +105,11 @@ class UserType extends AbstractType
'required' => false, 'required' => false,
'input' => 'datetime_immutable', 'input' => 'datetime_immutable',
'label' => 'absence.Absence start', 'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]); ]);
// @phpstan-ignore-next-line // @phpstan-ignore-next-line

View File

@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
return null; return null;
} }
const [year, month, day] = str.split("-").map((p) => parseInt(p)); // If the string already contains time info, use it directly
if (str.includes("T") || str.includes(" ")) {
return new Date(str);
}
// Otherwise, parse date only
const [year, month, day] = str.split("-").map((p) => parseInt(p));
return new Date(year, month - 1, day, 0, 0, 0, 0); return new Date(year, month - 1, day, 0, 0, 0, 0);
}; };
@@ -69,20 +74,19 @@ export const ISOToDatetime = (str: string | null): Date | null => {
* *
*/ */
export const datetimeToISO = (date: Date): string => { export const datetimeToISO = (date: Date): string => {
let cal, time, offset; const cal = [
cal = [
date.getFullYear(), date.getFullYear(),
(date.getMonth() + 1).toString().padStart(2, "0"), (date.getMonth() + 1).toString().padStart(2, "0"),
date.getDate().toString().padStart(2, "0"), date.getDate().toString().padStart(2, "0"),
].join("-"); ].join("-");
time = [ const time = [
date.getHours().toString().padStart(2, "0"), date.getHours().toString().padStart(2, "0"),
date.getMinutes().toString().padStart(2, "0"), date.getMinutes().toString().padStart(2, "0"),
date.getSeconds().toString().padStart(2, "0"), date.getSeconds().toString().padStart(2, "0"),
].join(":"); ].join(":");
offset = [ const offset = [
date.getTimezoneOffset() <= 0 ? "+" : "-", date.getTimezoneOffset() <= 0 ? "+" : "-",
Math.abs(Math.floor(date.getTimezoneOffset() / 60)) Math.abs(Math.floor(date.getTimezoneOffset() / 60))
.toString() .toString()

View File

@@ -0,0 +1,13 @@
/**
* Extracts the "returnPath" parameter from the current URL's query string and returns it.
* If the parameter is not present, returns the provided fallback path.
*
* @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string.
* @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present.
*/
export function returnPathOr(fallbackPath: string): string {
const urlParams = new URLSearchParams(window.location.search);
const returnPath = urlParams.get("returnPath");
return returnPath ?? fallbackPath;
}

View File

@@ -0,0 +1,16 @@
import { EntityWorkflow } from "ChillMainAssets/types";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export const fetchWorkflow = async (
workflowId: number,
): Promise<EntityWorkflow> => {
try {
return await makeFetch<null, EntityWorkflow>(
"GET",
`/api/1.0/main/workflow/${workflowId}.json`,
);
} catch (error) {
console.error(`Failed to fetch workflow ${workflowId}:`, error);
throw error;
}
};

View File

@@ -1,5 +1,6 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
export interface DateTime { export interface DateTime {
datetime: string; datetime: string;
@@ -202,6 +203,58 @@ export interface WorkflowAttachment {
genericDoc: null | GenericDoc; genericDoc: null | GenericDoc;
} }
export interface Workflow {
name: string;
text: string;
}
export interface EntityWorkflowStep {
type: "entity_workflow_step";
id: number;
comment: string;
currentStep: StepDefinition;
isFinal: boolean;
isFreezed: boolean;
isFinalized: boolean;
transitionPrevious: Transition | null;
transitionAfter: Transition | null;
previousId: number | null;
nextId: number | null;
transitionPreviousBy: User | null;
transitionPreviousAt: DateTime | null;
}
export interface Transition {
name: string;
text: string;
isForward: boolean;
}
export interface StepDefinition {
name: string;
text: string;
}
export interface EntityWorkflow {
type: "entity_workflow";
id: number;
relatedEntityClass: string;
relatedEntityId: number;
workflow: Workflow;
currentStep: EntityWorkflowStep;
steps: EntityWorkflowStep[];
datas: WorkflowData;
title: string;
isOnHoldAtCurrentStep: boolean;
_permissions: {
CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean;
};
}
export interface WorkflowData {
persons: Person[];
}
export interface ExportGeneration { export interface ExportGeneration {
id: string; id: string;
type: "export_generation"; type: "export_generation";
@@ -215,3 +268,8 @@ export interface ExportGeneration {
export interface PrivateCommentEmbeddable { export interface PrivateCommentEmbeddable {
comments: Record<number, string>; comments: Record<number, string>;
} }
/**
* Possible states for the WaitingScreen Component.
*/
export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready";

View File

@@ -10,7 +10,8 @@ import { computed, onMounted, ref } from "vue";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export"; import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import { ExportGeneration } from "ChillMainAssets/types"; import WaitingScreen from "../_components/WaitingScreen.vue";
import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types";
interface AppProps { interface AppProps {
exportGenerationId: string; exportGenerationId: string;
@@ -34,13 +35,16 @@ const storedObject = computed<null | StoredObject>(() => {
}); });
const isPending = computed<boolean>(() => status.value === "pending"); const isPending = computed<boolean>(() => status.value === "pending");
const isFetching = computed<boolean>(
() => tryiesForReady.value < maxTryiesForReady,
);
const isReady = computed<boolean>(() => status.value === "ready");
const isFailure = computed<boolean>(() => status.value === "failure");
const filename = computed<string>(() => `${props.title}-${props.createdDate}`); const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
const state = computed<WaitingScreenState>((): WaitingScreenState => {
if (status.value === "empty") {
return "pending";
}
return status.value;
});
/** /**
* counter for the number of times that we check for a new status * counter for the number of times that we check for a new status
*/ */
@@ -85,38 +89,26 @@ onMounted(() => {
</script> </script>
<template> <template>
<div id="waiting-screen"> <WaitingScreen :state="state">
<div <template v-slot:pending>
v-if="isPending && isFetching"
class="alert alert-danger text-center"
>
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p> </p>
</div> </template>
<div> <template v-slot:stopped>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-if="isPending && !isFetching" class="alert alert-info">
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} {{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p> </p>
</div> </template>
</div>
<div v-if="isFailure" class="alert alert-danger text-center"> <template v-slot:failure>
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} {{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p> </p>
</div> </template>
</div>
<div v-if="isReady" class="alert alert-success text-center"> <template v-slot:ready>
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }} {{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p> </p>
@@ -127,15 +119,6 @@ onMounted(() => {
:filename="filename" :filename="filename"
></document-action-buttons-group> ></document-action-buttons-group>
</p> </p>
</div> </template>
</div> </WaitingScreen>
</div>
</template> </template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useIntervalFn } from "@vueuse/core";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper";
import { ref } from "vue";
import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue";
import { WaitingScreenState } from "ChillMainAssets/types";
import {
trans,
WORKFLOW_WAIT_TITLE,
WORKFLOW_WAIT_ERROR_WHILE_WAITING,
WORKFLOW_WAIT_SUCCESS,
} from "translator";
interface WaitPostProcessWorkflowComponentProps {
workflowId: number;
expectedStep: string;
}
const props = defineProps<WaitPostProcessWorkflowComponentProps>();
const counter = ref<number>(0);
const MAX_TRYIES = 50;
const state = ref<WaitingScreenState>("pending");
const { pause, resume } = useIntervalFn(
async () => {
try {
const workflow = await fetchWorkflow(props.workflowId);
counter.value++;
if (workflow.currentStep.currentStep.name === props.expectedStep) {
window.location.assign(
returnPathOr("/fr/main/workflow" + workflow.id + "/show"),
);
resume();
state.value = "ready";
}
if (counter.value > MAX_TRYIES) {
pause();
state.value = "failure";
}
} catch (error) {
console.error(error);
pause();
}
},
2000,
{ immediate: true },
);
</script>
<template>
<div class="container">
<WaitingScreen :state="state">
<template v-slot:pending>
<p>
{{ trans(WORKFLOW_WAIT_TITLE) }}
</p>
</template>
<template v-slot:failure>
<p>
{{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }}
</p>
</template>
<template v-slot:ready>
<p>
{{ trans(WORKFLOW_WAIT_SUCCESS) }}
</p>
</template>
</WaitingScreen>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,51 @@
import { createApp } from "vue";
import App from "./App.vue";
function mountApp(): void {
const el = document.querySelector<HTMLDivElement>(".screen-wait");
if (!el) {
console.error(
"WaitPostProcessWorkflow: mount element .screen-wait not found",
);
return;
}
const workflowIdAttr = el.getAttribute("data-workflow-id");
const expectedStep = el.getAttribute("data-expected-step") || "";
if (!workflowIdAttr) {
console.error(
"WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element",
);
return;
}
if (!expectedStep) {
console.error(
"WaitPostProcessWorkflow: data-expected-step attribute missing on mount element",
);
return;
}
const workflowId = Number(workflowIdAttr);
if (Number.isNaN(workflowId)) {
console.error(
"WaitPostProcessWorkflow: data-workflow-id is not a valid number:",
workflowIdAttr,
);
return;
}
const app = createApp(App, {
workflowId,
expectedStep,
});
app.mount(el);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountApp);
} else {
mountApp();
}

View File

@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, useTemplateRef } from "vue"; import { computed, onMounted, ref, useTemplateRef } from "vue";
import type { WorkflowAttachment } from "ChillMainAssets/types"; import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue"; import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
import { GenericDoc } from "ChillDocStoreAssets/types"; import { GenericDoc } from "ChillDocStoreAssets/types";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
interface AppConfig { interface AppConfig {
workflowId: number; workflowId: number;
@@ -34,6 +35,13 @@ const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
) as GenericDocForAccompanyingPeriod[], ) as GenericDocForAccompanyingPeriod[],
); );
const workflow = ref<EntityWorkflow | null>(null);
onMounted(async () => {
workflow.value = await fetchWorkflow(Number(props.workflowId));
console.log("workflow", workflow.value);
});
const openModal = function () { const openModal = function () {
pickDocModal.value?.openModal(); pickDocModal.value?.openModal();
}; };
@@ -49,20 +57,30 @@ const onPickGenericDoc = ({
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => { const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
emit("removeAttachment", payload); emit("removeAttachment", payload);
}; };
const canEditAttachement = computed<boolean>(() => {
if (null === workflow.value) {
return false;
}
return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
});
</script> </script>
<template> <template>
<pick-generic-doc-modal <pick-generic-doc-modal
:workflow="workflow"
:accompanying-period-id="props.accompanyingPeriodId" :accompanying-period-id="props.accompanyingPeriodId"
:to-remove="attachedGenericDoc" :to-remove="attachedGenericDoc"
ref="pickDocModal" ref="pickDocModal"
@pickGenericDoc="onPickGenericDoc" @pickGenericDoc="onPickGenericDoc"
></pick-generic-doc-modal> ></pick-generic-doc-modal>
<attachment-list <attachment-list
:workflow="workflow"
:attachments="props.attachments" :attachments="props.attachments"
@removeAttachment="onRemoveAttachment" @removeAttachment="onRemoveAttachment"
></attachment-list> ></attachment-list>
<ul class="record_actions"> <ul v-if="canEditAttachement" class="record_actions">
<li> <li>
<button type="button" class="btn btn-create" @click="openModal"> <button type="button" class="btn btn-create" @click="openModal">
Ajouter une pièce jointe Ajouter une pièce jointe

View File

@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { WorkflowAttachment } from "ChillMainAssets/types"; import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue"; import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface AttachmentListProps { interface AttachmentListProps {
attachments: WorkflowAttachment[]; attachments: WorkflowAttachment[];
workflow: EntityWorkflow | null;
} }
const emit = defineEmits<{ const emit = defineEmits<{
@@ -36,7 +37,12 @@ const props = defineProps<AttachmentListProps>();
:stored-object="a.genericDoc.storedObject" :stored-object="a.genericDoc.storedObject"
></document-action-buttons-group> ></document-action-buttons-group>
</li> </li>
<li> <li
v-if="
!workflow?._permissions
.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
"
>
<button <button
type="button" type="button"
class="btn btn-delete" class="btn btn-delete"

View File

@@ -6,8 +6,10 @@ import {
import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue"; import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue";
import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api"; import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { EntityWorkflow } from "ChillMainAssets/types";
interface PickGenericDocProps { interface PickGenericDocProps {
workflow: EntityWorkflow | null;
accompanyingPeriodId: number; accompanyingPeriodId: number;
pickedList: GenericDocForAccompanyingPeriod[]; pickedList: GenericDocForAccompanyingPeriod[];
toRemove: GenericDocForAccompanyingPeriod[]; toRemove: GenericDocForAccompanyingPeriod[];
@@ -36,9 +38,21 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean =>
) !== -1; ) !== -1;
onMounted(async () => { onMounted(async () => {
genericDocs.value = await fetch_generic_docs_by_accompanying_period( const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period(
props.accompanyingPeriodId, props.accompanyingPeriodId,
); );
const documentClasses = [
"Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument",
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
"Chill\\DocStoreBundle\\Entity\\PersonDocument",
];
genericDocs.value = fetchedGenericDocs.filter(
(doc) =>
!documentClasses.includes(
props.workflow?.relatedEntityClass || "",
) || props.workflow?.relatedEntityId !== doc.identifiers.id,
);
loaded.value = true; loaded.value = true;
}); });

View File

@@ -3,8 +3,10 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, ref, useTemplateRef } from "vue"; import { computed, ref, useTemplateRef } from "vue";
import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue"; import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import { EntityWorkflow } from "ChillMainAssets/types";
interface PickGenericDocModalProps { interface PickGenericDocModalProps {
workflow: EntityWorkflow | null;
accompanyingPeriodId: number; accompanyingPeriodId: number;
toRemove: GenericDocForAccompanyingPeriod[]; toRemove: GenericDocForAccompanyingPeriod[];
} }
@@ -80,6 +82,7 @@ defineExpose({ openModal, closeModal });
</template> </template>
<template v-slot:body> <template v-slot:body>
<pick-generic-doc <pick-generic-doc
:workflow="props.workflow"
:accompanying-period-id="props.accompanyingPeriodId" :accompanying-period-id="props.accompanyingPeriodId"
:to-remove="props.toRemove" :to-remove="props.toRemove"
:picked-list="pickeds" :picked-list="pickeds"

View File

@@ -84,6 +84,8 @@ const emits = defineEmits<{
} }
.modal-header .close { .modal-header .close {
border-top-right-radius: 0.3rem; border-top-right-radius: 0.3rem;
margin-right: 0;
margin-left: auto;
} }
/* /*
* The following styles are auto-applied to elements with * The following styles are auto-applied to elements with

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { WaitingScreenState } from "ChillMainAssets/types";
interface Props {
state: WaitingScreenState;
}
const props = defineProps<Props>();
</script>
<template>
<div id="waiting-screen">
<div
v-if="props.state === 'pending' && !!$slots.pending"
class="alert alert-danger text-center"
>
<div>
<slot name="pending"></slot>
</div>
<div>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div
v-if="props.state === 'stopped' && !!$slots.stopped"
class="alert alert-info"
>
<div>
<slot name="stopped"></slot>
</div>
</div>
<div
v-if="props.state === 'failure' && !!$slots.failure"
class="alert alert-danger text-center"
>
<div>
<slot name="failure"></slot>
</div>
</div>
<div
v-if="props.state === 'ready' && !!$slots.ready"
class="alert alert-success text-center"
>
<div>
<slot name="ready"></slot>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -44,17 +44,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock content_view_actions_duplicate_link %} {% endblock content_view_actions_duplicate_link %}
{% block content_view_actions_merge %} {% block content_view_actions_merge %}{% endblock %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': entity.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc">
<i class="bi bi-chevron-contract"></i>
{{ 'Merge'|trans }}
</a>
</li>
{% endblock %}
{% block content_view_actions_edit_link %} {% block content_view_actions_edit_link %}
{% if chill_crud_action_exists(crud_name, 'edit') %} {% if chill_crud_action_exists(crud_name, 'edit') %}
{% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %} {% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %}

View File

@@ -280,11 +280,17 @@
</div> </div>
{% endblock %} {% endblock %}
{% block pick_linked_entities_widget %}
<input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %} data-input-uniqid="{{ form.vars['uniqid'] }}" />
<div data-input-uniqid="{{ form.vars['uniqid'] }}" data-module="pick-linked-entities" data-pick-entities-type="{{ form.vars['pick-entities-type'] }}"
></div>
{% block pick_linked_entities_widget %}
<input type="hidden" {{ block('widget_attributes') }}
{% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %}
data-input-uniqid="{{ form.vars['uniqid'] }}"/>
<div
data-input-uniqid="{{ form.vars['uniqid'] }}"
data-module="pick-linked-entities"
data-pick-entities-type="{{ form.vars['pick-entities-type'] }}"
data-suggested="{{ form.vars['suggested']|json_encode|escape('html_attr') }}"
></div>
{% endblock %} {% endblock %}
{% block pick_postal_code_widget %} {% block pick_postal_code_widget %}

View File

@@ -8,36 +8,36 @@
<div class="col-md-10"> <div class="col-md-10">
<h2>{{ 'absence.My absence'|trans }}</h2> <h2>{{ 'absence.My absence'|trans }}</h2>
{% if user.absenceStart is not null %}
<div> <div>
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p> {% if user.absenceStart is not null %}
<ul class="record_actions sticky-form-buttons"> <div class="alert alert-success flash_message">{{ 'absence.You are listed as absent, as of {date, date, short}'|trans({
<li> date: user.absenceStart
<a href="{{ path('chill_main_user_absence_unset') }}" }) }}
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a> {% if user.absenceEnd is not null %}
</li> {{ 'until %date%'|trans({'%date%': user.absenceEnd|format_date('short') }) }}
</ul> {% endif %}
</div> </div>
{% else %} {% else %}
<div> <div class="alert alert-warning flash_message">{{ 'absence.No absence listed'|trans }}</div>
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p> {% endif %}
</div> </div>
<div> <div>
{{ form_start(form) }} {{ form_start(form) }}
{{ form_row(form.absenceStart) }} {{ form_row(form.absenceStart) }}
{{ form_row(form.absenceEnd) }}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li>
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
</li>
<li> <li>
<button class="btn btn-save" type="submit"> <button class="btn btn-save" type="submit">
{{ 'Save'|trans }} {{ 'Save'|trans }}
</button> </button>
</li> </li>
</ul> </ul>
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,7 @@
role="button" role="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
<i class="fa fa-flash"></i> <i class="bi bi-lightning-fill"></i>
</a> </a>
<div class="dropdown-menu"> <div class="dropdown-menu">
{% for menu in menus %} {% for menu in menus %}

View File

@@ -21,8 +21,6 @@
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
{{ form_row(form.addressesEmails) }}
{% include handler.template(notification) with handler.templateData(notification) %} {% include handler.template(notification) with handler.templateData(notification) %}
<div class="mb-3 row"> <div class="mb-3 row">

View File

@@ -61,7 +61,7 @@
{% endif %} {% endif %}
</li> </li>
<li> <li>
<span class="dt">cercle/centre:</span> <span class="dt">service/territoire:</span>
{% if entity.mainScope %} {% if entity.mainScope %}
{{ entity.mainScope.name|localize_translatable_string }} {{ entity.mainScope.name|localize_translatable_string }}
{% endif %} {% endif %}

View File

@@ -64,7 +64,7 @@
{{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} {{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
</td> </td>
<td class="text-center"> <td class="text-center">
{{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} {{ form_widget(flag.daily_digest, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -58,12 +58,14 @@
{% endif %} {% endif %}
</section> </section>
{% if signatures|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% endif %}
<section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section>
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
{% if signatures|length > 0 %} {% if entity_workflow.currentStep.sends|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% elseif entity_workflow.currentStep.sends|length > 0 %}
<section class="step my-4"> <section class="step my-4">
<h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2> <h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2>
{% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %} {% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %}

View File

@@ -0,0 +1,18 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title %}{{ 'workflow.signature.waiting_for'|trans }}{% endblock %}
{% block css %}
{{ encore_entry_link_tags('page_workflow_waiting_post_process') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('page_workflow_waiting_post_process') }}
{% endblock %}
{% block content %}
<h1>{{ block('title') }}</h1>
<div class="screen-wait" data-workflow-id="{{ workflow.id|e('html_attr') }}" data-expected-step="{{ expectedStep|e('html_attr') }}"></div>
{% endblock %}

View File

@@ -79,7 +79,7 @@
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert"> <div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p> <p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto"> <span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a> <a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
</span> </span>
</div> </div>
{% endif %} {% endif %}

View File

@@ -49,7 +49,7 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface
'route' => 'chill_crud_center_index', 'route' => 'chill_crud_center_index',
])->setExtras(['order' => 1010]); ])->setExtras(['order' => 1010]);
$menu->addChild('Regroupements des centres', [ $menu->addChild('Regroupements des territoires', [
'route' => 'chill_crud_regroupment_index', 'route' => 'chill_crud_regroupment_index',
])->setExtras(['order' => 1015]); ])->setExtras(['order' => 1015]);

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Workflow\Registry;
final class EntityWorkflowAttachmentVoter extends Voter
{
public function __construct(
private readonly Registry $registry,
) {}
public const EDIT = 'CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT';
protected function supports(string $attribute, $subject): bool
{
return $subject instanceof EntityWorkflow && self::EDIT === $attribute;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
if (!$subject instanceof EntityWorkflow) {
throw new \UnexpectedValueException('Subject must be an instance of EntityWorkflow');
}
if ($subject->isFinal()) {
return false;
}
$workflow = $this->registry->get($subject, $subject->getWorkflowName());
$marking = $workflow->getMarking($subject);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if ($placeMetadata['isSentExternal'] ?? false) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Security;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class RoleDumper
{
public function __construct(
private RoleProvider $roleProvider,
private RoleHierarchyInterface $roleHierarchy,
private TranslatorInterface $translator,
) {}
public function dumpAsMarkdown(): string
{
$roles = $this->roleProvider->getRoles();
$rolesWithoutScopes = $this->roleProvider->getRolesWithoutScopes();
// Group roles by title
$groups = [];
foreach ($roles as $role) {
$title = $this->roleProvider->getRoleTitle($role);
$title ??= 'Other';
$groups[$title][] = $role;
}
// Sort groups by title
ksort($groups, SORT_NATURAL | SORT_FLAG_CASE);
$lines = [];
foreach ($groups as $title => $roleList) {
// Sort roles by translated label for deterministic output
usort($roleList, function (string $a, string $b): int {
$ta = $this->translator->trans($a);
$tb = $this->translator->trans($b);
return strcasecmp($ta, $tb);
});
$translatedTitle = $this->translator->trans($title);
$lines[] = '## '.$translatedTitle;
foreach ($roleList as $role) {
// Translate primary role
$translatedRole = $this->translator->trans($role);
// Scope marker: (S) if needs scope, (~~S~~) if no scope required
$needsScope = !in_array($role, $rolesWithoutScopes, true);
$scopeMarker = $needsScope ? '(S)' : '(~~S~~)';
// Compute dependent roles from hierarchy (exclude itself)
$reachable = $this->roleHierarchy->getReachableRoleNames([$role]);
$dependents = array_values(array_filter($reachable, static fn (string $r): bool => $r !== $role));
// Translate dependents and sort deterministically
$translatedDependents = array_map(fn (string $r) => $this->translator->trans($r), $dependents);
sort($translatedDependents, SORT_NATURAL | SORT_FLAG_CASE);
if (count($translatedDependents) > 0) {
$lines[] = sprintf('- **%s** %s: %s', $translatedRole, $scopeMarker, implode(', ', $translatedDependents));
} else {
$lines[] = sprintf('- **%s** %s', $translatedRole, $scopeMarker);
}
}
// Add a blank line between groups
$lines[] = '';
}
// Trim possible trailing blank line
$markdown = rtrim(implode("\n", $lines));
return $markdown."\n"; // End with newline for POSIX friendliness
}
}

View File

@@ -52,12 +52,8 @@ class RoleProvider
/** /**
* Get the title for each role. * Get the title for each role.
*
* @param string $role
*
* @return string the title of the role
*/ */
public function getRoleTitle($role) public function getRoleTitle(string $role): ?string
{ {
$this->initializeRolesTitlesCache(); $this->initializeRolesTitlesCache();
@@ -73,7 +69,7 @@ class RoleProvider
/** /**
* initialize the array for caching role and titles. * initialize the array for caching role and titles.
*/ */
private function initializeRolesTitlesCache() private function initializeRolesTitlesCache(): void
{ {
// break if already initialized // break if already initialized
if (null !== $this->rolesTitlesCache) { if (null !== $this->rolesTitlesCache) {

View File

@@ -12,18 +12,25 @@ declare(strict_types=1);
namespace Chill\MainBundle\Serializer\Normalizer; namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Registry;
class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface
{ {
use NormalizerAwareTrait; use NormalizerAwareTrait;
public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {} public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly MetadataExtractor $metadataExtractor,
private readonly Registry $registry,
private readonly Security $security,
) {}
/** /**
* @param EntityWorkflow $object * @param EntityWorkflow $object
@@ -46,6 +53,9 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn
'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context), 'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context),
'title' => $handler->getEntityTitle($object), 'title' => $handler->getEntityTitle($object),
'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(), 'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(),
'_permissions' => [
EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object),
],
]; ];
} }

View File

@@ -39,6 +39,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'label' => '', 'label' => '',
'email' => '', 'email' => '',
'isAbsent' => false, 'isAbsent' => false,
'absenceStart' => null,
'absenceEnd' => null,
]; ];
public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {} public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {}
@@ -77,6 +79,11 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read'] ['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read']
); );
$absenceDatesContext = array_merge(
$context,
['docgen:expects' => \DateTimeImmutable::class, 'groups' => 'docgen:read']
);
if (null === $object && 'docgen' === $format) { if (null === $object && 'docgen' === $format) {
return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)]; return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)];
} }
@@ -99,6 +106,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext), 'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext), 'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext),
'isAbsent' => $object->isAbsent(), 'isAbsent' => $object->isAbsent(),
'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext),
'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext),
]; ];
if ('docgen' === $format) { if ('docgen' === $format) {

View File

@@ -0,0 +1,85 @@
<?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 Action\User\UpdateProfile;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler;
use Chill\MainBundle\Entity\User;
use libphonenumber\PhoneNumber;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
final class UpdateProfileCommandHandlerTest extends TestCase
{
public function testUpdateProfileWithNullPhoneAndFlags(): void
{
$user = new User();
// Pre-set some flags to opposite values to check they are updated
$flag = 'tickets';
$user->setNotificationImmediately($flag, true);
$user->setNotificationDailyDigest($flag, true);
$command = new UpdateProfileCommand(null);
$command->notificationFlags = [
$flag => [
'immediate_email' => false,
'daily_digest' => false,
],
];
(new UpdateProfileCommandHandler())->updateProfile($user, $command);
self::assertNull($user->getPhonenumber(), 'Phone should be set to null');
self::assertFalse($user->isNotificationSendImmediately($flag));
self::assertFalse($user->isNotificationDailyDigest($flag));
}
public function testUpdateProfileWithPhoneAndMultipleFlags(): void
{
$user = new User();
$phone = new PhoneNumber();
$phone->setCountryCode(33); // France
$phone->setNationalNumber(612345678);
$command = new UpdateProfileCommand($phone);
$command->notificationFlags = [
'reports' => [
'immediate_email' => true,
'daily_digest' => false,
],
'activities' => [
'immediate_email' => false,
'daily_digest' => true,
],
];
(new UpdateProfileCommandHandler())->updateProfile($user, $command);
// Phone assigned
self::assertInstanceOf(PhoneNumber::class, $user->getPhonenumber());
self::assertSame(33, $user->getPhonenumber()->getCountryCode());
self::assertSame('612345678', (string) $user->getPhonenumber()->getNationalNumber());
// Flags applied
self::assertTrue($user->isNotificationSendImmediately('reports'));
self::assertFalse($user->isNotificationDailyDigest('reports'));
self::assertFalse($user->isNotificationSendImmediately('activities'));
self::assertTrue($user->isNotificationDailyDigest('activities'));
}
}

View File

@@ -0,0 +1,103 @@
<?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 Action\User\UpdateProfile;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Chill\MainBundle\Notification\NotificationFlagManager;
use libphonenumber\PhoneNumber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\TranslatableMessage;
/**
* @internal
*
* @coversNothing
*/
final class UpdateProfileCommandTest extends TestCase
{
public function testCreateTransfersPhonenumberAndNotificationFlags(): void
{
$user = new User();
// set a phone number
$phone = new PhoneNumber();
$phone->setCountryCode(32); // Belgium
$phone->setNationalNumber(471234567);
$user->setPhonenumber($phone);
// configure notification flags on the user via helpers
$flagA = 'foo';
$flagB = 'bar';
// For tickets: immediate true, daily false
$user->setNotificationImmediately($flagA, true);
$user->setNotificationDailyDigest($flagA, false);
// For reports: immediate false, daily true
$user->setNotificationImmediately($flagB, false);
$user->setNotificationDailyDigest($flagB, true);
// a third flag not explicitly set to validate default behavior from User
$flagC = 'foobar'; // by default immediate-email is true, daily-digest is false per User::getNotificationFlagData
$manager = $this->createNotificationFlagManager([$flagA, $flagB, $flagC]);
$command = UpdateProfileCommand::create($user, $manager);
// phone number transferred
self::assertInstanceOf(PhoneNumber::class, $command->phonenumber);
self::assertSame($phone->getCountryCode(), $command->phonenumber->getCountryCode());
self::assertSame($phone->getNationalNumber(), $command->phonenumber->getNationalNumber());
// flags transferred consistently
self::assertArrayHasKey($flagA, $command->notificationFlags);
self::assertArrayHasKey($flagB, $command->notificationFlags);
self::assertArrayHasKey($flagC, $command->notificationFlags);
self::assertSame([
'immediate_email' => true,
'daily_digest' => false,
], $command->notificationFlags[$flagA]);
self::assertSame([
'immediate_email' => false,
'daily_digest' => true,
], $command->notificationFlags[$flagB]);
// default from User::getNotificationFlagData -> immediate true, daily false
self::assertSame([
'immediate_email' => true,
'daily_digest' => false,
], $command->notificationFlags[$flagC]);
}
private function createNotificationFlagManager(array $flags): NotificationFlagManager
{
$providers = array_map(fn (string $flag) => new class ($flag) implements NotificationFlagProviderInterface {
public function __construct(private readonly string $flag) {}
public function getFlag(): string
{
return $this->flag;
}
public function getLabel(): TranslatableMessage
{
return new TranslatableMessage($this->flag);
}
}, $flags);
return new NotificationFlagManager($providers);
}
}

View File

@@ -35,7 +35,7 @@ final class ScopeControllerTest extends WebTestCase
$client->getResponse()->getStatusCode(), $client->getResponse()->getStatusCode(),
'Unexpected HTTP status code for GET /fr/admin/scope/' 'Unexpected HTTP status code for GET /fr/admin/scope/'
); );
$crawler = $client->click($crawler->selectLink('Créer un nouveau cercle')->link()); $crawler = $client->click($crawler->selectLink('Créer un nouveau service')->link());
// Fill in the form and submit it // Fill in the form and submit it
$form = $crawler->selectButton('Créer')->form([ $form = $crawler->selectButton('Créer')->form([
'chill_mainbundle_scope[name][fr]' => 'Test en fr', 'chill_mainbundle_scope[name][fr]' => 'Test en fr',

View File

@@ -45,7 +45,7 @@ final class UserControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$username = 'Test_user'.uniqid(); $username = 'Test_user'.uniqid();
$password = 'Password1234!'; $password = 'Password_1234!';
// Fill in the form and submit it // Fill in the form and submit it
@@ -99,7 +99,7 @@ final class UserControllerTest extends WebTestCase
{ {
$client = $this->getClientAuthenticatedAsAdmin(); $client = $this->getClientAuthenticatedAsAdmin();
$crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password"); $crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password");
$newPassword = '1234Password!'; $newPassword = '1234_Password!';
$form = $crawler->selectButton('Changer le mot de passe')->form([ $form = $crawler->selectButton('Changer le mot de passe')->form([
'chill_mainbundle_user_password[new_password][first]' => $newPassword, 'chill_mainbundle_user_password[new_password][first]' => $newPassword,

View File

@@ -96,11 +96,13 @@ final class NotificationTest extends KernelTestCase
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email'); $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email');
// immediate-email preference // immediate-email preference
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL, User::NOTIF_FLAG_DAILY_DIGEST]]); $user->setNotificationImmediately('test_notification_type', true);
$user->setNotificationDailyDigest('test_notification_type', true);
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email'); $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email');
// daily-email preference // daily-email preference
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_DAILY_DIGEST]]); $user->setNotificationDailyDigest('test_notification_type', true);
$user->setNotificationImmediately('test_notification_type', false);
$this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only'); $this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only');
$this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email'); $this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email');

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Entity;
use Chill\MainBundle\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class UserNotificationFlagsPersistenceTest extends KernelTestCase
{
public function testFlushPersistsNotificationFlagsChanges(): void
{
self::bootKernel();
$em = self::getContainer()->get('doctrine')->getManager();
$user = new User();
$user->setUsername('user_'.bin2hex(random_bytes(4)));
$user->setLabel('Test User');
$user->setPassword('secret');
// Étape 1: créer et persister lutilisateur
$em->persist($user);
$em->flush();
$id = $user->getId();
self::assertNotNull($id, 'User should have an ID after flush');
try {
// Sanity check: par défaut, pas de daily digest pour "alerts"
self::assertFalse($user->isNotificationDailyDigest('alerts'));
// Étape 2: activer le daily digest -> setNotificationFlagElement réassigne la propriété
$user->setNotificationDailyDigest('alerts', true);
$em->flush(); // persist le changement
$em->clear(); // simule un nouveau cycle de requête
// Étape 3: recharger depuis la base et vérifier la persistance
/** @var User $reloaded */
$reloaded = $em->find(User::class, $id);
self::assertNotNull($reloaded);
self::assertTrue(
$reloaded->isNotificationDailyDigest('alerts'),
'Daily digest flag should be persisted'
);
// Étape 4: modifier via setNotificationFlagData (remplacement du tableau)
// Cette méthode doit réassigner la propriété (copie -> réassignation)
$reloaded->setNotificationImmediately('alerts', true);
$reloaded->setNotificationDailyDigest('alerts', false);
$em->flush();
$em->clear();
/** @var User $reloaded2 */
$reloaded2 = $em->find(User::class, $id);
self::assertNotNull($reloaded2);
// Le daily digest nest plus actif, seul immediate-email est présent
self::assertFalse($reloaded2->isNotificationDailyDigest('alerts'));
self::assertTrue($reloaded2->isNotificationSendImmediately('alerts'));
} finally {
// Nettoyage
$managed = $em->find(User::class, $id);
if (null !== $managed) {
$em->remove($managed);
$em->flush();
}
$em->clear();
}
}
}

View File

@@ -67,4 +67,54 @@ class UserTest extends TestCase
->first()->getEndDate() ->first()->getEndDate()
); );
} }
public function testIsAbsent()
{
$user = new User();
// Absent: today is within absence period
$absenceStart = new \DateTimeImmutable('-1 day');
$absenceEnd = new \DateTimeImmutable('+1 day');
$user->setAbsenceStart($absenceStart);
$user->setAbsenceEnd($absenceEnd);
self::assertTrue($user->isAbsent(), 'Should be absent when now is between start and end');
// Absent: end is null
$user->setAbsenceStart(new \DateTimeImmutable('-2 days'));
$user->setAbsenceEnd(null);
self::assertTrue($user->isAbsent(), 'Should be absent when started and no end');
// Not absent: absenceStart is in the future
$user->setAbsenceStart(new \DateTimeImmutable('+2 days'));
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is in the future');
// Not absent: absenceEnd is in the past
$user->setAbsenceStart(new \DateTimeImmutable('-5 days'));
$user->setAbsenceEnd(new \DateTimeImmutable('-1 day'));
self::assertFalse($user->isAbsent(), 'Should not be absent if end is in the past');
// Not absent: both are null
$user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is null');
}
public function testSetNotification(): void
{
$user = new User();
self::assertTrue($user->isNotificationSendImmediately('dummy'));
self::assertFalse($user->isNotificationDailyDigest('dummy'));
$user->setNotificationImmediately('dummy', false);
self::assertFalse($user->isNotificationSendImmediately('dummy'));
$user->setNotificationDailyDigest('dummy', true);
self::assertTrue($user->isNotificationDailyDigest('dummy'));
$user->setNotificationImmediately('dummy', true);
self::assertTrue($user->isNotificationSendImmediately('dummy'));
self::assertTrue($user->isNotificationDailyDigest('dummy'));
}
} }

View File

@@ -144,7 +144,7 @@ class NotificationMailerTest extends TestCase
$idProperty->setValue($user, 456); $idProperty->setValue($user, 456);
// Set notification flags for the user // Set notification flags for the user
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL]]); $user->setNotificationImmediately('test_notification_type', true);
$messageBus = $this->createMock(MessageBusInterface::class); $messageBus = $this->createMock(MessageBusInterface::class);
$messageBus->expects($this->once()) $messageBus->expects($this->once())

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Security\Authorization;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class EntityWorkflowAttachmentVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider dataVoteOnAttribute
*/
public function testVoteOnAttribute(EntityWorkflow $entityWorkflow, int $expected): void
{
$voter = new EntityWorkflowAttachmentVoter($this->buildRegistry());
$actual = $voter->vote(
new UsernamePasswordToken(new User(), 'default'),
$entityWorkflow,
['CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'],
);
$this->assertEquals($expected, $actual);
}
public static function dataVoteOnAttribute(): iterable
{
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_final_positive',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_final_positive',
'transitionAt' => new \DateTimeImmutable()],
);
// we need to mark manually as final, as the listener is not registered
$entity->getCurrentStep()->setIsFinal(true);
yield 'on final positive' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_final_negative',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_final_negative',
'transitionAt' => new \DateTimeImmutable()],
);
// we need to mark manually as final, as the listener is not registered
$entity->getCurrentStep()->setIsFinal(true);
yield 'on final negative' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_sent_external',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_sent_external',
'transitionAt' => new \DateTimeImmutable()],
);
yield 'on sent_external' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
yield 'on initial' => [
$entity,
VoterInterface::ACCESS_GRANTED,
];
}
private static function buildRegistry(): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces(['initial'])
->addPlaces(['initial', 'sent_external', 'final_positive', 'final_negative'])
->addTransitions([
new Transition('to_final_positive', ['initial'], 'final_positive'),
new Transition('to_sent_external', ['initial'], 'sent_external'),
new Transition('to_final_negative', ['initial'], 'final_negative'),
])
->setMetadataStore(
new InMemoryMetadataStore(
placesMetadata: [
'sent_external' => [
'isSentExternal' => true,
],
'final_positive' => [
'isFinal' => true,
'isFinalPositive' => true,
],
'final_negative' => [
'isFinal' => true,
'isFinalPositive' => false,
],
]
)
);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
$registry = new Registry();
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
});
return $registry;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Security;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\MainBundle\Security\RoleDumper;
use Chill\MainBundle\Security\RoleProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class RoleDumperTest extends TestCase
{
public function testDumpAsMarkdownGroupsByTitleTranslatesAndListsDependencies(): void
{
// Fake provider with two groups
$provider = new class () implements ProvideRoleHierarchyInterface {
public const R_PERSON_SEE = 'CHILL_PERSON_SEE';
public const R_PERSON_UPDATE = 'CHILL_PERSON_UPDATE';
public const R_REPORT_SEE = 'CHILL_REPORT_SEE';
public function getRoles(): array
{
return [self::R_PERSON_SEE, self::R_PERSON_UPDATE, self::R_REPORT_SEE];
}
public function getRolesWithoutScope(): array
{
// In this test, assume REPORT_SEE does not need scope, others do
return [self::R_REPORT_SEE];
}
public function getRolesWithHierarchy(): array
{
return [
'Person' => [self::R_PERSON_SEE, self::R_PERSON_UPDATE],
'Report' => [self::R_REPORT_SEE],
];
}
};
$roleProvider = new RoleProvider([$provider]);
// Fake role hierarchy: UPDATE implies SEE; others none
$roleHierarchy = new class () implements RoleHierarchyInterface {
public function getReachableRoleNames(array $roles): array
{
$output = [];
foreach ($roles as $r) {
$output[] = $r;
if ('CHILL_PERSON_UPDATE' === $r) {
$output[] = 'CHILL_PERSON_SEE';
}
}
return array_values(array_unique($output));
}
};
// Fake translator that clearly shows translation applied
$translator = new class () implements TranslatorInterface {
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
return 'T('.$id.')';
}
public function getLocale(): string
{
return 'en';
}
};
$dumper = new RoleDumper($roleProvider, $roleHierarchy, $translator);
$md = $dumper->dumpAsMarkdown();
$expected = "## T(Person)\n"
."- **T(CHILL_PERSON_SEE)** (S)\n"
."- **T(CHILL_PERSON_UPDATE)** (S): T(CHILL_PERSON_SEE)\n\n"
."## T(Report)\n"
."- **T(CHILL_REPORT_SEE)** (~~S~~)\n";
self::assertSame($expected, $md);
}
}

View File

@@ -101,6 +101,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'SomeUser', 'text_without_absent' => 'SomeUser',
'isAbsent' => false, 'isAbsent' => false,
'main_center' => ['context' => Center::class], 'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]]; ]];
yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class], yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class],
@@ -120,6 +122,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'AnotherUser', 'text_without_absent' => 'AnotherUser',
'isAbsent' => false, 'isAbsent' => false,
'main_center' => ['context' => Center::class], 'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]]; ]];
yield [null, 'docgen', ['docgen:expects' => User::class], [ yield [null, 'docgen', ['docgen:expects' => User::class], [
@@ -138,6 +142,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => '', 'text_without_absent' => '',
'isAbsent' => false, 'isAbsent' => false,
'main_center' => ['context' => Center::class], 'main_center' => ['context' => Center::class],
'absenceStart' => null,
'absenceEnd' => null,
]]; ]];
} }
} }

View File

@@ -11,6 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Helper; namespace Chill\MainBundle\Tests\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
@@ -148,8 +151,11 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user']; 'abstain because there is no workflow'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force deny because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow(); $entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
@@ -171,6 +177,9 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user']; 'force grant because the user was a previous user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied because the user was not a previous user'];
$entityWorkflow = new EntityWorkflow(); $entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User(); $dto->futureDestUsers[] = $user = new User();
@@ -232,6 +241,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature on a canceled workflow']; 'abstain: there is a signature on a canceled workflow'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), 'force denied: the workflow is sent to an external user'];
} }
public function testNoWorkflow(): void public function testNoWorkflow(): void
@@ -253,7 +269,217 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows); $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); $repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
}
/**
* @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowReadByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'Abstain: there is a signature for person, but the attachment is not concerned'];
}
/**
* @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowWriteByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because there is no workflow'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user (and attachment)'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user was not a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: user was a previous user, but it is finalized positive'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: user was a previous user, it is finalized, but finalized negative'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but the signature is not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature on a canceled workflow'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: the workflow is sent to an external user'];
}
/**
* @param list<EntityWorkflow> $entityWorkflows
*/
private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled();
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachments = [];
foreach ($entityWorkflows as $entityWorkflow) {
$attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject());
}
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
} }
private static function buildRegistry(): Registry private static function buildRegistry(): Registry
@@ -261,10 +487,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$builder = new DefinitionBuilder(); $builder = new DefinitionBuilder();
$builder $builder
->setInitialPlaces(['initial']) ->setInitialPlaces(['initial'])
->addPlaces(['initial', 'test', 'final_positive', 'final_negative']) ->addPlaces(['initial', 'test', 'sent_external', 'final_positive', 'final_negative'])
->setMetadataStore( ->setMetadataStore(
new InMemoryMetadataStore( new InMemoryMetadataStore(
placesMetadata: [ placesMetadata: [
'sent_external' => [
'isSentExternal' => true,
],
'final_positive' => [ 'final_positive' => [
'isFinal' => true, 'isFinal' => true,
'isFinalPositive' => true, 'isFinalPositive' => true,

View File

@@ -11,8 +11,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Messenger; namespace Chill\MainBundle\Tests\Workflow\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
@@ -23,6 +26,7 @@ use Chill\ThirdPartyBundle\Entity\ThirdParty;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Address;
@@ -39,24 +43,54 @@ class PostSendExternalMessageHandlerTest extends TestCase
public function testSendMessageHappyScenario(): void public function testSendMessageHappyScenario(): void
{ {
$entityWorkflow = $this->buildEntityWorkflow(); $entityWorkflow = $this->buildEntityWorkflow();
// Prepare attachments (2 attachments)
$attachmentStoredObject1 = new StoredObject();
$attachmentStoredObject2 = new StoredObject();
new EntityWorkflowAttachment('generic_doc', ['id' => 1], $entityWorkflow, $attachmentStoredObject1);
new EntityWorkflowAttachment('generic_doc', ['id' => 2], $entityWorkflow, $attachmentStoredObject2);
// Prepare transition DTO and sends
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestineeEmails = ['external@example.com']; $dto->futureDestineeEmails = ['external@example.com'];
$dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')]; $dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')];
$entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User()); $entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User());
// Repository returns our workflow
$repository = $this->prophesize(EntityWorkflowRepository::class); $repository = $this->prophesize(EntityWorkflowRepository::class);
$repository->find(1)->willReturn($entityWorkflow); $repository->find(1)->willReturn($entityWorkflow);
// Mailer must send to both recipients
$mailer = $this->prophesize(MailerInterface::class); $mailer = $this->prophesize(MailerInterface::class);
$mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce(); $mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce();
$mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce(); $mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce();
// Workflow manager and handler
$workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class); $workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title'); $workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title');
$workflowManager = $this->prophesize(EntityWorkflowManager::class); $workflowManager = $this->prophesize(EntityWorkflowManager::class);
$workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal()); $workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal());
$handler = new PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $workflowManager->reveal()); // Associated stored object for the workflow
$associatedStoredObject = new StoredObject();
$workflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($associatedStoredObject);
// Converter should be called for each attachment and the associated stored object
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($attachmentStoredObject1, 'fr')->shouldBeCalledOnce();
$converter->addConvertedVersion($attachmentStoredObject2, 'fr')->shouldBeCalledOnce();
$converter->addConvertedVersion($associatedStoredObject, 'fr')->shouldBeCalledOnce();
// Logger (not used in happy path, but required by handler)
$logger = $this->prophesize(LoggerInterface::class);
$handler = new PostSendExternalMessageHandler(
$repository->reveal(),
$mailer->reveal(),
$workflowManager->reveal(),
$converter->reveal(),
$logger->reveal(),
);
$handler(new PostSendExternalMessage(1, 'fr')); $handler(new PostSendExternalMessage(1, 'fr'));

View File

@@ -11,9 +11,12 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\Helper; namespace Chill\MainBundle\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
@@ -58,21 +61,39 @@ class WorkflowRelatedEntityPermissionHelper
public function __construct( public function __construct(
private readonly Security $security, private readonly Security $security,
private readonly EntityWorkflowManager $entityWorkflowManager, private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly Registry $registry, private readonly Registry $registry,
private readonly ClockInterface $clock, private readonly ClockInterface $clock,
) {} ) {}
/** /**
* @param object $entity The entity may be an
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/ */
public function isAllowedByWorkflowForReadOperation(object $entity): string public function isAllowedByWorkflowForReadOperation(object $entity): string
{ {
if ($entity instanceof StoredObject) {
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
$isAttached = true;
} else {
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$isAttached = false;
}
if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) { if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) {
return self::FORCE_GRANT; return self::FORCE_GRANT;
} }
if ($isAttached) {
return self::ABSTAIN;
}
// give a view permission if there is a Person signature pending, or in the 12 hours following // give a view permission if there is a Person signature pending, or in the 12 hours following
// the signature last state // the signature last state
foreach ($entityWorkflows as $workflow) { foreach ($entityWorkflows as $workflow) {
@@ -100,28 +121,45 @@ class WorkflowRelatedEntityPermissionHelper
*/ */
public function isAllowedByWorkflowForWriteOperation(object $entity): string public function isAllowedByWorkflowForWriteOperation(object $entity): string
{ {
if ($entity instanceof StoredObject) {
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
$isAttached = true;
} else {
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$runningWorkflows = []; $isAttached = false;
}
// if a workflow is finalized positive, we are not allowed to edit to document any more if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
// if a workflow is finalized positive, anyone is allowed to edit the document anymore
foreach ($entityWorkflows as $entityWorkflow) { foreach ($entityWorkflows as $entityWorkflow) {
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $int) { foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (true === ($placeMetadata['isFinalPositive'] ?? false)) { if (
// the workflow is final, and final positive, so we stop here. ($entityWorkflow->isFinal() && ($placeMetadata['isFinalPositive'] ?? false))
|| ($placeMetadata['isSentExternal'] ?? false)
) {
// the workflow is final, and final positive, or is sentExternal, so we stop here.
return self::FORCE_DENIED; return self::FORCE_DENIED;
} }
if (
// if not finalized positive
$entityWorkflow->isFinal() && !($placeMetadata['isFinalPositive'] ?? false)
) {
return self::ABSTAIN;
} }
} else {
$runningWorkflows[] = $entityWorkflow;
} }
} }
// if there is a signature on a **running workflow**, no one can edit the workflow any more $runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal());
// if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore
if (!$isAttached) {
foreach ($runningWorkflows as $entityWorkflow) { foreach ($runningWorkflows as $entityWorkflow) {
foreach ($entityWorkflow->getSteps() as $step) { foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) { foreach ($step->getSignatures() as $signature) {
@@ -131,15 +169,20 @@ class WorkflowRelatedEntityPermissionHelper
} }
} }
} }
}
// allow only the users involved // allow only the users involved
if ($this->isUserInvolvedInAWorkflow($runningWorkflows)) { if ($this->isUserInvolvedInAWorkflow($runningWorkflows)) {
return self::FORCE_GRANT; return self::FORCE_GRANT;
} }
if ($isAttached) {
return self::ABSTAIN; return self::ABSTAIN;
} }
return self::FORCE_DENIED;
}
/** /**
* @param list<EntityWorkflow> $entityWorkflows * @param list<EntityWorkflow> $entityWorkflows
*/ */

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