Compare commits

...

45 Commits

Author SHA1 Message Date
4028c020ee Release v4.12.0 2026-01-15 18:02:12 +01:00
0d4eef6a0c Merge branch '493-fix-stored-object-workflow-permission' into 'master'
Fix issues with permission for stored objects associated with workflows

Closes #493

See merge request Chill-Projet/chill-bundles!951
2026-01-15 16:54:37 +00:00
b6152d5356 Fix issues with permission for stored objects associated with workflows 2026-01-15 16:54:37 +00:00
8b708f8c73 fix CommentInput: replace deprecated value binding with model-value 2026-01-15 14:53:40 +01:00
8d5b200107 Restrict ux-translator version to 2.31.0 2026-01-15 14:44:05 +01:00
a9e9207d5a Update php-cs-fixer version 2026-01-15 13:41:00 +01:00
3915574ed4 phpstan error fix 2026-01-15 13:40:46 +01:00
f3217d22ef Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center 2026-01-15 13:25:54 +01:00
06c5affbe7 Increase delay for removing stale workflows from 90 to 180 days
- Updated `KEEP_INTERVAL` in `CancelStaleWorkflowCronJob` to `P180D`.
2026-01-15 10:08:40 +01:00
bf461a1211 Merge branch '473-display-bundles-version' into 'master'
Resolve "Afficher le numéro de version de Chill dans l'UX"

Closes #473

See merge request Chill-Projet/chill-bundles!947
2026-01-13 15:35:26 +00:00
3f0ad51114 Resolve "Afficher le numéro de version de Chill dans l'UX" 2026-01-13 15:35:26 +00:00
a4de8eaab3 Merge branch '489-fix-desactivation-date-goarls-results' into 'master'
Fix issue with goal/result deactivation date handling and improve formatting

Closes #489

See merge request Chill-Projet/chill-bundles!949
2026-01-13 15:32:08 +00:00
2feb137ac2 Fix issue with goal/result deactivation date handling and improve formatting 2026-01-13 15:32:07 +00:00
5ea74d118b Merge branch '490-fix-double-notification' into 'master'
Prevent notifications from being sent when the user signs a document he asked to himself

Closes #490

See merge request Chill-Projet/chill-bundles!950
2026-01-13 15:31:50 +00:00
8eb7a55ef5 Prevent notifications from being sent when the user signs a document he asked to himself 2026-01-13 15:31:49 +00:00
281887355f Fix calculation of budget balance 2026-01-12 10:34:30 +01:00
47b285b584 Fix export group by center for persons without a center in CenterAggregator.php 2025-12-30 13:01:56 +01:00
7c9b4d02f6 Fix ordering of social actions
Actions with a closing date in the future should be considered as 'still open'.
2025-12-18 11:08:18 +01:00
3ff9bba4de Fix the condition to display concerned persons in calendar list items. 2025-12-18 10:24:24 +01:00
c0f9e953fb Update to v4.11.0 2025-12-17 16:56:35 +01:00
a49ea2b6b9 Fix translation syntax
Cannot start with %, wrap translation value in double quotes
2025-12-17 16:54:33 +01:00
a30232d3ce Merge branch '478-admin-list-filters' into 'master'
Resolve "Add filters to admin lists"

Closes #478

See merge request Chill-Projet/chill-bundles!941
2025-12-15 16:49:39 +00:00
aae55e6f8c Merge branch '466-fix-migrations' into 'master'
Fix migration to exclude null `user_id` in `activity_user` population

Closes #466

See merge request Chill-Projet/chill-bundles!943
2025-12-15 13:43:20 +00:00
c9513f2f6c Fix migration to exclude null user_id in activity_user population 2025-12-15 13:43:20 +00:00
11d7425883 php cs fixes 2025-12-15 10:48:20 +01:00
08897e0981 Fix count of total items for correct paginator display 2025-12-15 10:48:00 +01:00
98cbfed054 Add filtering methods to controllers 2025-12-15 10:48:00 +01:00
9af4d19744 Add repository methods for filtering 2025-12-15 10:48:00 +01:00
c1cf5a8bb2 Start implementation of filter within admin index pages 2025-12-15 10:48:00 +01:00
ba4e445110 Release v4.10.1 2025-12-11 14:49:07 +01:00
0f1ff9baf4 Fix CS 2025-12-11 14:43:29 +01:00
e4365ad058 Merge branch '483-fix-ctrl-c-collabora' into 'master'
Tentatively fix CTRL+C in Collabora editor with edge and chrome browser (+ remove zimbra bundle from configuration)

Closes #483

See merge request Chill-Projet/chill-bundles!942
2025-12-11 13:26:11 +00:00
a16d659f69 Tentatively fix CTRL+C in Collabora editor with edge and chrome browser (+ remove zimbra bundle from configuration) 2025-12-11 13:26:10 +00:00
c4a069ba2e Fix display of header for ByActivityNumberAggregator 2025-12-10 05:13:24 +01:00
2600c6fa2a Fix ByActivityNumberAggregator when used on count exports within ActivityBundle 2025-12-10 05:06:03 +01:00
09ef95d13e Translate rst files to md format and add new index entries for translation directives and provider 2025-12-10 04:32:19 +01:00
c19b2e18ad Move symfony/loco-translation-provider dependency to require-dev 2025-12-10 04:26:27 +01:00
27853c594d Fix missing translation variable in NewLocation component 2025-12-10 04:17:29 +01:00
7714b07a9d Add changie for display of upcoming appointments within person context 2025-12-09 17:11:11 +01:00
6e1c9b6f29 Remove chill-project/chill-zimbra-bundle from composer dependencies
- This package provoke failures on build in the CI
2025-12-09 15:38:24 +01:00
d80661e479 Release v4.10.0 2025-12-09 15:31:57 +01:00
473c5a9fa3 Remove dependency on @symfony/ux-translator from package.json
- Deleted `@symfony/ux-translator` as it is no longer needed.
- Documented the change in `.changes/unreleased` with no schema modifications.
2025-12-09 15:27:39 +01:00
acceeeaa2a Merge branch '462-display-calendar-items-for-person' into 'master'
Improve the display of upcoming calendar items within the person render box

Closes #462

See merge request Chill-Projet/chill-bundles!928
2025-12-08 12:45:01 +00:00
53b02a0ced Improve the display of upcoming calendar items within the person render box 2025-12-08 12:45:01 +00:00
387bf55b11 fix version constraint in chill-zimbra-bundle 2025-12-05 16:32:30 +00:00
72 changed files with 2194 additions and 1082 deletions

6
.changes/v4.10.0.md Normal file
View File

@@ -0,0 +1,6 @@
## v4.10.0 - 2025-12-09
### Feature
* [MR 928](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/928) [#462](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/462) Add the future appointments for the person in search results
### Fixed
* Remove dependency to package @symfony/ux-translator

6
.changes/v4.10.1.md Normal file
View File

@@ -0,0 +1,6 @@
## v4.10.1 - 2025-12-11
### Fixed
* Fix missing translation variable in NewLocation component
* ([#476](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/476)) Fix display of header for ByActivityNumberAggregator
* Fix use of ByActivityNumberAggregator in combination with activity count exports
* ([#483](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/483)) Tentatively fix usage of CTRL+C in collabora editor with chrome / edge browser

9
.changes/v4.11.0.md Normal file
View File

@@ -0,0 +1,9 @@
## v4.11.0 - 2025-12-17
### Feature
* ([#478](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/478)) Add filtering to admin lists: social actions, social issues, goals, results, and evaluations
### Fixed
* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Fix migration query after previous fix
* Fix translation key/value
Cannot start with % and should be wrapped in "".

16
.changes/v4.12.0.md Normal file
View File

@@ -0,0 +1,16 @@
## v4.12.0 - 2026-01-15
### Feature
* ([#473](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/473)) Display version of chill bundles in application footer
* Increase the delay before removing stale workflow from 90 days to 180 days.
### Fixed
* ([#480](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/480)) Fix the condition to display concerned persons in calendar list items.
* ([#481](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/481)) Fix ordering of social actions: actions with a closing date in the future should be considered as 'still open'.
* ([#477](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/477)) Fix export group by center for persons without a center in CenterAggregator.php
* Fix the calculation of budget balance to only take into account resources and charges that are still actual
* ([#489](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/489)) Fix desactivation date for Goals and results
* ([#490](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/490)) Prevent sending a notification when the user signs the document himself
* ([#491](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/491)) Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center
* ([#492](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/492)) fix CommentInput: replace deprecated value binding with model-value
* ([#493](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/493)) fix issue with stored object permissions associated with workflows (as attachment, or through a related entity)
BC: the constructor's signature of `\Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter` has changed.

View File

@@ -6,6 +6,45 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.12.0 - 2026-01-15
### Feature
* ([#473](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/473)) Display version of chill bundles in application footer
* Increase the delay before removing stale workflow from 90 days to 180 days.
### Fixed
* ([#480](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/480)) Fix the condition to display concerned persons in calendar list items.
* ([#481](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/481)) Fix ordering of social actions: actions with a closing date in the future should be considered as 'still open'.
* ([#477](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/477)) Fix export group by center for persons without a center in CenterAggregator.php
* Fix the calculation of budget balance to only take into account resources and charges that are still actual
* ([#489](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/489)) Fix desactivation date for Goals and results
* ([#490](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/490)) Prevent sending a notification when the user signs the document himself
* ([#491](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/491)) Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center
* ([#492](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/492)) fix CommentInput: replace deprecated value binding with model-value
* ([#493](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/493)) fix issue with stored object permissions associated with workflows (as attachment, or through a related entity)
## v4.11.0 - 2025-12-17
### Feature
* ([#478](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/478)) Add filtering to admin lists: social actions, social issues, goals, results, and evaluations
### Fixed
* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Fix migration query after previous fix
* Fix translation key/value
Cannot start with % and should be wrapped in "".
## v4.10.1 - 2025-12-11
### Fixed
* Fix missing translation variable in NewLocation component
* ([#476](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/476)) Fix display of header for ByActivityNumberAggregator
* Fix use of ByActivityNumberAggregator in combination with activity count exports
* ([#483](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/483)) Tentatively fix usage of CTRL+C in collabora editor with chrome / edge browser
## v4.10.0 - 2025-12-09
### Feature
* [MR 928](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/928) [#462](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/462) Add the future appointments for the person in search results
### Fixed
* Remove dependency to package @symfony/ux-translator
## v4.9.0 - 2025-12-05
### Feature
* ([#459](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/459)) Add a counter for invitations awaiting reply

View File

@@ -21,7 +21,7 @@
"ext-openssl": "*",
"ext-redis": "*",
"ext-zlib": "*",
"chill-project/chill-zimbra-bundle": "@dev",
"composer-runtime-api": "*",
"champs-libres/wopi-bundle": "dev-symfony-v5@dev",
"champs-libres/wopi-lib": "dev-master@dev",
"doctrine/data-fixtures": "^1.8",
@@ -62,7 +62,6 @@
"symfony/http-client": "^5.4",
"symfony/http-foundation": "^5.4",
"symfony/intl": "^5.4",
"symfony/loco-translation-provider": "^6.0",
"symfony/mailer": "^5.4",
"symfony/messenger": "^5.4",
"symfony/mime": "^5.4",
@@ -84,7 +83,7 @@
"symfony/templating": "^5.4",
"symfony/translation": "^5.4",
"symfony/twig-bundle": "^5.4",
"symfony/ux-translator": "^2.22",
"symfony/ux-translator": "2.31.0",
"symfony/validator": "^5.4",
"symfony/webpack-encore-bundle": "^1.11",
"symfony/workflow": "^5.4",
@@ -99,7 +98,7 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3",
"fakerphp/faker": "^1.13",
"friendsofphp/php-cs-fixer": "3.65.0",
"friendsofphp/php-cs-fixer": "3.92.5",
"jangregor/phpstan-prophecy": "^1.0",
"nelmio/alice": "^3.8",
"nikic/php-parser": "^4.15",
@@ -119,7 +118,8 @@
"symfony/runtime": "^5.4",
"symfony/stopwatch": "^5.4",
"symfony/var-dumper": "^5.4",
"symfony/web-profiler-bundle": "^5.4"
"symfony/web-profiler-bundle": "^5.4",
"symfony/loco-translation-provider": "^6.0"
},
"conflict": {
"symfony/symfony": "*"

View File

@@ -37,5 +37,4 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true],
loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true],
Chill\ZimbraBundle\ChillZimbraBundle::class => ['all' => true],
];

View File

@@ -11,3 +11,6 @@ services:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
when@dev:
services:
ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface: '@Chill\WopiBundle\Service\Wopi\NullProofValidator'

View File

@@ -12,6 +12,8 @@ As Chill relies on the [symfony ](http://symfony.com) framework, reading the fra
- [Messages to users](messages-to-users.md)
- [Pagination](pagination.md)
- [Localisation](localisation.md)
- [Translation directives](translation_directives.md)
- [Translation provider](translation_provider.md)
- [Logging](logging.md)
- [Database migrations](migrations.md)
- [Searching](searching.md)

View File

@@ -0,0 +1,376 @@
# Translation Key Directives
These directives are meant to ensure better consistency across bundles, avoid duplication, and make keys more predictable.
## General Principles
1. **Use lowercase snake_case for all keys**
2. **Use dot-separated namespaces**
The dot is used to reflect:
- bundle
- feature
- sub-feature
- key type
3. **Do not use spaces in keys**
4. **Avoid duplicating the same text in multiple places**
When a translation is needed, try a search for the translation value first and see if it exists elsewhere
5. **If a key is used across multiple bundles, it must live in ChillMainBundle.**
6. **If a key is used across multiple bundles and is a generic term, it must be placed in the `common` namespace.**
## Key Structure
We use the following structure:
```
<scope>.<feature>.<sub-feature>.<key-type>
```
Where:
- `<scope>` identifies the bundle or shared context
- `<feature>` identifies the part of the module using the translation
- `<element>` describes the text purpose
- `<subelement>` for a multi-level element (e.g., activity.export.person.count.description)
### Examples of scopes
- `activity` — ChillActivityBundle
- `person` — ChillPersonBundle
- `common` — neutral shared translation values
## Naming Scopes
### 1. Bundle-specific keys
For most things inside a bundle:
```
activity.<feature>.<element>
```
Example:
```
activity.form.save
activity.list.title
activity.entity.type
activity.menu.activities
activity.controller.success_created
```
### 2. Shared UI elements (buttons, labels, generic text)
These belong in the `common` namespace in ChillMainBundle:
```
common.save
common.delete
common.edit
common.filter
common.duration_time
```
## Translation Workflow
Use the following workflow when deciding where a key belongs:
1. **Is this text used in more than one bundle?**
→ Place in `main` or `common`
2. **Is this text generic UI (button, label, pagination, yes/no)?**
→ Place in `common`
3. **Is this text specific to one bundle and one feature?**
→ Place in `<bundle>.feature.<element>`
4. **Is this text related to an entity or value object?**
→ Place in `<bundle>.entity.<entityname>.<field>`
5. **Is this text used in forms?**
`<bundle>.form.<field>` or `<bundle>.form.<action>`
6. **Is this text related to exports?**
`<bundle>.export.<feature>.<column>`
7. **Is it related to filtering, searching or parameters?**
`<bundle>.filter.<name>` or
`<bundle>.filter.<feature>.<field>` for nested filters
## Examples Based on Translations Within ChillActivityBundle
Below are concrete examples from `ChillActivityBundle`, refactored according to the guidelines.
### General activity keys
Instead of scattered keys like:
```
Show the activity
Edit the activity
Activity
Duration time
...
```
We use:
```
activity.general.show
activity.general.edit
activity.general.title
activity.general.duration
activity.general.travel_time
activity.general.attendee
activity.general.remark
activity.general.no_comments
```
### Forms
Instead of keys like:
```
Activity creation
Save activity
Reset form
Choose a type
```
Use:
```
activity.form.title_create
activity.form.save
activity.form.reset
activity.form.choose_type
activity.form.choose_duration
```
Long lists (like durations) should be grouped:
```
activity.form.duration.5min
activity.form.duration.10min
activity.form.duration.15min
activity.form.duration.1h
activity.form.duration.1h30
activity.form.duration.2h
...
```
### Entities
Entity fields should follow:
```
activity.entity.activity.date
activity.entity.activity.comment
activity.entity.activity.deleted
activity.entity.location.name
activity.entity.location.type
```
### Controller messages
Instead of strings as keys:
```
'Success : activity created!'
'The form is not valid. The activity has not been created !'
```
Use:
```
activity.controller.success_created
activity.controller.error_invalid_create
activity.controller.success_updated
activity.controller.error_invalid_update
```
### Roles
Access control keys should be:
```
activity.role.create
activity.role.update
activity.role.see
activity.role.see_details
activity.role.delete
activity.role.stats
activity.role.list
```
### Admin
```
activity.admin.configuration
activity.admin.types
activity.admin.reasons
activity.admin.reason_category
activity.admin.presence
```
### CRUD
```
activity.crud.type.title_new
activity.crud.type.title_edit
activity.crud.presence.title_new
```
### Activity Reason
```
activity.reason.list
activity.reason.create
activity.reason.active
activity.reason.category
activity.reason.entity_title
```
### Exports
Group them logically:
```
activity.export.person.count.title
activity.export.person.count.description
activity.export.person.count.header
activity.export.period.sum_duration.title
activity.export.period.sum_duration.description
activity.export.period.sum_duration.header
```
### Filters
Use hierarchical filters:
```
activity.filter.by_reason
activity.filter.by_type
activity.filter.by_date
activity.filter.by_location
activity.filter.by_sent_received
activity.filter.by_user
```
### Aggregators
```
activity.aggregator.reason.by_category
activity.aggregator.reason.level
activity.aggregator.user.by_scope
activity.aggregator.user.by_job
```
## Global/Shared Keys
Keys like the following **must not be redeclared** in each bundle:
- First name
- Last name
- Username
- ID
- Type
- Duration
- Comment
- Date
- Location
- Present / Not present
- Add / Edit / Delete / Save / Update
These belong in `common` or `main`:
```
common.firstname
common.lastname
common.username
common.id
common.type
common.comment
common.date
common.location
common.present
common.absent
common.add
common.edit
common.delete
common.save
common.update
```
## Naming Directives Summary
- **snake_case**
- **namespaced with dots**
- **bundle prefix for bundle-specific concepts**
- **common or main for shared concepts**
- **avoid free-floating keys (without namespace)**
- **reuse common keys wherever possible**
## Migration Strategy (Optional)
To apply this structure progressively:
1. New keys must follow these guidelines.
2. Existing keys may remain as-is until refactored.
3. When refactoring:
- Move cross-bundle keys to ChillMainBundle and possible `common` namespace.
- Replace duplicated keys with shared ones.
---
# Avoiding Duplicate Translations
## 1. Use Shared Namespaces
Two namespaces must be used for shared translations:
- `common.*` — generic UI concepts (save, delete, date, name, etc.)
If a translation may be reused in multiple bundles, it must be placed in the `common` namespace or in ChillMainBundle.
## 2. Bundle-Specific Keys
Keys belonging only to one bundle or one feature are namespaced inside that bundle:
```
activity.<feature>.<element>
person.<feature>.<element>
```
## 3. Search Before Creating
Before adding a new translation key, developers must:
1. For common translations like "enregistrer/opslaan" look in the `common` namespace.
2. Search in Loco or translations for existing values.
If a suitable key exists, reuse it.
## 4. Only Create a New Key When Necessary
Create a new key only when the text is:
- specific to the bundle
- specific to the feature
- not reusable elsewhere
## 5. Progressive Cleanup
Old duplicates may remain temporarily. When updating code in an area, clean duplicate values by moving them into `common` or `main`.
## General Workflow
- **Reuse shared keys** within `common` namespace.
- **Search before creating** new keys.
- **Namespace bundle-specific keys** under their bundle.
- **Refactor progressively** when touching old code.

View File

@@ -1,419 +0,0 @@
============================================
Directives for creating new translation keys
============================================
These directives are meant to ensure better consistency across bundles, avoid duplication, and make keys more predictable.
General Principles
==================
1. **Use lowercase snake_case for all keys**
2. **Use dot-separated namespaces**
The dot is used to reflect:
- bundle
- feature
- sub-feature
- key type
3. **Do not use spaces in keys**
4. **Avoid duplicating the same text in multiple places**
When a translation is needed, try a search for the translation value first and see if it exists elsewhere
5. **If a key is used across multiple bundles, it must live in ChillMainBundle.**
6. **If a key is used across multiple bundles and is a generic term, it must be placed in the ``common`` namespace.**
Key Structure
=============
We use the following structure:
.. code-block:: text
<scope>.<feature>.<sub-feature>.<key-type>
Where:
* ``<>`` identifies the bundle or shared context
* ``<feature>`` identifies the part of the module using the translation
* ``<element>`` describes the text purpose
* ``<subelement>`` for a multi-level element ( eg. activity.export.person.count.description)
Examples of scopes
------------------
* ``activity`` — ChillActivityBundle
* ``person`` — ChillPersonBundle
* ``common`` — neutral shared translation values
Naming Scopes
=============
1. **Bundle-specific keys**
For most things inside a bundle:
.. code-block:: text
activity.<feature>.<element>
Example:
.. code-block:: text
activity.form.save
activity.list.title
activity.entity.type
activity.menu.activities
activity.controller.success_created
2. **Shared UI elements (buttons, labels, generic text)**
These belong in the ``common`` namespace in ChillMainBundle:
.. code-block:: text
common.save
common.delete
common.edit
common.filter
common.duration_time
Translation workflow
====================
Use the following workflow when deciding where a key belongs:
1. **Is this text used in more than one bundle?**
→ Place in ``main`` or ``common``
2. **Is this text generic UI (button, label, pagination, yes/no)?**
→ Place in ``common``
3. **Is this text specific to one bundle and one feature?**
→ Place in ``<bundle>.feature.<element>``
4. **Is this text related to an entity or value object?**
→ Place in ``<bundle>.entity.<entityname>.<field>``
5. **Is this text used in forms?**
``<bundle>.form.<field>`` or ``<bundle>.form.<action>``
6. **Is this text related to exports?**
``<bundle>.export.<feature>.<column>``
7. **Is it related to filtering, searching or parameters?**
``<bundle>.filter.<name>`` or
``<bundle>.filter.<feature>.<field>`` for nested filters
Examples based on translations within ChillActivityBundle
=========================================================
Below are concrete examples from ``ChillActivityBundle``,
refactored according to the guidelines.
General activity keys
---------------------
Instead of scattered keys like::
Show the activity
Edit the activity
Activity
Duration time
...
We use:
.. code-block:: text
activity.general.show
activity.general.edit
activity.general.title
activity.general.duration
activity.general.travel_time
activity.general.attendee
activity.general.remark
activity.general.no_comments
Forms
-----
Instead of keys like::
Activity creation
Save activity
Reset form
Choose a type
Use:
.. code-block:: text
activity.form.title_create
activity.form.save
activity.form.reset
activity.form.choose_type
activity.form.choose_duration
Long lists (like durations) should be grouped:
.. code-block:: text
activity.form.duration.5min
activity.form.duration.10min
activity.form.duration.15min
activity.form.duration.1h
activity.form.duration.1h30
activity.form.duration.2h
...
Entities
--------
Entity fields should follow:
.. code-block:: text
activity.entity.activity.date
activity.entity.activity.comment
activity.entity.activity.deleted
activity.entity.location.name
activity.entity.location.type
Controller messages
-------------------
Instead of strings as keys::
'Success : activity created!'
'The form is not valid. The activity has not been created !'
Use:
.. code-block:: text
activity.controller.success_created
activity.controller.error_invalid_create
activity.controller.success_updated
activity.controller.error_invalid_update
Roles
-----
Access control keys should be:
.. code-block:: text
activity.role.create
activity.role.update
activity.role.see
activity.role.see_details
activity.role.delete
activity.role.stats
activity.role.list
Admin
-----
.. code-block:: text
activity.admin.configuration
activity.admin.types
activity.admin.reasons
activity.admin.reason_category
activity.admin.presence
CRUD
----
.. code-block:: text
activity.crud.type.title_new
activity.crud.type.title_edit
activity.crud.presence.title_new
Activity Reason
---------------
.. code-block:: text
activity.reason.list
activity.reason.create
activity.reason.active
activity.reason.category
activity.reason.entity_title
Exports
-------
Group them logically:
.. code-block:: text
activity.export.person.count.title
activity.export.person.count.description
activity.export.person.count.header
activity.export.period.sum_duration.title
activity.export.period.sum_duration.description
activity.export.period.sum_duration.header
Filters
-------
Use hierarchical filters:
.. code-block:: text
activity.filter.by_reason
activity.filter.by_type
activity.filter.by_date
activity.filter.by_location
activity.filter.by_sent_received
activity.filter.by_user
Aggregators
-----------
.. code-block:: text
activity.aggregator.reason.by_category
activity.aggregator.reason.level
activity.aggregator.user.by_scope
activity.aggregator.user.by_job
Global/Shared Keys
==================
Keys like the following **must not be redeclared** in each bundle:
- First name
- Last name
- Username
- ID
- Type
- Duration
- Comment
- Date
- Location
- Present / Not present
- Add / Edit / Delete / Save / Update
These belong in ``common`` or ``main``:
.. code-block:: text
common.firstname
common.lastname
common.username
common.id
common.type
common.comment
common.date
common.location
common.present
common.absent
common.add
common.edit
common.delete
common.save
common.update
Naming directives summary
==========================
* **snake_case**
* **namespaced with dots**
* **bundle prefix for bundle-specific concepts**
* **common or main for shared concepts**
* **avoid free-floating keys (without namespace)**
* **reuse common keys wherever possible**
Migration Strategy (Optional)
=============================
To apply this structure progressively:
1. New keys must follow these guidelines.
2. Existing keys may remain as-is until refactored.
3. When refactoring:
- Move cross-bundle keys to ChillMainBundle and possible `common` namespace.
- Replace duplicated keys with shared ones.
===========================================
Avoiding duplicate translations
===========================================
1. Use Shared Namespaces
========================
Two namespaces must be used for shared translations:
* ``common.*`` — generic UI concepts (save, delete, date, name, etc.)
If a translation may be reused in multiple bundles, it must be placed
in the ``common`` namespace or in ChillMainBundle.
2. Bundle-Specific Keys
=======================
Keys belonging only to one bundle or one feature are namespaced inside that
bundle:
.. code-block:: text
activity.<feature>.<element>
person.<feature>.<element>
3. Search Before Creating
=========================
Before adding a new translation key, developers must:
1. For common translations like: "enregistrer/opslaan" look in the `common` namespace.
3. Search in Loco or translations for existing values.
If a suitable key exists, reuse it.
4. Only Create a New Key When Necessary
=======================================
Create a new key only when the text is:
* specific to the bundle
* specific to the feature
* not reusable elsewhere
6. Progressive Cleanup
======================
Old duplicates may remain temporarily. When updating code in an area, clean
duplicate values by moving them into ``common`` or ``main``.
General workflow
================
* **Reuse shared keys** within ``common`` namespace.
* **Search before creating** new keys.
* **Namespace bundle-specific keys** under their bundle.
* **Refactor progressively** when touching old code.

View File

@@ -0,0 +1,139 @@
# Managing Translations Within CHILL Using Loco as a Translation Provider
Within CHILL we make use of Symfony's translation component together with *Loco* as an external translation provider. Using this setup centralises translations in a single online location (Loco), while still allowing developers to create and update translation keys locally in the project (YAML files).
## Workflow
We use the following workflow:
- Developers create translation keys in YAML files inside each bundle.
- Keys are written in **English**.
- Application UI defaults to **French**, with **Dutch** as an additional locale (other languages can be added in the future).
- Loco acts as the central translation memory and synchronisation source.
- Loco Symfony package was installed so that built-in translation commands can be used to push/pull content between Loco and the local project.
## Translation Directory Structure
Each bundle contains its own `translations` directory, for example:
```
chill-bundles/
ChillCoreBundle/
translations/
messages.fr.yml
messages.nl.yml
ChillPersonBundle/
translations/
messages.fr.yml
messages.nl.yml
...
```
## Configuration
The translation configuration is defined in `config/packages/translation.yaml`:
```yaml
framework:
default_locale: '%env(resolve:LOCALE)%'
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- '%env(resolve:LOCALE)%'
- 'en'
providers:
loco:
dsn: '%env(LOCO_DSN)%'
domains: [ 'messages' ]
locales: [ 'fr', 'nl' ]
```
Note:
- `en` is the **source locale** in Loco.
- `fr` and `nl` are the **application locales**.
- `domains: [messages]` means only `messages.*.yml` files are pushed.
### Environment Variables
In `.env`:
```
LOCALE=fr
```
In `.env.local`:
```
LOCO_DSN="loco://API_KEY@default"
```
Replace `API_KEY` with the key provided by Loco.
## Working with Loco
Loco shows all translation keys under three languages:
- **English (source)** — keys are listed but remain "untranslated"
- **French** — translated strings for French users
- **Dutch** — translated strings for Dutch users
Note: Don't add translations directly in the English column. This column simply represents the *key*.
## Pushing Translations to Loco
You can push local translations to Loco using:
```bash
symfony console translation:push loco --locales=fr --locales=nl --force
```
This will:
- Upload all French and Dutch translation values from `*.fr.yml` and `*.nl.yml` files
- Ensure Loco stays in sync with local YAML files
- Create any missing keys in Loco
## Pulling Translations from Loco
When translators update strings in Loco, developers can fetch updates with:
```bash
symfony console translation:pull loco --locales=fr --locales=nl --force
```
This will:
- Download the latest French and Dutch translations
- Overwrite the local YAML files with Loco's content
- Keep everything consistent across the team
## Adding New Translation Keys (Developer Workflow)
1. Add a new key directly in the appropriate YAML file, for example:
```
chill-bundles/ChillPersonBundle/translations/messages.fr.yml
```
Example key:
```yaml
person.form.submit: "Envoyer"
```
2. Add Dutch translation as well if you can (otherwise leave empty to be translated within Loco later):
```yaml
person.form.submit: "Verzenden"
```
3. Run a push to send the new key to Loco:
```bash
symfony console translation:push loco --locales=fr --locales=nl --force
```
4. The key will now appear in Loco for translation management.
Note: English appears as "untranslated", because it is merely the source language.

View File

@@ -1,148 +0,0 @@
=======================================================================
Managing translations within CHILL using Loco as a translation provider
=======================================================================
Within CHILL we make use of Symfony's translation component together with *Loco* as an external
translation provider. Using this setup centralise translations in a single online
location (Loco), while still allowing developers to create and update
translation keys locally in the project (YAML files).
Workflow
========
We use the following workflow:
* Developers create translation keys in YAML files inside each bundle.
* Keys are written in **English**.
* Application UI defaults to **French**, with **Dutch** as an additional locale (other languages can be added in the future).
* Loco acts as the central translation memory and synchronisation source.
* Loco Symfony package was installed so that built-in translation commands can be used to push/pull content
between Loco and the local project.
Translation directory structure
===============================
Each bundle contains its own ``translations`` directory, for example::
chill-bundles/
ChillCoreBundle/
translations/
messages.fr.yml
messages.nl.yml
ChillPersonBundle/
translations/
messages.fr.yml
messages.nl.yml
...
Configuration
=============
The translation configuration is defined in
``config/packages/translation.yaml``::
framework:
default_locale: '%env(resolve:LOCALE)%'
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- '%env(resolve:LOCALE)%'
- 'en'
providers:
loco:
dsn: '%env(LOCO_DSN)%'
domains: [ 'messages' ]
locales: [ 'fr', 'nl' ]
Note:
* ``en`` is the **source locale** in Loco.
* ``fr`` and ``nl`` are the **application locales**.
* ``domains: [messages]`` means only ``messages.*.yml`` files are pushed.
Environment variables
---------------------
In ``.env``::
LOCALE=fr
In ``.env.local``::
LOCO_DSN="loco://API_KEY@default"
Replace ``API_KEY`` with the key provided by Loco.
Working with Loco
=================
Loco shows all translation keys under three languages:
* **English (source)** — keys are listed but remain “untranslated”
* **French** — translated strings for French users
* **Dutch** — translated strings for Dutch users
Note: Don't add translations directly in the English column.
This column simply represents the *key*.
Pushing translations to Loco
============================
You can push local translations to Loco using:
.. code-block:: bash
symfony console translation:push loco --locales=fr --locales=nl --force
This will:
* Upload all French and Dutch translation values from ``*.fr.yml`` and
``*.nl.yml`` files
* Ensures Loco stays in sync with local YAML files
* Creates any missing keys in Loco
Pulling translations from Loco
==============================
When translators update strings in Loco, developers can fetch updates with:
.. code-block:: bash
symfony console translation:pull loco --locales=fr --locales=nl --force
This will:
* Download the latest French and Dutch translations
* Overwrite the local YAML files with Locos content
* Keep everything consistent across the team
Adding new translation keys (Developer workflow)
================================================
1. Add a new key directly in the appropriate YAML file, for example::
chill-bundles/ChillPersonBundle/translations/messages.fr.yml
Example key::
person.form.submit: "Envoyer"
2. Add Dutch translation as well if you can (otherwise leave empty to be translated within Loco later)::
person.form.submit: "Verzenden"
3. Run a push to send the new key to Loco:
.. code-block:: bash
symfony console translation:push loco --locales=fr --locales=nl --force
4. The key will now appear in Loco for translation management.
Note: English appears as “untranslated”, because it is merely the source language

View File

@@ -11,7 +11,6 @@
"@hotwired/stimulus": "^3.0.0",
"@luminateone/eslint-baseline": "^1.0.9",
"@symfony/stimulus-bridge": "^3.2.0",
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
"@symfony/webpack-encore": "^4.1.0",
"@tsconfig/node20": "^20.1.4",
"@types/dompurify": "^3.0.5",

View File

@@ -9,7 +9,7 @@
"social worker"
],
"require": {
"chill-project/chill-bundles": "dev-master as v4.6.1",
"chill-project/chill-bundles": "^4.9.0",
"zimbra-api/soap-api": "^3.2.2",
"psr/http-client": "^1.0",
"nyholm/psr7": "^1.0"

View File

@@ -3,7 +3,6 @@ parameters:
paths:
- src/
- utils/
- packages/
tmpDir: var/cache/phpstan
reportUnmatchedIgnoredErrors: false
excludePaths:

View File

@@ -27,7 +27,8 @@ class ByActivityNumberAggregator implements AggregatorInterface
public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void
{
$qb
->addSelect('(SELECT COUNT(activity.id) FROM '.Activity::class.' activity WHERE activity.accompanyingPeriod = acp) AS activity_by_number_aggregator')
// Use a distinct alias inside the subquery to avoid colliding with the root alias "activity"
->addSelect('(SELECT COUNT(agg_activity.id) FROM '.Activity::class.' agg_activity WHERE agg_activity.accompanyingPeriod = acp) AS activity_by_number_aggregator')
->addGroupBy('activity_by_number_aggregator');
}
@@ -65,7 +66,7 @@ class ByActivityNumberAggregator implements AggregatorInterface
{
return static function ($value) {
if ('_header' === $value) {
return '';
return 'Count activities linked to an accompanying period';
}
if (null === $value) {

View File

@@ -136,6 +136,8 @@ import {
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
trans,
} from "translator";
@@ -156,6 +158,8 @@ export default {
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
};
},
props: ["availableLocations"],
@@ -179,14 +183,14 @@ export default {
options: {
button: {
text: {
create: "activity.create_address",
edit: "activity.edit_address",
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
size: "btn-sm",
},
title: {
create: "activity.create_address",
edit: "activity.edit_address",
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
},
context: {

View File

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

View File

@@ -39,7 +39,7 @@ final class Version20251118124241 extends AbstractMigration
$this->addSql("COMMENT ON COLUMN activity_user.by_migration IS 'For backup purpose - can be safely deleted after a while. See migration \\Chill\\Migrations\\Activity\\Version20251118124241'");
$this->addSql('INSERT INTO activity_user (activity_id, user_id, by_migration)
SELECT id, user_id, true FROM activity
SELECT id, user_id, true FROM activity WHERE user_id is not null
ON CONFLICT DO NOTHING');
}

View File

@@ -72,14 +72,20 @@
{% macro table_results(actualCharges, actualResources, results) %}
{% set now = date() %}
{% set totalCharges = 0 %}
{% for c in actualCharges %}
{% set totalCharges = totalCharges + c.amount %}
{% if c.startDate <= now and (c.endDate is null or c.endDate >= now) %}
{% set totalCharges = totalCharges + c.amount %}
{% endif %}
{% endfor %}
{% set totalResources = 0 %}
{% for r in actualResources %}
{% set totalResources = totalResources + r.amount %}
{% if r.startDate <= now and (r.endDate is null or r.endDate >= now) %}
{% set totalResources = totalResources + r.amount %}
{% endif %}
{% endfor %}
{% set result = (totalResources - totalCharges) %}

View File

@@ -75,7 +75,7 @@
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.persons|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">

View File

@@ -15,7 +15,10 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
@@ -34,7 +37,8 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function __construct(
private readonly Security $security,
private readonly ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
@@ -46,16 +50,6 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
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
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
@@ -65,7 +59,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
$regularPermission = $this->security->isGranted($voterAttribute, $entity);
if (!$this->canBeAssociatedWithWorkflow()) {
return $regularPermission;
return $this->voteOnStoredObjectAsAttachementOfAWorkflow($attribute, $regularPermission, $subject);
}
$workflowPermission = match ($attribute) {
@@ -74,9 +68,41 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
};
return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN => $this->voteOnStoredObjectAsAttachementOfAWorkflow($attribute, $regularPermission, $subject),
};
}
private function voteOnStoredObjectAsAttachementOfAWorkflow(StoredObjectRoleEnum $attribute, bool $regularPermission, StoredObject $storedObject): bool
{
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($storedObject);
// we get all the entity workflows where the stored object is attached
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
// we compute all the permission for each entity workflow
$permissions = array_map(fn (EntityWorkflow $entityWorkflow): string => match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($entityWorkflow),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($entityWorkflow),
}, $entityWorkflows);
// now, we reduce the permissions: abstain are ignored. Between DENIED and and GRANT, DENIED takes precedence
$computedPermission = WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN;
foreach ($permissions as $permission) {
if (WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED === $permission) {
return false;
}
if (WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT === $permission) {
$computedPermission = WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT;
}
}
if (WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN === $computedPermission) {
return $regularPermission;
}
// this is the case where WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT is returned
return true;
}
}

View File

@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Symfony\Component\Security\Core\Security;
@@ -25,8 +26,9 @@ final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredOb
private readonly AccompanyingCourseDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Symfony\Component\Security\Core\Security;
@@ -25,8 +26,9 @@ class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
private readonly PersonDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -16,8 +16,11 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Security;
@@ -31,21 +34,31 @@ class AbstractStoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @param array<int, EntityWorkflowAttachment> $attachments
*
* @return void
*/
private function buildStoredObjectVoter(
bool $canBeAssociatedWithWorkflow,
AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
?WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService = null,
array $attachments = [],
): AbstractStoredObjectVoter {
$attachmentsRepository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachmentsRepository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
// Anonymous class extending the abstract class
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter {
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $attachmentsRepository->reveal(), $workflowDocumentService) extends AbstractStoredObjectVoter {
public function __construct(
private readonly bool $canBeAssociatedWithWorkflow,
private readonly AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
EntityWorkflowAttachmentRepository $attachmentRepository,
WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function attributeToRole($attribute): string
@@ -72,28 +85,29 @@ class AbstractStoredObjectVoterTest extends TestCase
public function testSupportsOnAttribute(): void
{
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), null);
$entityWorkflowService = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), null);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), null);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
}
/**
* @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission
* @dataProvider dataProviderVoteOnAttributeWithWorkflow
*/
public function testVoteOnAttributeWithStoredObjectPermission(
public function testVoteOnAttributeWithWorkflow(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $isGrantedRegularPermission,
string $isGrantedWorkflowPermission,
string $isGrantedStoredObjectAttachment,
): void {
$storedObject = new StoredObject();
$repository = new DummyRepository($related = new \stdClass());
@@ -102,31 +116,28 @@ class AbstractStoredObjectVoterTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$attachementRepository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachementRepository->findByStoredObject($storedObject)->willReturn([]);
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)
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal(), $attachementRepository->reveal()) extends AbstractStoredObjectVoter {
public function __construct(private $repository, $helper, $security, EntityWorkflowAttachmentRepository $attachmentRepository)
{
parent::__construct($security, $helper);
parent::__construct($security, $attachmentRepository, $helper);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
@@ -155,96 +166,64 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $actual);
}
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable
public static function dataProviderVoteOnAttributeWithWorkflow(): 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,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
];
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,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
true,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
];
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
];
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,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
];
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
}
}
/**
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
* @dataProvider dataProviderVoteOnAttribute
*/
public function testVoteOnAttributeWithoutStoredObjectPermission(
public function testVoteOnAttribute(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $canBeAssociatedWithWorkflow,
@@ -260,10 +239,7 @@ class AbstractStoredObjectVoterTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
@@ -283,27 +259,155 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
public static function dataProviderVoteOnAttribute(): iterable
{
// not associated on a workflow
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];
yield [StoredObjectRoleEnum::SEE, false, false, false, null, null, 'not associated on a workflow, denied by regular access, must not rely on helper'];
// associated on a workflow, read operation
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied'];
// association on a workflow, write operation
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied'];
}
/**
* @dataProvider dataProviderPrecedenceOfDirectAssociationOverWorkflowAttachments
*/
public function testPrecedenceOfDirectAssociationOverWorkflowAttachments(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $regularPermission,
string $directWorkflowPermission,
string $attachmentWorkflowPermission,
string $message,
): 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($regularPermission);
$workflowHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
// Direct association permission
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($directWorkflowPermission);
} else {
$workflowHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($directWorkflowPermission);
}
// Attachment permission
$entityWorkflow = $this->prophesize(\Chill\MainBundle\Entity\Workflow\EntityWorkflow::class)->reveal();
$attachment = $this->prophesize(EntityWorkflowAttachment::class);
$attachment->getEntityWorkflow()->willReturn($entityWorkflow);
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowHelper->isAllowedByWorkflowForReadOperation($entityWorkflow)
->willReturn($attachmentWorkflowPermission);
} else {
$workflowHelper->isAllowedByWorkflowForWriteOperation($entityWorkflow)
->willReturn($attachmentWorkflowPermission);
}
$voter = $this->buildStoredObjectVoter(
true,
$repository,
$security->reveal(),
$workflowHelper->reveal(),
[$attachment->reveal()]
);
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderPrecedenceOfDirectAssociationOverWorkflowAttachments(): iterable
{
$cases = [
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'message' => 'Direct FORCE_GRANT should win over attachment FORCE_DENIED',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'message' => 'Direct FORCE_DENIED should win over attachment FORCE_GRANT',
],
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Direct FORCE_GRANT should win over attachment ABSTAIN',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Direct FORCE_DENIED should win over attachment ABSTAIN',
],
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'message' => 'Direct ABSTAIN should let attachment FORCE_GRANT win',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'message' => 'Direct ABSTAIN should let attachment FORCE_DENIED win',
],
[
'expected' => true,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Both ABSTAIN should let regular permission (true) win',
],
[
'expected' => false,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Both ABSTAIN should let regular permission (false) win',
],
];
foreach ([StoredObjectRoleEnum::SEE, StoredObjectRoleEnum::EDIT] as $attribute) {
foreach ($cases as $case) {
yield sprintf('%s - %s', $attribute->name, $case['message']) => [
$attribute,
$case['expected'],
$case['regular'],
$case['direct'],
$case['attachment'],
$case['message'],
];
}
}
}
}

View File

@@ -14,6 +14,7 @@ namespace Chill\EventBundle\Security\Authorization;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Repository\EventRepository;
@@ -26,8 +27,9 @@ class EventStoredObjectVoter extends AbstractStoredObjectVoter
private readonly EventRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -189,14 +189,14 @@ crud:
title_edit: Rapport "belemmering" bewerken
title_delete: Belemmering verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
cscv:
title_new: Nieuw CV voor %person%
title_view: CV voor %person%
title_edit: CV bewerken
title_delete: CV verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
no_date: Geen datum aangegeven
no_end_date: einddatum onbekend
no_start_date: startdatum onbekend
@@ -206,7 +206,7 @@ crud:
title_edit: Immersie bewerken
title_delete: Immersie verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
projet_prof:
title_new: Nieuw professioneel project voor %person%
title_view: Professioneel project voor %person%

View File

@@ -2,6 +2,10 @@
<p>
{{ 'This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>'|trans|raw }}
<br/>
{% if get_chill_version() %}
{{ 'footer.Running chill version %version%'|trans({ '%version%': get_chill_version() }) }}
{% endif %}
<br/>
<a name="bottom" class="btn text-white" href="https://gitea.champs-libres.be/Chill-project/manuals/releases" target="_blank">
{{ 'User manual'|trans }}
</a>

View File

@@ -0,0 +1,45 @@
<?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\Service;
use Composer\InstalledVersions;
readonly class VersionProvider
{
public function __construct(private string $packageName) {}
public function getVersion(): string
{
try {
$version = InstalledVersions::getPrettyVersion($this->packageName);
if (null === $version) {
return 'unknown';
}
return $version;
} catch (\OutOfBoundsException) {
return 'unknown';
}
}
public function getFormattedVersion(): string
{
$version = $this->getVersion();
if ('unknown' === $version) {
return 'Version unavailable';
}
return $version;
}
}

View File

@@ -23,7 +23,7 @@ class CancelStaleWorkflowCronJob implements CronJobInterface
{
public const KEY = 'remove-stale-workflow';
public const KEEP_INTERVAL = 'P90D';
public const KEEP_INTERVAL = 'P180D';
private const LAST_CANCELED_WORKFLOW = 'last-canceled-workflow-id';

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\Templating;
use Chill\MainBundle\Service\VersionProvider;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class VersionRenderExtension extends AbstractExtension
{
public function __construct(
private readonly VersionProvider $versionProvider,
) {}
public function getFunctions(): array
{
return [
new TwigFunction('get_chill_version', $this->getChillVersion(...)),
];
}
public function getChillVersion(): string
{
return $this->versionProvider->getFormattedVersion();
}
}

View File

@@ -42,14 +42,14 @@ class CancelStaleWorkflowHandlerTest extends TestCase
{
use ProphecyTrait;
public function testWorkflowWithOneStepOlderThan90DaysIsCanceled(): void
public function testWorkflowWithOneStepOlderThan180DaysIsCanceled(): void
{
$clock = new MockClock('2024-01-01');
$daysAgos = new \DateTimeImmutable('2023-09-01');
$daysAgos = new \DateTimeImmutable('2023-06-01');
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$workflow->setCreatedAt(new \DateTimeImmutable('2023-06-01'));
$workflow->setStep('step1', new WorkflowTransitionContextDTO($workflow), 'to_step1', $daysAgos, new User());
$em = $this->prophesize(EntityManagerInterface::class);
@@ -94,7 +94,7 @@ class CancelStaleWorkflowHandlerTest extends TestCase
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$workflow->setCreatedAt(new \DateTimeImmutable('2023-06-01'));
$em = $this->prophesize(EntityManagerInterface::class);
$em->flush()->shouldBeCalled();

View File

@@ -15,7 +15,9 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EventSubscriber\NotificationOnTransition;
@@ -87,7 +89,7 @@ final class NotificationOnTransitionTest extends TestCase
->willReturn([]);
$registry = $this->prophesize(Registry::class);
$registry->get(Argument::type(EntityWorkflow::class), Argument::type('string'))
$registry->get(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn($workflow);
$security = $this->prophesize(Security::class);
@@ -111,4 +113,74 @@ final class NotificationOnTransitionTest extends TestCase
$notificationOnTransition->onCompletedSendNotification($event);
}
public function testOnCompleteDoNotSendNotificationIfStepCreatedByPreviousSignature(): void
{
$dest = new User();
$currentUser = new User();
$workflowProphecy = $this->prophesize(WorkflowInterface::class);
$workflow = $workflowProphecy->reveal();
$entityWorkflow = new EntityWorkflow();
$entityWorkflow
->setWorkflowName('workflow_name')
->setRelatedEntityClass(\stdClass::class)
->setRelatedEntityId(1);
// force an id to entityWorkflow:
$reflection = new \ReflectionClass($entityWorkflow);
$id = $reflection->getProperty('id');
$id->setValue($entityWorkflow, 1);
$previousStep = new EntityWorkflowStep();
$previousStep->addSignature($signature = new EntityWorkflowStepSignature($previousStep, $dest));
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
$currentStep = new EntityWorkflowStep();
$currentStep->addDestUser($dest);
$currentStep->setCurrentStep('to_state');
$entityWorkflow->addStep($previousStep);
$entityWorkflow->addStep($currentStep);
$em = $this->prophesize(EntityManagerInterface::class);
// we check that NO notification has been persisted for $dest
$em->persist(Argument::that(
fn ($notificationCandidate) => $notificationCandidate instanceof Notification && $notificationCandidate->getAddressees()->contains($dest)
))->shouldNotBeCalled();
$engine = $this->prophesize(\Twig\Environment::class);
$engine->render(Argument::type('string'), Argument::type('array'))
->willReturn('dummy text');
$extractor = $this->prophesize(MetadataExtractor::class);
$extractor->buildArrayPresentationForPlace(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn([]);
$extractor->buildArrayPresentationForWorkflow(Argument::any())
->willReturn([]);
$registry = $this->prophesize(Registry::class);
$registry->get(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn($workflow);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn(null);
$entityWorkflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$entityWorkflowHandler->getEntityTitle($entityWorkflow)->willReturn('workflow title');
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getHandler($entityWorkflow)->willReturn($entityWorkflowHandler->reveal());
$notificationOnTransition = new NotificationOnTransition(
$em->reveal(),
$engine->reveal(),
$extractor->reveal(),
$security->reveal(),
$registry->reveal(),
$entityWorkflowManager->reveal(),
);
$event = new Event($entityWorkflow, new Marking(), new Transition('dummy_transition', ['from_state'], ['to_state']), $workflow);
$notificationOnTransition->onCompletedSendNotification($event);
}
}

View File

@@ -11,9 +11,6 @@ declare(strict_types=1);
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\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
@@ -269,217 +266,7 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
$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()));
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
}
private static function buildRegistry(): Registry

View File

@@ -103,7 +103,10 @@ class NotificationOnTransition implements EventSubscriberInterface
foreach ($dests as $subscriber) {
if (
// prevent to send a notification to the one who created the step
$this->security->getUser() === $subscriber
// prevent to send a notification if the user applyied a signature on the previous step
|| $this->isStepCreatedByPreviousSignature($entityWorkflow, $subscriber)
) {
continue;
}
@@ -131,4 +134,31 @@ class NotificationOnTransition implements EventSubscriberInterface
$this->entityManager->persist($notification);
}
}
/**
* Checks if the current step in the workflow was created by a previous signature of the specified user.
*
* This method retrieves the current step of the workflow and its preceding step. It iterates through
* the signatures of the preceding step to verify if the provided user is the signer of any of those
* signatures. Returns true if the user matches any signer; otherwise, returns false.
*
* @param EntityWorkflow $entityWorkflow the workflow entity containing the current step and its details
* @param User $user the user to check against the signatures of the previous step in the workflow
*
* @return bool true if the specified user created the step via a previous signature, false otherwise
*/
private function isStepCreatedByPreviousSignature(EntityWorkflow $entityWorkflow, User $user): bool
{
$step = $entityWorkflow->getCurrentStepChained();
$previous = $step->getPrevious();
foreach ($previous->getSignatures() as $signature) {
if ($signature->getSigner() === $user) {
return true;
}
}
return false;
}
}

View File

@@ -11,12 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security;
@@ -52,48 +49,28 @@ use Symfony\Component\Workflow\Registry;
* the workflow denys write operations;
* - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted;
*/
class WorkflowRelatedEntityPermissionHelper
final readonly class WorkflowRelatedEntityPermissionHelper implements WorkflowRelatedEntityPermissionHelperInterface
{
public const FORCE_GRANT = 'FORCE_GRANT';
public const FORCE_DENIED = 'FORCE_DENIED';
public const ABSTAIN = 'ABSTAIN';
public function __construct(
private readonly Security $security,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly Registry $registry,
private readonly ClockInterface $clock,
private Security $security,
private EntityWorkflowManager $entityWorkflowManager,
private Registry $registry,
private ClockInterface $clock,
) {}
/**
* @param object $entity The entity may be an
* @param object $entity The entity may be an object that is a related entity of a workflow, or an EntityWorkflow itself
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
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);
$isAttached = false;
}
if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
$entityWorkflows = $entity instanceof EntityWorkflow ? [$entity] : $this->entityWorkflowManager->findByRelatedEntity($entity);
if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) {
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
// the signature last state
foreach ($entityWorkflows as $workflow) {
@@ -117,24 +94,20 @@ class WorkflowRelatedEntityPermissionHelper
}
/**
* @param object $entity the entity may be an object which is the related entity of a workflow
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
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);
$isAttached = false;
}
$entityWorkflows = $entity instanceof EntityWorkflow ? [$entity] : $this->entityWorkflowManager->findByRelatedEntity($entity);
if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
// if a workflow is finalized positive, anyone is allowed to edit the document anymore
// if a workflow is finalized positive or isSentExternal, no one is allowed to edit the document anymore
foreach ($entityWorkflows as $entityWorkflow) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
@@ -147,39 +120,64 @@ class WorkflowRelatedEntityPermissionHelper
// the workflow is final, and final positive, or is sentExternal, so we stop here.
return self::FORCE_DENIED;
}
if (
// if not finalized positive
$entityWorkflow->isFinal() && !($placeMetadata['isFinalPositive'] ?? false)
) {
return self::ABSTAIN;
}
}
}
$runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal());
// if there is a signature on a **running workflow**, no one is allowed edit anymore, except if the workflow is canceled
foreach ($entityWorkflows as $entityWorkflow) {
// if the workflow is canceled, we ignore it
$isFinalNegative = false;
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (isset($placeMetadata['isFinalPositive']) && false === $placeMetadata['isFinalPositive']) {
$isFinalNegative = true;
}
}
}
if ($isFinalNegative) {
continue;
}
// if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore
if (!$isAttached) {
foreach ($runningWorkflows as $entityWorkflow) {
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return self::FORCE_DENIED;
}
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return self::FORCE_DENIED;
}
}
}
}
// if all workflows are finalized negative (= canceled), we should abstain
$runningWorkflows = [];
foreach ($entityWorkflows as $entityWorkflow) {
$isFinalNegative = false;
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (isset($placeMetadata['isFinalPositive']) && false === $placeMetadata['isFinalPositive']) {
$isFinalNegative = true;
}
}
}
if (!$isFinalNegative) {
$runningWorkflows[] = $entityWorkflow;
}
}
if ([] === $runningWorkflows) {
return self::ABSTAIN;
}
// allow only the users involved
if ($this->isUserInvolvedInAWorkflow($runningWorkflows)) {
return self::FORCE_GRANT;
}
if ($isAttached) {
return self::ABSTAIN;
}
return self::FORCE_DENIED;
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Workflow\Helper;
/**
* Helper to give supplementary permissions to a related entity.
*
* If a related entity is associated within a workflow, the logic of the workflow can give more permissions, or
* remove some permissions.
*
* The methods of this helper return either:
*
* - FORCE_GRANT, which means that a permission can be given, even if it would be denied when the related
* entity is not associated with a workflow;
* - FORCE_DENIED, which means that a permission should be denied, even if it would be granted when the related entity
* is not associated with a workflow
* - ABSTAIN, if there is no workflow logic to add or remove permission
*
* For read operations:
*
* - if the user is involved in the workflow (is part of the current step, of a step before), the user is granted read
* operation;
* - if there is a pending signature for a person, the workflow grant access to the related entity;
* - if there a signature applyied in less than 12 hours, the workflow grant access to the related entity. This allow to
* show the related entity to the person during this time frame.
*
*
* For write operation:
*
* - if the workflow is finalized "positive" (means "not canceled"), the workflow denys write operations;
* - if there isn't any finalized "positive" workflow, and if there is a signature appliyed for a running workflow (not finalized nor canceled),
* the workflow denys write operations;
* - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted;
*/
interface WorkflowRelatedEntityPermissionHelperInterface
{
public const FORCE_GRANT = 'FORCE_GRANT';
public const FORCE_DENIED = 'FORCE_DENIED';
public const ABSTAIN = 'ABSTAIN';
/**
* @param object $entity The entity may be an object that is a related entity of a workflow, or an EntityWorkflow itself
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForReadOperation(object $entity): string;
/**
* @param object $entity the entity may be an object which is the related entity of a workflow
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForWriteOperation(object $entity): string;
}

View File

@@ -115,3 +115,7 @@ services:
$vienEntityInfoProviders: !tagged_iterator chill_main.entity_info_provider
Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler: ~
Chill\MainBundle\Service\VersionProvider:
arguments:
$packageName: 'chill-project/chill-bundles'

View File

@@ -66,3 +66,7 @@ services:
resource: './../../Templating/Listing'
Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface: '@Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory'
Chill\MainBundle\Templating\VersionRenderExtension:
tags:
- { name: twig.extension }

View File

@@ -48,6 +48,9 @@ See: Voir
Name: Nom
Label: Nom
footer:
Running chill version %version%: "Version de Chill: %version%"
user:
current_user: Utilisateur courant
profile:

View File

@@ -13,14 +13,63 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\EvaluationRepository;
use Symfony\Component\HttpFoundation\Request;
class EvaluationController extends CRUDController
{
public function __construct(private readonly EvaluationRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredEvaluations($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredEvaluations($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredEvaluations(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\GoalRepository;
use Symfony\Component\HttpFoundation\Request;
class GoalController extends CRUDController
{
public function __construct(private readonly GoalRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredGoals($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredGoals(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredGoals(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\ResultRepository;
use Symfony\Component\HttpFoundation\Request;
class ResultController extends CRUDController
{
public function __construct(private readonly ResultRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredResults($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredResults(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredResults(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\SocialActionRepository;
use Symfony\Component\HttpFoundation\Request;
class SocialActionController extends CRUDController
{
public function __construct(private readonly SocialActionRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.ordering', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredSocialActions($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredSocialActions(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredSocialActions(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,11 +13,15 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\SocialIssueRepository;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
class SocialIssueController extends CRUDController
{
public function __construct(private readonly SocialIssueRepository $repository) {}
protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface
{
if ('new' === $action) {
@@ -37,4 +41,54 @@ class SocialIssueController extends CRUDController
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredSocialIssues($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredSocialIssues(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredSocialIssues(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -38,7 +38,7 @@ class SocialWorkEvaluationApiController extends AbstractController
$pagination->getCurrentPageFirstItemNumber(),
$pagination->getItemsPerPage()
);
$collection = new Collection($evaluations, $pagination);
$collection = new Collection(array_values($evaluations), $pagination);
return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['read']]);
}

View File

@@ -25,14 +25,15 @@ class SocialWorkGoalApiController extends ApiController
public function listBySocialAction(Request $request, SocialAction $action): Response
{
$totalItems = $this->goalRepository->countBySocialActionWithDescendants($action);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$totalItems = $this->goalRepository->countBySocialActionWithDescendants($action, true);
$paginator = $this->paginator->create($totalItems);
$entities = $this->goalRepository->findBySocialActionWithDescendants(
$action,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true
);
$model = new Collection($entities, $paginator);

View File

@@ -25,14 +25,15 @@ class SocialWorkResultApiController extends ApiController
public function listByGoal(Request $request, Goal $goal): Response
{
$totalItems = $this->resultRepository->countByGoal($goal);
$totalItems = $this->resultRepository->countByGoal($goal, true);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$entities = $this->resultRepository->findByGoal(
$goal,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true,
);
$model = new Collection($entities, $paginator);
@@ -42,14 +43,15 @@ class SocialWorkResultApiController extends ApiController
public function listBySocialAction(Request $request, SocialAction $action): Response
{
$totalItems = $this->resultRepository->countBySocialActionWithDescendants($action);
$totalItems = $this->resultRepository->countBySocialActionWithDescendants($action, true);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$entities = $this->resultRepository->findBySocialActionWithDescendants(
$action,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true
);
$model = new Collection($entities, $paginator);

View File

@@ -43,6 +43,7 @@ use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
@@ -139,6 +140,12 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
#[ORM\ManyToMany(targetEntity: Calendar::class, mappedBy: 'persons')]
private Collection $calendars;
/**
* @var Collection<int, Calendar>&Selectable<int, Calendar>
*/
#[ORM\OneToMany(mappedBy: 'person', targetEntity: Calendar::class)]
private Collection $directCalendars;
/**
* The person's center.
*
@@ -406,6 +413,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
public function __construct()
{
$this->calendars = new ArrayCollection();
$this->directCalendars = new ArrayCollection();
$this->accompanyingPeriodParticipations = new ArrayCollection();
$this->spokenLanguages = new ArrayCollection();
$this->addresses = new ArrayCollection();
@@ -866,6 +874,30 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this->calendars;
}
/**
* Get next calendars for this person (calendars with start date after today).
* Only returns calendars that are directly linked to this person via the person property,
* not those linked through AccompanyingPeriods.
*
* @param int|null $limit Optional limit for the number of results
*
* @return array<Calendar>
*/
public function getNextCalendarsForPerson(?int $limit = null): array
{
$today = new \DateTimeImmutable('today');
$criteria = Criteria::create()
->where(Criteria::expr()->gte('startDate', $today))
->orderBy(['startDate' => Order::Ascending]);
if (null !== $limit) {
$criteria->setMaxResults($limit);
}
return $this->directCalendars->matching($criteria)->toArray();
}
public function getCenter(): ?Center
{
if (null !== $this->centerCurrent) {
@@ -1119,7 +1151,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
->where(
$expr->eq('shareHousehold', false)
)
->orderBy(['startDate' => \Doctrine\Common\Collections\Order::Descending]);
->orderBy(['startDate' => Order::Descending]);
return $this->getHouseholdParticipations()
->matching($criteria);
@@ -1141,7 +1173,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
->where(
$expr->eq('shareHousehold', true)
)
->orderBy(['startDate' => \Doctrine\Common\Collections\Order::Descending, 'id' => \Doctrine\Common\Collections\Order::Descending]);
->orderBy(['startDate' => Order::Descending, 'id' => Order::Descending]);
return $this->getHouseholdParticipations()
->matching($criteria);

View File

@@ -19,6 +19,7 @@ use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class CenterAggregator implements AggregatorInterface
{
@@ -27,6 +28,7 @@ final readonly class CenterAggregator implements AggregatorInterface
public function __construct(
private CenterRepositoryInterface $centerRepository,
private RollingDateConverterInterface $rollingDateConverter,
private TranslatorInterface $translator,
) {}
public function buildForm(FormBuilderInterface $builder): void
@@ -62,7 +64,7 @@ final readonly class CenterAggregator implements AggregatorInterface
{
return function (int|string|null $value) {
if (null === $value || '' === $value) {
return '';
return $this->translator->trans('person.export.aggregator.by_center.no_center');
}
if ('_header' === $value) {
@@ -94,15 +96,18 @@ final readonly class CenterAggregator implements AggregatorInterface
$atDate = 'pers_center_agg_at_date';
$qb->leftJoin('person.centerHistory', $alias);
$qb
->andWhere(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
)->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.id'),
$qb->expr()->andX(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
)
)
);
)
);
$qb->setParameter($atDate, $this->rollingDateConverter->convert($data['at_date']));
$qb->addSelect("IDENTITY({$alias}.center) AS ".self::COLUMN_NAME);

View File

@@ -44,12 +44,10 @@ final readonly class FilterListAccompanyingPeriodHelper implements FilterListAcc
};
// add filtering on confidential accompanying period. The confidential is applyed on the current status of
// the accompanying period (we do not use the 'calc_date' here
$aclConditionsOrX = $qb->expr()->orX(
// either the current user is the refferer for the course
'acp.user = :list_acp_current_user',
);
$qb->setParameter('list_acp_current_user', $user);
// the accompanying period (we do not use the 'calc_date' here)
//
// IMPORTANT: we must NOT bypass selected centers just because the current user is the referrer.
$aclConditionsOrX = $qb->expr()->orX();
$i = 0;
foreach ($centers as $center) {
@@ -93,6 +91,12 @@ final readonly class FilterListAccompanyingPeriodHelper implements FilterListAcc
++$i;
}
$qb->andWhere($aclConditionsOrX);
// Prevent invalid/empty WHERE when no conditions were added (e.g., no centers available)
if (0 === count($aclConditionsOrX->getParts())) {
// No allowed conditions => return no rows
$qb->andWhere('1 = 0');
} else {
$qb->andWhere($aclConditionsOrX);
}
}
}

View File

@@ -100,9 +100,9 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
$rsm->addRootEntityFromClassMetadata(AccompanyingPeriodWork::class, 'w');
$sql = "SELECT {$rsm} FROM chill_person_accompanying_period_work w
LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id
AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE)
WHERE accompanyingPeriod_id = :periodId";
LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id
AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE)
WHERE accompanyingPeriod_id = :periodId";
// implement filters
@@ -136,11 +136,14 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
}
// set limit and offset
$sql .= " ORDER BY
CASE WHEN w.enddate IS NULL THEN '-infinity'::timestamp ELSE 'infinity'::timestamp END ASC,
w.startdate DESC,
w.enddate DESC,
w.id DESC";
$sql .= ' ORDER BY
CASE
WHEN w.enddate IS NULL OR w.enddate > CURRENT_DATE THEN 0
ELSE 1
END ASC,
w.startdate DESC,
w.enddate DESC,
w.id DESC';
$sql .= ' LIMIT :limit OFFSET :offset';

View File

@@ -14,12 +14,16 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\Evaluation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class EvaluationRepository implements EvaluationRepositoryInterface
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(Evaluation::class);
}
@@ -65,4 +69,86 @@ final readonly class EvaluationRepository implements EvaluationRepositoryInterfa
{
return Evaluation::class;
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('e');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('e.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Evaluation::class, 'e');
// Extract the current locale's value from the JSON `title` and search on it
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(e.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Evaluation::class, 'e');
}
// Add condition based on active/inactive status
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere('e.active = true');
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere('e.active = false');
}
return $qb;
}
public function findFilteredEvaluations(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['title' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredEvaluations(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(e)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
}

View File

@@ -15,21 +15,28 @@ use Chill\PersonBundle\Entity\SocialWork\Goal;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Clock\ClockInterface;
final readonly class GoalRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
public function __construct(
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private RequestStack $requestStack,
) {
$this->repository = $entityManager->getRepository(Goal::class);
}
public function countBySocialActionWithDescendants(SocialAction $action): int
public function countBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): int
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('COUNT(g)');
return $qb
@@ -64,9 +71,9 @@ final readonly class GoalRepository implements ObjectRepository
/**
* @return Goal[]
*/
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null): array
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('g');
$qb->andWhere(
@@ -101,7 +108,103 @@ final readonly class GoalRepository implements ObjectRepository
return Goal::class;
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action): QueryBuilder
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('g');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('g.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Goal::class, 'g');
// search across locales by extracting the localized value
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(g.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Goal::class, 'g');
}
// Active when desactivationDate is null or in the future
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('g.desactivationDate'),
$qb->expr()->gt('g.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('g.desactivationDate'),
$qb->expr()->lte('g.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, Goal>
*/
public function findFilteredGoals(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['id' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredGoals(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(g)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action, bool $onlyActive): QueryBuilder
{
$actions = $action->getDescendantsWithThis();
@@ -116,6 +219,11 @@ final readonly class GoalRepository implements ObjectRepository
}
$qb->where($orx);
if ($onlyActive) {
$qb->andWhere('g.desactivationDate > :now OR g.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
}

View File

@@ -16,21 +16,28 @@ use Chill\PersonBundle\Entity\SocialWork\Result;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Clock\ClockInterface;
final readonly class ResultRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
public function __construct(
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private RequestStack $requestStack,
) {
$this->repository = $entityManager->getRepository(Result::class);
}
public function countByGoal(Goal $goal): int
public function countByGoal(Goal $goal, bool $onlyActive = false): int
{
$qb = $this->buildQueryByGoal($goal);
$qb = $this->buildQueryByGoal($goal, $onlyActive);
$qb->select('COUNT(r)');
return $qb
@@ -38,9 +45,9 @@ final readonly class ResultRepository implements ObjectRepository
->getSingleScalarResult();
}
public function countBySocialActionWithDescendants(SocialAction $action): int
public function countBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): int
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('COUNT(r)');
return $qb
@@ -75,9 +82,9 @@ final readonly class ResultRepository implements ObjectRepository
/**
* @return array<Result>
*/
public function findByGoal(Goal $goal, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
public function findByGoal(Goal $goal, ?array $orderBy = null, ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryByGoal($goal);
$qb = $this->buildQueryByGoal($goal, $onlyActive);
if (null !== $orderBy) {
foreach ($orderBy as $sort => $order) {
@@ -96,9 +103,9 @@ final readonly class ResultRepository implements ObjectRepository
/**
* @return Result[]
*/
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null): array
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('r');
foreach ($orderBy as $sort => $order) {
@@ -125,17 +132,116 @@ final readonly class ResultRepository implements ObjectRepository
return Result::class;
}
private function buildQueryByGoal(Goal $goal): QueryBuilder
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('r');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('r.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Result::class, 'r');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(r.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Result::class, 'r');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('r.desactivationDate'),
$qb->expr()->gt('r.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('r.desactivationDate'),
$qb->expr()->lte('r.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, Result>
*/
public function findFilteredResults(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['id' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredResults(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(r)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryByGoal(Goal $goal, bool $onlyActive): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('r');
$qb->where(':goal MEMBER OF r.goals')
->setParameter('goal', $goal);
if ($onlyActive) {
$qb->andWhere('r.desactivationDate > :now OR r.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action): QueryBuilder
private function buildQueryBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): QueryBuilder
{
$actions = $action->getDescendantsWithThis();
@@ -150,6 +256,11 @@ final readonly class ResultRepository implements ObjectRepository
}
$qb->where($orx);
if ($onlyActive) {
$qb->andWhere('r.desactivationDate > :now OR r.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
}

View File

@@ -14,14 +14,17 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class SocialActionRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(SocialAction::class);
}
@@ -84,6 +87,100 @@ final readonly class SocialActionRepository implements ObjectRepository
return SocialAction::class;
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('sa');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('sa.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(SocialAction::class, 'sa');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(sa.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(SocialAction::class, 'sa');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('sa.desactivationDate'),
$qb->expr()->gt('sa.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('sa.desactivationDate'),
$qb->expr()->lte('sa.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, SocialAction>
*/
public function findFilteredSocialActions(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['ordering' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredSocialActions(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(sa)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryWithDesactivatedDateCriteria(): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('sa');

View File

@@ -14,14 +14,17 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class SocialIssueRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(SocialIssue::class);
}
@@ -79,6 +82,100 @@ final readonly class SocialIssueRepository implements ObjectRepository
return SocialIssue::class;
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('si');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('si.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(SocialIssue::class, 'si');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(si.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(SocialIssue::class, 'si');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('si.desactivationDate'),
$qb->expr()->gt('si.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('si.desactivationDate'),
$qb->expr()->lte('si.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, SocialIssue>
*/
public function findFilteredSocialIssues(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['ordering' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredSocialIssues(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(si)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryWithDesactivatedDateCriteria(): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('si');

View File

@@ -8,8 +8,8 @@
:editor="ClassicEditor"
:config="classicEditorConfig"
:placeholder="trans(EVALUATION_COMMENT_PLACEHOLDER)"
:value="comment"
@input="$emit('update:comment', $event)"
:model-value="comment"
@update:model-value="$emit('update:comment', $event)"
tag-name="textarea"
></ckeditor>
</div>

View File

@@ -212,4 +212,3 @@
</div>
</div>
{%- endif -%}

View File

@@ -301,6 +301,47 @@
'customButtons': { 'after': _self.button_person_after(person), 'before': _self.button_person_before((person)) }
}) }}
{% set calendars = [] %}
{% for c in person.getNextCalendarsForPerson(10) %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_SEE', c) %}
{% set calendars = calendars|merge([c]) %}
{% endif %}
{% endfor %}
{% if calendars|length > 0 %}
<div class="wrap-list periods-list">
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'chill_calendar.Next calendars'|trans }}</h3>
</div>
<div class="wl-col list">
<div class="calendar-list">
<ul class="calendar-list">
{% for c in calendars %}
<li>
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c) %}
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { id: c.id }) }}">
<span class="badge bg-secondary">
{{ c.startDate|format_datetime('long', 'short') }}
</span>
</a>
{% else %}
<span class="badge bg-secondary">
{{ c.startDate|format_datetime('long', 'short') }}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% if is_granted('CHILL_CALENDAR_CALENDAR_SEE', person) %}
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_list_by_person', {'id': person.id}) }}" class="calendar-list__global"><i class="fa fa-list"></i></a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{#- 'acps' is for AcCompanyingPeriodS #}
{%- set acps = [] %}
{%- set acpsClosed = [] %}

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>
@@ -22,7 +25,7 @@
</td>
<td>
{% if entity.desactivationDate is not null %}
{{ entity.desactivationDate|date('Y-m-d') }}
{{ entity.desactivationDate|format_date('medium') }}
{% endif %}
</td>
<td>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>
@@ -16,7 +19,7 @@
<td>{{ entity.title|localize_translatable_string }}</td>
<td>
{% if entity.desactivationDate is not null %}
{{ entity.desactivationDate|date('Y-m-d') }}
{{ entity.desactivationDate|format_date('medium') }}
{% endif %}
</td>
<td>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id' }}</th>
<th>{{ 'Title'|trans }}</th>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>

View File

@@ -14,6 +14,7 @@ namespace Chill\PersonBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocumentRepository;
@@ -26,8 +27,9 @@ class AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter extends Abstract
private readonly AccompanyingPeriodWorkEvaluationDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -103,6 +103,11 @@ Employment status: Situation professionelle
Administrative status: Situation administrative
person:
# trans key according to new conventions
export:
aggregator:
by_center:
no_center: Sans territoire
Identifiers: Identifiants

View File

@@ -2,7 +2,7 @@
module.exports = function (encore, entries) {
encore.addEntry(
"page_wopi_editor",
__dirname + "/src/Resources/public/page/editor/index.js",
__dirname + "/src/Resources/public/page/editor/index.ts",
);
encore.addEntry(
"mod_reload_page",

View File

@@ -1,46 +0,0 @@
require("./index.scss");
window.addEventListener("DOMContentLoaded", function () {
let frameholder = document.getElementById("frameholder");
let office_frame = document.createElement("iframe");
office_frame.name = "office_frame";
office_frame.id = "office_frame";
// The title should be set for accessibility
office_frame.title = "Office Frame";
// This attribute allows true fullscreen mode in slideshow view
// when using PowerPoint's 'view' action.
office_frame.setAttribute("allowfullscreen", "true");
// The sandbox attribute is needed to allow automatic redirection to the O365 sign-in page in the business user flow
office_frame.setAttribute(
"sandbox",
"allow-downloads allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-top-navigation allow-popups-to-escape-sandbox",
);
frameholder.appendChild(office_frame);
document.getElementById("office_form").submit();
const url = new URL(editor_url);
const editor_domain = url.origin;
window.addEventListener("message", function (message) {
if (message.origin !== editor_domain) {
return;
}
let data = JSON.parse(message.data);
if ("UI_Close" === data.MessageId) {
closeEditor();
}
});
});
function closeEditor() {
let params = new URLSearchParams(window.location.search),
returnPath = params.get("returnPath");
window.location.assign(returnPath);
}

View File

@@ -0,0 +1,68 @@
import "./index.scss";
// Provided by the server-side template
declare const editor_url: string;
window.addEventListener("DOMContentLoaded", function () {
const frameholder = document.getElementById("frameholder");
const office_frame = document.createElement("iframe");
office_frame.name = "office_frame";
office_frame.id = "office_frame";
// The title should be set for accessibility
office_frame.title = "Office Frame";
// This attribute allows true fullscreen mode in slideshow view
// when using PowerPoint's 'view' action.
office_frame.setAttribute("allowfullscreen", "true");
// The sandbox attribute is needed to allow automatic redirection to the O365 sign-in page in the business user flow
office_frame.setAttribute(
"sandbox",
"allow-downloads allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-top-navigation allow-popups-to-escape-sandbox",
);
office_frame.setAttribute(
"allow",
"clipboard-read *; clipboard-write *; fullscreen *",
);
if (frameholder) {
frameholder.appendChild(office_frame);
}
const officeForm = document.getElementById(
"office_form",
) as HTMLFormElement | null;
officeForm?.submit();
const url = new URL(editor_url);
const editor_domain = url.origin;
window.addEventListener("message", function (message: MessageEvent) {
if (message.origin !== editor_domain) {
return;
}
let data: { MessageId: "UI_Close" | null; data: string };
try {
data =
typeof message.data === "string"
? JSON.parse(message.data)
: message.data;
} catch (e: unknown) {
console.error("error while parsing data from message UI_CLOSE", e);
return;
}
if ("UI_Close" === data.MessageId) {
closeEditor();
}
});
});
function closeEditor(): void {
const params = new URLSearchParams(window.location.search);
const returnPath = params.get("returnPath") ?? "/";
window.location.assign(returnPath);
}

View File

@@ -2,9 +2,6 @@
"champs-libres/wopi-bundle": {
"version": "dev-master"
},
"chill-project/chill-zimbra-bundle": {
"version": "dev-472-zimbra-connector"
},
"doctrine/annotations": {
"version": "1.14",
"recipe": {