Compare commits

..

75 Commits

Author SHA1 Message Date
4091efc72e Update bundles version to 2.18.2 2024-04-12 12:58:23 +02:00
b577bd7123 Merge branch '250-fix-import-postal-codes' into 'master'
Resolve "Import postal codes"

Closes #250

See merge request Chill-Projet/chill-bundles!672
2024-04-12 10:54:15 +00:00
dbb9feb129 rector correction: cast value to string 2024-04-12 12:35:52 +02:00
e8b95f8491 Add changie for fix import postal codes 2024-04-11 16:45:43 +02:00
8de37a9ef3 Fix import of postal codes. URL changed + names of keys 2024-04-11 16:45:22 +02:00
8fd6986c47 Release 2.18.1 2024-03-26 22:10:18 +01:00
807f1b4aa1 Fix layout in admin document generation
A layout issue in the admin document generation has been fixed, particularly in the ChillDocGeneratorBundle. Unnecessary elements such as table headers and multiple entity data rows in DocGeneratorTemplate have been removed, simplifying the view page and improving its performance.
2024-03-26 22:10:01 +01:00
f3002631ea Release 2.18.0 2024-03-26 18:10:18 +01:00
9e667d4de4 Merge branch '268-improve-ux-when-configuring-documents' into 'master'
Improve admin UX for configuration of document template (document generation)

Closes #268

See merge request Chill-Projet/chill-bundles!670
2024-03-26 17:06:49 +00:00
fc88a5f40d Improve admin UX for configuration of document template (document generation) 2024-03-26 17:06:49 +00:00
9ff7aef3fc Merge branch '267-Fix-the-join-with-the-user-list' into 'master'
Fix the join in the user list (admin): show only current user job

Closes #267

See merge request Chill-Projet/chill-bundles!669
2024-03-20 12:34:13 +00:00
4f08019618 Fix the join in the user list (admin): show only current user job 2024-03-20 12:45:08 +01:00
2a58330832 Update v2.17.0.md: add missing changie line 2024-03-19 20:46:50 +00:00
a2cea3df02 Release 2.17.0 2024-03-19 21:00:38 +01:00
9ac43ecf5b Merge branch '238-custom-column-export-person' into 'master'
Liste des usagers: permettre d'ajouter des colonnes custom

Closes #238

See merge request Chill-Projet/chill-bundles!668
2024-03-19 19:59:38 +00:00
f78f5e8419 Fix cs with php-cs-fixer version 3.52 2024-03-19 20:49:39 +01:00
ccf3324bc2 Refactor ListPersonHelper and ListPerson to simplify process and allow to add customization of fields 2024-03-19 20:49:39 +01:00
dfe780f0f5 Merge branch '258-centers-parcours-export' into 'master'
In the accompanying period list, add person's centers and duration

Closes #258

See merge request Chill-Projet/chill-bundles!661
2024-03-14 21:35:00 +00:00
dd056efa0d In the accompanying period list, add person's centers and duration 2024-03-14 21:35:00 +00:00
18c0b6a47f Merge branch '259-Génération-de-document-avoir-un-comportement-coherent' into 'master'
Fix activity filter inconsistency in document generation

Closes #259

See merge request Chill-Projet/chill-bundles!667
2024-03-14 20:25:59 +00:00
df0afcd228 Fix activity filter inconsistency in document generation
This commit resolves issue 259 where the filtering of activities differed within the document generation and in the list of activities for an accompanying period. This amendment to the Chill Activity Bundle ensures consistent behavior. Additionally, new test methods and query adjustments were applied to the ActivityACLAwareRepository for better functionality.
2024-03-14 21:16:05 +01:00
d66933c8b5 Merge branch '264-repair-loading-languages' into 'master'
Fix the command which load language

Closes #264

See merge request Chill-Projet/chill-bundles!666
2024-03-14 15:12:30 +00:00
0ff51b0a5c Force new parameter to be readonly in LoadAndUpdateLanguagesCommand constructor 2024-03-14 15:05:30 +00:00
d7f4895248 Fix the command which load language
The command load the languages in the configured languages in chill's configuration.
2024-03-14 15:46:32 +01:00
7aee722957 Merge branch '237-filter-evaluations-between-dates' into 'master'
Resolve "Nouveau filtre: Filtrer les actions ayant reçu une nouvelle évaluation créée entre deux dates"

Closes #237

See merge request Chill-Projet/chill-bundles!663
2024-03-08 10:37:44 +00:00
5880858191 Resolve "Nouveau filtre: Filtrer les actions ayant reçu une nouvelle évaluation créée entre deux dates" 2024-03-08 10:37:43 +00:00
96105b101f Merge branch 'issue159_page_acceuil' into 'master'
Allow users to display news on homepage (+ configuring a dashboard homepage)

See merge request Chill-Projet/chill-bundles!604
2024-03-07 21:08:00 +00:00
d29415317b Allow users to display news on homepage (+ configuring a dashboard homepage) 2024-03-07 21:08:00 +00:00
2ad3bbe96f Merge branch '00585-fix-deprecations-doctrine-2024-03' into 'master'
Fix deprecations and code style issues (2024-03-07)

See merge request Chill-Projet/chill-bundles!665
2024-03-07 14:33:58 +00:00
1d636f5e9e Fix deprecations and code style issues 2024-03-07 15:26:58 +01:00
f0dbb17172 Update exports.rst: fix typo 2024-03-06 11:40:19 +00:00
f1dbc17dad Merge branch 'Doc-why-use-exists-in-exports' into 'master'
Update documentation to explain use of EXISTS in SQL queries

See merge request Chill-Projet/chill-bundles!664
2024-03-06 11:36:01 +00:00
09578a775c Update documentation to explain use of EXISTS in SQL queries
Added an explanatory section to the "exports.rst" doc to clarify why to use an EXISTS subquery instead of a JOIN clause in SQL queries involving many-to-* relationships. This explanation includes sample SQL queries and results to illustrate the potential issue of duplicates with JOIN and count, and how EXISTS can help avoid this issue. Also updated the ".editorconfig" file for .rst files.
2024-03-06 12:34:36 +01:00
c888b5b84f Update chill bundles version to 2.16.3 2024-02-26 14:53:20 +01:00
27d76d9579 Merge branch '232-filters-uj-and-serv-order-alphabetical' into 'master'
Resolve "Filtres sur les données: classer par ordre alphabétique les items à sélectionner"

Closes #232

See merge request Chill-Projet/chill-bundles!662
2024-02-26 13:51:25 +00:00
5b714f17be order scopes alphabetically 2024-02-26 14:40:41 +01:00
bbb167bb85 order user jobs alphabetically when returning all active user jobs 2024-02-26 13:36:44 +01:00
d713087dcb Changie and php style fixes 2024-02-26 13:30:26 +01:00
569aeeef87 Fix wrong translation of user job service -> métier 2024-02-26 12:23:11 +01:00
97f2c75de8 Change syntax of check on null for closing motive 2024-02-21 20:14:18 +01:00
4a2078dc65 upgrade to 2.16.2 2024-02-21 19:49:43 +01:00
00444e1e56 Add check on null value in template for closing motive 2024-02-20 10:10:44 +01:00
f02c5bca13 release 2.16.1 2024-02-09 00:11:11 +01:00
0d56828ebd force boostrap version to 5.2.3 2024-02-09 00:10:26 +01:00
8b28667fe5 prepare release 2.16.0 2024-02-08 21:21:49 +01:00
72f73ec8e7 prepare release 2.16.0 2024-02-08 21:21:34 +01:00
b3d1320c94 Merge branch '149-150-events-improve' into 'master'
Modernisation fonctionnement module Evénement

Closes #149, #150, and #225

See merge request Chill-Projet/chill-bundles!621
2024-02-08 20:19:30 +00:00
2ed42e1a2c Fix cs with php-cs-fier version 3.49 2024-02-08 21:12:27 +01:00
d0e5ba16fe Merge branch 'issue115_social_action_versioning' into 'master'
Add versioning and optimistic locking on accompanying period work

Closes #115

See merge request Chill-Projet/chill-bundles!627
2024-02-08 20:02:05 +00:00
8e65ad9476 Add changie file 2024-02-08 21:01:53 +01:00
cf7338b690 Fix issues with inexisting fields 2024-02-08 21:00:16 +01:00
63dd71037a Add changie file 2024-02-08 14:33:30 +01:00
cc281762b3 Translate message on conflict in AccompanyingPeriodWorkEdit App 2024-02-08 14:33:30 +01:00
aa0cadfa84 Add conflict resolution for generated API + add test
Implemented additional code to handle version conflicts when editing accompanying period work. By keeping track of the current version and returning an HTTP conflict response when it doesn't match with the provided entity version, users are properly alerted to update their entity before continuing. Furthermore, adjusted BadRequestHttpException to match correct arguments order and introduced entity version as query parameter for the URL.

ensure kernel is shutdown after generating data
2024-02-08 14:33:30 +01:00
6e2cce9531 Event: add more fields: documents, organizationCost, note, and location 2024-02-08 12:59:52 +01:00
1fbbf2b2ad fixup! Fix migrations to take into account the change in table name for Person's entity 2024-02-08 12:59:52 +01:00
e586b8ee5e Event: move validation to annotation and add UniqueEntity constraint on Participation 2024-02-08 12:59:51 +01:00
6d04e477f8 Clean database, to avoid double participations on event 2024-02-08 12:59:51 +01:00
6b7b2ae522 fixup! Fix migrations to take into account the change in table name for Person's entity 2024-02-08 12:59:51 +01:00
9b9c2774ad Allow Pick*Type to submit the form when selection an entity, and apply inside Event 2024-02-08 12:59:50 +01:00
e902b6d409 Create a page which list events 2024-02-08 12:59:50 +01:00
d8bf6a195f add creation and update information on events and participation 2024-02-08 12:59:50 +01:00
7c3152f277 Fix migration when executed after the person entity table name change 2024-02-08 12:59:50 +01:00
cef218fed5 Add interface for pagination 2024-02-08 12:59:47 +01:00
930a76cc66 use a PickPersonDynamic type in event bundle 2024-02-08 12:57:17 +01:00
f11f7498d7 Add new option "as_id" to Pick*DynamicType
This option will make the app return a single id of the entity in data, and not the entity json.
2024-02-08 12:54:44 +01:00
1a9af6b0b1 activate Event Bundle in test app 2024-02-08 12:54:44 +01:00
d347f6ae60 Fix migrations to take into account the change in table name for Person's entity 2024-02-08 12:54:44 +01:00
3bb911b4d0 Update version within PUT request
Try to add api logic

check for version being the same instead of smaller

implementing optimistic locking and displaying correct message in frontend

rector fixes

adjust violation message and add translation in translation.yaml

add translator in apiController
2024-02-08 12:09:51 +01:00
f00b39980c Add version of the social action to the state
put correct serialization groups
2024-02-08 12:08:36 +01:00
09882bb4be Add translations that were missing according to console error 2024-02-08 12:08:35 +01:00
1d21499eab add version property to accompanyingperiodwork for optimistic locking 2024-02-08 12:08:35 +01:00
8ef001e67e Merge branch '260-order-centers-dropdown' into 'master'
Resolve "Mettre en ordre alphabétique la liste des centres dans le dropdown du section 'utilisateurs' dans l'admin"

Closes #260

See merge request Chill-Projet/chill-bundles!657
2024-02-08 10:33:09 +00:00
458df45fa5 Add changie 2024-02-08 11:22:48 +01:00
2b968b9a5b order centers dropdown alphabetically 2024-02-08 11:21:33 +01:00
226 changed files with 3987 additions and 3119 deletions

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Create new filter for persons having a participation in an accompanying period
during a certain time span
time: 2023-12-18T15:31:51.489901829+01:00
custom:
Issue: "231"

View File

@@ -1,6 +0,0 @@
kind: Feature
body: '[Export][List of accompanyign period] Add two columns: the list of persons
participating to the period, and their ids'
time: 2024-01-22T12:48:49.824833412+01:00
custom:
Issue: "241"

View File

@@ -1,5 +0,0 @@
kind: Feature
body: 'Add capability to generate export about change of steps of accompanying period, and generate exports for this'
time: 2024-01-29T13:33:19.190365565+01:00
custom:
Issue: "244"

View File

@@ -1,5 +0,0 @@
kind: Feature
body: 'Export: group accompanying period by person participating'
time: 2024-02-07T10:39:51.97331052+01:00
custom:
Issue: "253"

View File

@@ -1,5 +0,0 @@
kind: Feature
body: 'Export: add filter for courses not linked to a reference address'
time: 2024-02-07T11:46:29.491027007+01:00
custom:
Issue: "243"

View File

@@ -1,5 +0,0 @@
kind: Feature
body: Allow to group activities linked with accompanying period by reason
time: 2024-02-07T16:40:38.408575109+01:00
custom:
Issue: "229"

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: Fix error in logs about wrong typing of eventArgs in onEditNotificationComment
method
time: 2023-11-29T11:31:38.933538592+01:00
custom:
Issue: "220"

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: Fix the conditions upon which social actions should be optional or required
in relation to social issues within the activity creation form
time: 2024-01-30T14:03:01.942955636+01:00
custom:
Issue: "256"

15
.changes/v2.16.0.md Normal file
View File

@@ -0,0 +1,15 @@
## v2.16.0 - 2024-02-08
### Feature
* ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span
* ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids
* ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this
* ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating
* ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address
* ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason
* ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work
* Modernize the event bundle, with some new fields and multiple improvements
### Fixed
* ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method
* ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form
### UX
* ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.

3
.changes/v2.16.1.md Normal file
View File

@@ -0,0 +1,3 @@
## v2.16.1 - 2024-02-09
### Fixed
* Force bootstrap version to avoid error in builds with newer version

3
.changes/v2.16.2.md Normal file
View File

@@ -0,0 +1,3 @@
## v2.16.2 - 2024-02-21
### Fixed
* Check for null values in closing motive of parcours d'accompagnement for correct rendering of template

5
.changes/v2.16.3.md Normal file
View File

@@ -0,0 +1,5 @@
## v2.16.3 - 2024-02-26
### Fixed
* ([#236](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/236)) Fix translation of user job -> 'service' must be 'métier'
### UX
* ([#232](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/232)) Order user jobs and services alphabetically in export filters

9
.changes/v2.17.0.md Normal file
View File

@@ -0,0 +1,9 @@
## v2.17.0 - 2024-03-19
### Feature
* ([#237](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/237)) New export filter for social actions with an evaluation created between two dates
* ([#258](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/258)) In the list of accompangying period, add the list of person's centers and the duration of the course
* ([#238](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/238)) Allow to customize list person with new fields
* ([#159](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/159)) Admin can publish news on the homepage
### Fixed
* ([#264](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/264)) Fix languages: load the languages in all availables languages configured for Chill
* ([#259](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/259)) Keep a consistent behaviour between the filtering of activities within the document generation (model "accompanying period with activities"), and the same filter in the list of activities for an accompanying period

5
.changes/v2.18.0.md Normal file
View File

@@ -0,0 +1,5 @@
## v2.18.0 - 2024-03-26
### Feature
* ([#268](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/268)) Improve admin UX to configure document templates for document generation
### Fixed
* ([#267](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/267)) Fix the join between job and user in the user list (admin): show only the current user job

3
.changes/v2.18.1.md Normal file
View File

@@ -0,0 +1,3 @@
## v2.18.1 - 2024-03-26
### Fixed
* Fix layout issue in document generation for admin (minor)

3
.changes/v2.18.2.md Normal file
View File

@@ -0,0 +1,3 @@
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record

View File

@@ -23,3 +23,7 @@ max_line_length = 0
indent_size = 2
indent_style = space
[.rst]
ident_size = 3
ident_style = space

View File

@@ -35,7 +35,7 @@ variables:
# force a timezone
TZ: Europe/Brussels
# avoid direct deprecations (using symfony phpunit bridge: https://symfony.com/doc/4.x/components/phpunit_bridge.html#internal-deprecations
SYMFONY_DEPRECATIONS_HELPER: max[total]=99999999&max[self]=0&max[direct]=0&verbose=0
SYMFONY_DEPRECATIONS_HELPER: max[total]=99999999&max[self]=0&max[direct]=0&verbose=1
stages:
- Composer install

View File

@@ -6,6 +6,60 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
## v2.18.1 - 2024-03-26
### Fixed
* Fix layout issue in document generation for admin (minor)
## v2.18.0 - 2024-03-26
### Feature
* ([#268](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/268)) Improve admin UX to configure document templates for document generation
### Fixed
* ([#267](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/267)) Fix the join between job and user in the user list (admin): show only the current user job
## v2.17.0 - 2024-03-19
### Feature
* ([#237](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/237)) New export filter for social actions with an evaluation created between two dates
* ([#258](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/258)) In the list of accompangying period, add the list of person's centers and the duration of the course
* ([#238](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/238)) Allow to customize list person with new fields
* ([#159](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/159)) Admin can publish news on the homepage
### Fixed
* ([#264](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/264)) Fix languages: load the languages in all availables languages configured for Chill
* ([#259](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/259)) Keep a consistent behaviour between the filtering of activities within the document generation (model "accompanying period with activities"), and the same filter in the list of activities for an accompanying period
## v2.16.3 - 2024-02-26
### Fixed
* ([#236](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/236)) Fix translation of user job -> 'service' must be 'métier'
### UX
* ([#232](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/232)) Order user jobs and services alphabetically in export filters
## v2.16.2 - 2024-02-21
### Fixed
* Check for null values in closing motive of parcours d'accompagnement for correct rendering of template
## v2.16.1 - 2024-02-09
### Fixed
* Force bootstrap version to avoid error in builds with newer version
## v2.16.0 - 2024-02-08
### Feature
* ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span
* ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids
* ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this
* ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating
* ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address
* ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason
* ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work
* Modernize the event bundle, with some new fields and multiple improvements
### Fixed
* ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method
* ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form
### UX
* ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.
## v2.15.2 - 2024-01-11
### Fixed
* Fix the id_seq used when creating a new accompanying period participation during fusion of two person files

View File

@@ -9,7 +9,6 @@
],
"require": {
"php": "^8.2",
"ext-dom": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",

View File

@@ -242,3 +242,129 @@ This is an example of the *filter by birthdate*. This filter asks some informati
Continue to explain the export framework
.. _main bundle: https://git.framasoft.org/Chill-project/Chill-Main
With many-to-* relationship, why should we set WHERE clauses in an EXISTS subquery instead of a JOIN ?
``````````````````````````````````````````````````````````````````````````````````````````````````````
As we described above, the doctrine builder is converted into a sql query. Let's see how to compute the "number of course
which count at least one activity type with the id 7". For the purpose of this demonstration, we will restrict this on
two accompanying period only: the ones with id 329 and 334.
Let's see the list of activities associated with those accompanying period:
.. code-block:: sql
SELECT id, accompanyingperiod_id, type_id FROM activity WHERE accompanyingperiod_id IN (329, 334) AND type_id = 7
ORDER BY accompanyingperiod_id;
We see that we have 6 activities for the accompanying period with id 329, and only one for the 334's one.
.. csv-table::
:header: id, accompanyingperiod_id, type_id
990,329,7
986,329,7
987,329,7
993,329,7
991,329,7
992,329,7
1000,334,7
Let's calculate the average duration for those accompanying periods, and the number of period:
.. code-block:: sql
SELECT AVG(age(COALESCE(closingdate, CURRENT_DATE), openingdate)), COUNT(id) from chill_person_accompanying_period WHERE id IN (329, 334);
The result of this query is:
.. csv-table::
:header: AVG, COUNT
2 years 2 mons 21 days 12 hours 0 mins 0.0 secs,2
Now, we count the number of accompanying period, adding a :code:`JOIN` clause which make a link to the :code:`activity` table, and add a :code:`WHERE` clause to keep
only the accompanying period which contains the given activity type:
.. code-block:: sql
SELECT COUNT(chill_person_accompanying_period.id) from chill_person_accompanying_period
JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id
WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7;
What are the results here ?
.. csv-table::
:header: COUNT
7
:code:`7` ! Why this result ? Because the number of lines is duplicated for each activity. Let's see the list of rows which
are taken into account for the computation:
.. code-block:: sql
SELECT chill_person_accompanying_period.id, activity.id from chill_person_accompanying_period
JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id
WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7;
.. csv-table::
:header: accompanyingperiod.id, activity.id
329,993
334,1000
329,987
329,990
329,991
329,992
329,986
For each activity, a row is created and, as we count the number of non-null :code:`accompanyingperiod.id` columns, we
count one entry for each activity (actually, we count the number of activities).
So, let's use the :code:`DISTINCT` keyword to count only once the equal ids:
.. code-block::
SELECT COUNT(DISTINCT chill_person_accompanying_period.id) from chill_person_accompanying_period
JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id
WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7;
Now, it works again...
.. csv-table::
:header: COUNT
2
But, for the average duration, this won't work: the duration which are equals (because the :code:`openingdate` is the same and
:code:`closingdate` is still :code:`NULL`, for instance) will be counted only once, which will give unexpected result.
The solution is to move the condition "having an activity with activity type with id 7" in a :code:`EXISTS` clause:
.. code-block:: sql
SELECT COUNT(chill_person_accompanying_period.id) from chill_person_accompanying_period
WHERE chill_person_accompanying_period.id IN (329, 334) AND EXISTS (SELECT 1 FROM activity WHERE type_id = 7 AND accompanyingperiod_id = chill_person_accompanying_period.id);
The result is correct without :code:`DISTINCT` keyword:
.. csv-table::
:header: COUNT
2
And we can now compute the average duration without fear:
.. code-block:: sql
SELECT AVG(age(COALESCE(closingdate, CURRENT_DATE), openingdate)) from chill_person_accompanying_period
WHERE chill_person_accompanying_period.id IN (329, 334) AND EXISTS (SELECT 1 FROM activity WHERE type_id = 7 AND accompanyingperiod_id = chill_person_accompanying_period.id);
Give the result:
.. csv-table::
:header: AVG
2 years 2 mons 21 days 12 hours 0 mins 0.0 secs

View File

@@ -14,8 +14,8 @@
"@ckeditor/ckeditor5-vue": "^4.0.1",
"@symfony/webpack-encore": "^4.1.0",
"@tsconfig/node14": "^1.0.1",
"@types/dompurify": "^3.0.5",
"bindings": "^1.5.0",
"bootstrap": "5.2.3",
"chokidar": "^3.5.1",
"fork-awesome": "^1.1.7",
"jquery": "^3.6.0",
@@ -34,7 +34,6 @@
"webpack-cli": "^5.0.1"
},
"dependencies": {
"bootstrap": "~5.2.0",
"@fullcalendar/core": "^6.1.4",
"@fullcalendar/daygrid": "^6.1.4",
"@fullcalendar/interaction": "^6.1.4",
@@ -43,11 +42,9 @@
"@fullcalendar/vue3": "^6.1.4",
"@popperjs/core": "^2.9.2",
"@types/leaflet": "^1.9.3",
"dompurify": "^3.0.6",
"dropzone": "^5.7.6",
"es6-promise": "^4.2.8",
"leaflet": "^1.7.1",
"marked": "^9.1.5",
"masonry-layout": "^4.2.2",
"mime": "^3.0.0",
"swagger-ui": "^4.15.5",

View File

@@ -80,7 +80,7 @@ final readonly class CreatorJobFilter implements FilterInterface
{
$builder
->add('jobs', EntityType::class, [
'choices' => $this->userJobRepository->findAllOrderedByName(),
'choices' => $this->userJobRepository->findAllActive(),
'class' => UserJob::class,
'choice_label' => fn (UserJob $s) => $this->translatableStringHelper->localize(
$s->getLabel()

View File

@@ -15,6 +15,7 @@ use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User\UserScopeHistory;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@@ -26,7 +27,8 @@ class CreatorScopeFilter implements FilterInterface
private const PREFIX = 'acp_act_filter_creator_scope';
public function __construct(
private readonly TranslatableStringHelper $translatableStringHelper
private readonly TranslatableStringHelper $translatableStringHelper,
private readonly ScopeRepositoryInterface $scopeRepository,
) {
}
@@ -76,6 +78,7 @@ class CreatorScopeFilter implements FilterInterface
$builder
->add('scopes', EntityType::class, [
'class' => Scope::class,
'choices' => $this->scopeRepository->findAllActive(),
'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize(
$s->getName()
),

View File

@@ -16,6 +16,7 @@ use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\QueryBuilder;
@@ -27,7 +28,8 @@ class UsersJobFilter implements FilterInterface
private const PREFIX = 'act_filter_user_job';
public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly UserJobRepositoryInterface $userJobRepository
) {
}
@@ -69,6 +71,7 @@ class UsersJobFilter implements FilterInterface
$builder
->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()),
'multiple' => true,
'expanded' => true,

View File

@@ -95,7 +95,7 @@ class ActivityType extends AbstractType
]);
}
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod|null $accompanyingPeriod */
/** @var AccompanyingPeriod|null $accompanyingPeriod */
$accompanyingPeriod = null;
if ($options['accompanyingPeriod'] instanceof AccompanyingPeriod) {

View File

@@ -243,7 +243,8 @@ final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepos
thirdparties.thirdpartyids,
persons.personids,
actions.socialactionids,
issues.socialissueids
issues.socialissueids,
a.user_id
FROM activity a
LEFT JOIN chill_main_location location ON a.location_id = location.id
@@ -283,6 +284,7 @@ final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepos
->addJoinedEntityResult(ActivityPresence::class, 'activityPresence', 'a', 'attendee')
->addFieldResult('activityPresence', 'presence_id', 'id')
->addFieldResult('activityPresence', 'presence_name', 'name')
->addScalarResult('user_id', 'userId', Types::INTEGER)
// results which cannot be mapped into entity
->addScalarResult('comment_comment', 'comment', Types::TEXT)

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Service\DocGenerator;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Entity\ActivityPresence;
use Chill\ActivityBundle\Entity\ActivityType;
use Chill\ActivityBundle\Repository\ActivityACLAwareRepositoryInterface;
@@ -112,7 +113,7 @@ class ListActivitiesByAccompanyingPeriodContext implements
}
/**
* @return list
* @return list<Activity>
*/
private function filterActivitiesByUser(array $activities, User $user): array
{
@@ -120,6 +121,12 @@ class ListActivitiesByAccompanyingPeriodContext implements
array_filter(
$activities,
function ($activity) use ($user) {
$u = $activity['user'];
if (null !== $u && $u['username'] === $user->getUsername()) {
return true;
}
$activityUsernames = array_map(static fn ($user) => $user['username'], $activity['users'] ?? []);
return \in_array($user->getUsername(), $activityUsernames, true);
@@ -129,7 +136,7 @@ class ListActivitiesByAccompanyingPeriodContext implements
}
/**
* @return list
* @return list<AccompanyingPeriod\AccompanyingPeriodWork>
*/
private function filterWorksByUser(array $works, User $user): array
{
@@ -216,6 +223,15 @@ class ListActivitiesByAccompanyingPeriodContext implements
foreach ($activities as $row) {
$activity = $row[0];
$user = match (null === $row['userId']) {
false => $this->userRepository->find($row['userId']),
true => null,
};
$activity['user'] = $this->normalizer->normalize($user, 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => User::class,
]);
$activity['date'] = $this->normalizer->normalize($activity['date'], 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => \DateTime::class,
]);

View File

@@ -91,6 +91,29 @@ class ActivityACLAwareRepositoryTest extends KernelTestCase
self::assertIsArray($actual);
}
/**
* @dataProvider provideDataFindByAccompanyingPeriod
*/
public function testfindByAccompanyingPeriodSimplified(AccompanyingPeriod $period, User $user, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): void
{
$security = $this->prophesize(Security::class);
$security->isGranted($role, $period)->willReturn(true);
$security->getUser()->willReturn($user);
$repository = new ActivityACLAwareRepository(
$this->authorizationHelperForCurrentUser,
$this->centerResolverManager,
$this->activityRepository,
$this->entityManager,
$security->reveal(),
$this->requestStack
);
$actual = $repository->findByAccompanyingPeriodSimplified($period);
self::assertIsArray($actual);
}
/**
* @dataProvider provideDataFindByAccompanyingPeriod
*/
@@ -301,7 +324,10 @@ class ActivityACLAwareRepositoryTest extends KernelTestCase
->getQuery()
->getResult()
) {
throw new \RuntimeException('no jobs found');
$job = new UserJob();
$job->setLabel(['fr' => 'test']);
$this->entityManager->persist($job);
$this->entityManager->flush();
}
if (null === $user = $this->entityManager

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Tests\Service\DocGenerator;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Service\DocGenerator\ListActivitiesByAccompanyingPeriodContext;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class ListActivitiesByAccompanyingPeriodContextTest extends KernelTestCase
{
private ListActivitiesByAccompanyingPeriodContext $listActivitiesByAccompanyingPeriodContext;
private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private UserRepositoryInterface $userRepository;
protected function setUp(): void
{
self::bootKernel();
$this->listActivitiesByAccompanyingPeriodContext = self::$container->get(ListActivitiesByAccompanyingPeriodContext::class);
$this->accompanyingPeriodRepository = self::$container->get(AccompanyingPeriodRepository::class);
$this->userRepository = self::$container->get(UserRepositoryInterface::class);
}
/**
* @dataProvider provideAccompanyingPeriod
*/
public function testGetDataWithoutFilteringActivityNorWorks(int $accompanyingPeriodId, int $userId): void
{
$context = $this->getContext();
$template = new DocGeneratorTemplate();
$template->setOptions([
'mainPerson' => false,
'person1' => false,
'person2' => false,
'thirdParty' => false,
]);
$data = $context->getData(
$template,
$this->accompanyingPeriodRepository->find($accompanyingPeriodId),
['myActivitiesOnly' => false, 'myWorksOnly' => false]
);
self::assertIsArray($data);
self::assertArrayHasKey('activities', $data);
self::assertIsArray($data['activities']);
self::assertGreaterThan(0, count($data['activities']));
self::assertIsArray($data['activities'][0]);
self::assertArrayHasKey('user', $data['activities'][0]);
self::assertIsArray($data['activities'][0]['user']);
}
/**
* @dataProvider provideAccompanyingPeriod
*/
public function testGetDataWithoutFilteringActivityByUser(int $accompanyingPeriodId, int $userId): void
{
$context = $this->getContext();
$template = new DocGeneratorTemplate();
$template->setOptions([
'mainPerson' => false,
'person1' => false,
'person2' => false,
'thirdParty' => false,
]);
$data = $context->getData(
$template,
$this->accompanyingPeriodRepository->find($accompanyingPeriodId),
['myActivitiesOnly' => true, 'myWorksOnly' => false, 'creator' => $this->userRepository->find($userId)]
);
self::assertIsArray($data);
self::assertArrayHasKey('activities', $data);
self::assertIsArray($data['activities']);
self::assertGreaterThan(0, count($data['activities']));
self::assertIsArray($data['activities'][0]);
self::assertArrayHasKey('user', $data['activities'][0]);
self::assertIsArray($data['activities'][0]['user']);
}
public static function provideAccompanyingPeriod(): array
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
if (null === $period = $em->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a')
->setMaxResults(1)
->getSingleResult()) {
throw new \RuntimeException('no period found');
}
if (null === $user = $em->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)
->getSingleResult()
) {
throw new \RuntimeException('no user found');
}
$activity = new Activity();
$activity
->setAccompanyingPeriod($period)
->setUser($user)
->setDate(new \DateTime());
$em->persist($activity);
$em->flush();
self::ensureKernelShutdown();
return [
[$period->getId(), $user->getId()],
];
}
private function getContext(): ListActivitiesByAccompanyingPeriodContext
{
return $this->listActivitiesByAccompanyingPeriodContext;
}
}

View File

@@ -396,7 +396,7 @@ export:
by_creator_job:
job_form_label: Métiers
Filter activity by user job: Filtrer les échanges par métier du créateur de l'échange
'Filtered activity by user job: only %jobs%': "Filtré par service du créateur de l'échange: uniquement %jobs%"
'Filtered activity by user job: only %jobs%': "Filtré par métier du créateur de l'échange: uniquement %jobs%"
by_persons:
Filter activity by persons: Filtrer les échanges par usager participant
'Filtered activity by persons: only %persons%': 'Échanges filtrés par usagers participants: seulement %persons%'

View File

@@ -16,6 +16,7 @@ use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\QueryBuilder;
@@ -27,7 +28,8 @@ class ByUserJobFilter implements FilterInterface
private const PREFIX = 'aside_act_filter_user_job';
public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly UserJobRepositoryInterface $userJobRepository
) {
}
@@ -69,6 +71,7 @@ class ByUserJobFilter implements FilterInterface
$builder
->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()),
'multiple' => true,
'expanded' => true,

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
/**
* @internal
*
* @coversNothing
*/
class DavTokenAuthenticationEventSubscriberTest extends TestCase
{
public function testOnJWTAuthenticatedWithDavDataInPayload(): void
{
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
$token = new class () extends AbstractToken {
public function getCredentials()
{
return null;
}
};
$event = new JWTAuthenticatedEvent([
'dav' => 1,
'so' => '1234',
'e' => 1,
], $token);
$eventSubscriber->onJWTAuthenticated($event);
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
}
public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void
{
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
$token = new class () extends AbstractToken {
public function getCredentials()
{
return null;
}
};
$event = new JWTAuthenticatedEvent([], $token);
$eventSubscriber->onJWTAuthenticated($event);
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
}
}

View File

@@ -15,6 +15,7 @@ use Chill\CalendarBundle\Export\Declarations;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@@ -26,7 +27,8 @@ final readonly class JobFilter implements FilterInterface
private const PREFIX = 'cal_filter_job';
public function __construct(
private TranslatableStringHelper $translatableStringHelper
private TranslatableStringHelper $translatableStringHelper,
private UserJobRepositoryInterface $userJobRepository
) {
}
@@ -74,6 +76,7 @@ final readonly class JobFilter implements FilterInterface
$builder
->add('job', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize(
$j->getLabel()
),

View File

@@ -15,6 +15,7 @@ use Chill\CalendarBundle\Export\Declarations;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User\UserScopeHistory;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@@ -28,7 +29,8 @@ class ScopeFilter implements FilterInterface
public function __construct(
protected TranslatorInterface $translator,
private readonly TranslatableStringHelper $translatableStringHelper
private readonly TranslatableStringHelper $translatableStringHelper,
private readonly ScopeRepositoryInterface $scopeRepository
) {
}
@@ -76,6 +78,7 @@ class ScopeFilter implements FilterInterface
$builder
->add('scope', EntityType::class, [
'class' => Scope::class,
'choices' => $this->scopeRepository->findAllActive(),
'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize(
$s->getName()
),

View File

@@ -33,7 +33,7 @@ final readonly class MSUserAbsenceReader implements MSUserAbsenceReaderInterface
/**
* @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft
*/
public function isUserAbsent(User $user): bool|null
public function isUserAbsent(User $user): ?bool
{
$id = $this->mapCalendarToUser->getUserId($user);

View File

@@ -18,5 +18,5 @@ interface MSUserAbsenceReaderInterface
/**
* @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft
*/
public function isUserAbsent(User $user): bool|null;
public function isUserAbsent(User $user): ?bool;
}

View File

@@ -16,29 +16,42 @@ use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface;
use Chill\DocGeneratorBundle\Context\Exception\ContextNotFoundException;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorInterface;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Serializer\Model\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
// TODO à mettre dans services
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
final class DocGeneratorTemplateController extends AbstractController
{
public function __construct(private readonly ContextManager $contextManager, private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private readonly GeneratorInterface $generator, private readonly MessageBusInterface $messageBus, private readonly PaginatorFactory $paginatorFactory, private readonly EntityManagerInterface $entityManager)
{
public function __construct(
private readonly ContextManager $contextManager,
private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
private readonly MessageBusInterface $messageBus,
private readonly PaginatorFactory $paginatorFactory,
private readonly EntityManagerInterface $entityManager,
private readonly ClockInterface $clock,
private readonly Security $security,
) {
}
/**
@@ -163,9 +176,7 @@ final class DocGeneratorTemplateController extends AbstractController
throw new NotFoundHttpException(sprintf('Entity with classname %s and id %s is not found', $context->getEntityClass(), $entityId));
}
$contextGenerationData = [
'test_file' => null,
];
$contextGenerationData = [];
if (
$context instanceof DocGeneratorContextWithPublicFormInterface
@@ -175,25 +186,39 @@ final class DocGeneratorTemplateController extends AbstractController
$builder = $this->createFormBuilder(
array_merge(
$context->getFormData($template, $entity),
$isTest ? ['test_file' => null, 'show_data' => false] : []
$isTest ? ['creator' => null, 'dump_only' => false, 'send_result_to' => ''] : []
)
);
$context->buildPublicForm($builder, $template, $entity);
} else {
$builder = $this->createFormBuilder(
['test_file' => null, 'show_data' => false]
['creator' => null, 'show_data' => false, 'send_result_to' => '']
);
}
if ($isTest) {
$builder->add('test_file', FileType::class, [
'label' => 'Template file',
$builder->add('dump_only', CheckboxType::class, [
'label' => 'docgen.Show data instead of generating',
'required' => false,
]);
$builder->add('show_data', CheckboxType::class, [
'label' => 'Show data instead of generating',
'required' => false,
$builder->add('send_result_to', EmailType::class, [
'label' => 'docgen.Send report to',
'help' => 'docgen.Send report errors to this email address',
'empty_data' => '',
'required' => true,
'constraints' => [
new NotBlank(),
new NotNull(),
],
]);
$builder->add('creator', PickUserDynamicType::class, [
'label' => 'docgen.Generate as creator',
'help' => 'docgen.The document will be generated as the given creator',
'multiple' => false,
'constraints' => [
new NotNull(),
],
]);
}
@@ -204,8 +229,10 @@ final class DocGeneratorTemplateController extends AbstractController
} elseif (!$form->isSubmitted() || ($form->isSubmitted() && !$form->isValid())) {
$templatePath = '@ChillDocGenerator/Generator/basic_form.html.twig';
$templateOptions = [
'entity' => $entity, 'form' => $form->createView(),
'template' => $template, 'context' => $context,
'entity' => $entity,
'form' => $form->createView(),
'template' => $template,
'context' => $context,
];
return $this->render($templatePath, $templateOptions);
@@ -218,60 +245,57 @@ final class DocGeneratorTemplateController extends AbstractController
$context->contextGenerationDataNormalize($template, $entity, $contextGenerationData)
: [];
// if is test, render the data or generate the doc
if ($isTest && isset($form) && $form['show_data']->getData()) {
return $this->render('@ChillDocGenerator/Generator/debug_value.html.twig', [
'datas' => json_encode($context->getData($template, $entity, $contextGenerationData), \JSON_PRETTY_PRINT),
]);
}
if ($isTest) {
$generated = $this->generator->generateDocFromTemplate(
$template,
$entityId,
$contextGenerationDataSanitized,
null,
true,
isset($form) ? $form['test_file']->getData() : null
);
return new Response(
$generated,
Response::HTTP_OK,
[
'Content-Transfer-Encoding', 'binary',
'Content-Type' => 'application/vnd.oasis.opendocument.text',
'Content-Disposition' => 'attachment; filename="generated.odt"',
'Content-Length' => \strlen($generated),
],
);
}
// this is not a test
// we prepare the object to store the document
$storedObject = (new StoredObject())
->setStatus(StoredObject::STATUS_PENDING)
;
if ($isTest) {
// document will be stored during 15 days, if generation is a test
$storedObject->setDeleteAt($this->clock->now()->add(new \DateInterval('P15D')));
}
$this->entityManager->persist($storedObject);
// we store the generated document
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
// we store the generated document (associate with the original entity, etc.)
// but only if this is not a test
if (!$isTest) {
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
}
$this->entityManager->flush();
if ($isTest) {
$creator = $contextGenerationData['creator'];
$sendResultTo = ($form ?? null)?->get('send_result_to')?->getData() ?? null;
$dumpOnly = ($form ?? null)?->get('dump_only')?->getData() ?? false;
} else {
$creator = $this->security->getUser();
if (!$creator instanceof User) {
throw new AccessDeniedHttpException('only authenticated user can request a generation');
}
$sendResultTo = null;
$dumpOnly = false;
}
$this->messageBus->dispatch(
new RequestGenerationMessage(
$this->getUser(),
$creator,
$template,
$entityId,
$storedObject,
$contextGenerationDataSanitized,
$isTest,
$sendResultTo,
$dumpOnly,
)
);

View File

@@ -69,7 +69,7 @@ class DocGeneratorTemplate
*
* @Serializer\Groups({"read"})
*/
private int $id;
private ?int $id = null;
/**
* @ORM\Column(type="json")

View File

@@ -14,10 +14,9 @@ namespace Chill\DocGeneratorBundle\Repository;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final class DocGeneratorTemplateRepository implements ObjectRepository
final class DocGeneratorTemplateRepository implements DocGeneratorTemplateRepositoryInterface
{
private readonly EntityRepository $repository;

View File

@@ -0,0 +1,23 @@
<?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\DocGeneratorBundle\Repository;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Doctrine\Persistence\ObjectRepository;
/**
* @extends ObjectRepository<DocGeneratorTemplate>
*/
interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository
{
public function countByEntity(string $entity): int;
}

View File

@@ -1,36 +1,62 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block table_entities_thead_tr %}
<th></th>
<th>{{ 'Title'|trans }}</th>
<th>{{ 'docgen.Context'|trans }}</th>
<th>{{ 'docgen.test generate'|trans }}</th>
<th>{{ 'Edit'|trans }}</th>
{% endblock %}
{% block table_entities_tbody %}
{% for entity in entities %}
<tr>
<td>{{ entity.id }}</td>
<td>{{ entity.name|localize_translatable_string}}</td>
<td>{{ contextManager.getContextByKey(entity.context).name|trans }}</td>
<td>
<form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}">
<input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', '/')|e('html_attr') }}" />
<input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" />
<input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" />
<input type="text" name="entityId" />
{% if entities|length == 0 %}
<p class="chill-no-data-statement">{{ 'docgen.Any template configured'|trans }}</p>
{% else %}
<div class="flex-table">
{% for entity in entities %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="flex-basis:100%;">
<h2>{{ entity.name|localize_translatable_string }}</h2>
</div>
</div>
<div class="item-row">
<p><span class="badge bg-chill-green-dark">{{ contextManager.getContextByKey(entity.context).name|trans }}</span></p>
</div>
<div class="item-row">
<div class="item-col"></div>
<ul class="record_actions item-col flex-shrink-1">
<li>
<form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}">
<input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', app.request.uri)|e('html_attr') }}" />
<input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" />
<input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" />
<input type="text" name="entityId" placeholder="{{ 'docgen.entity_id_placeholder'|trans }}" required />
<button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button>
</form>
</li>
<li>
{{ entity.file|chill_document_button_group('Template file', true) }}
</li>
<li>
<a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
</ul>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button>
</form>
</td>
<td>
<a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</td>
</tr>
{% endfor %}
{% endblock %}
{% block actions_before %}

View File

@@ -6,18 +6,20 @@
<div class="col-md-10 col-xxl">
<h1>{{ block('title') }}</h1>
<div class="container">
<div class="container overflow-hidden">
{% for key, context in contexts %}
<div class="row">
<div class="col-md-4">
<div class="row g-3" style="margin-top: 1rem;">
<div class="col-4 offset-1 text-center">
<a
href="{{ path('chill_crud_docgen_template_new', { 'context': key }) }}"
class="btn btn-outline-chill-green-dark">
{{ context.name|trans }}
</a>
</div>
<div class="col-md-8">
{{ context.description|trans|nl2br }}
<div class="col">
<div>
{{ context.description|trans|nl2br }}
</div>
</div>
</div>
{% endfor %}

View File

@@ -1,6 +1,6 @@
{{ creator.label }},
{% if creator is not same as null %}{{ creator.label }},{% endif %}
{{ 'docgen.failure_email.The generation of the document {template_name} failed'|trans({'{template_name}': template.name|localize_translatable_string}) }}
{{ 'docgen.failure_email.The generation of the document %template_name% failed'|trans({'%template_name%': template.name|localize_translatable_string}) }}
{{ 'docgen.failure_email.Forward this email to your administrator for solving'|trans }}

View File

@@ -0,0 +1,7 @@
{{ 'docgen.data_dump_email.Dear'|trans }}
{{ 'docgen.data_dump_email.data_dump_ready_and_link'|trans }}
{{ link }}
{{ 'docgen.data_dump_email.link_valid_until'|trans({validity: validity}) }}

View File

@@ -17,54 +17,88 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface;
use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Yaml\Yaml;
class Generator implements GeneratorInterface
{
private const LOG_PREFIX = '[docgen generator] ';
public function __construct(private readonly ContextManagerInterface $contextManager, private readonly DriverInterface $driver, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, private readonly StoredObjectManagerInterface $storedObjectManager)
{
public function __construct(
private readonly ContextManagerInterface $contextManager,
private readonly DriverInterface $driver,
private readonly ManagerRegistry $objectManagerRegistry,
private readonly LoggerInterface $logger,
private readonly StoredObjectManagerInterface $storedObjectManager
) {
}
public function generateDataDump(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject {
return $this->generateFromTemplate(
$template,
$entityId,
$contextGenerationDataNormalized,
$destinationStoredObject,
$creator,
$clearEntityManagerDuringProcess,
true,
);
}
/**
* @template T of File|null
* @template B of bool
*
* @param B $isTest
* @param (B is true ? T : null) $testFile
*
* @psalm-return (B is true ? string : null)
*
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null,
?User $creator = null
): ?string {
if ($destinationStoredObject instanceof StoredObject && StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) {
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject {
return $this->generateFromTemplate(
$template,
$entityId,
$contextGenerationDataNormalized,
$destinationStoredObject,
$creator,
$clearEntityManagerDuringProcess,
false,
);
}
private function generateFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
bool $generateDumpOnly = false,
): StoredObject {
if (StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) {
$this->logger->info(self::LOG_PREFIX.'Aborting generation of an already generated document');
throw new ObjectReadyException();
}
$this->logger->info(self::LOG_PREFIX.'Starting generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => null === $destinationStoredObject ? null : $destinationStoredObject->getId(),
'destination_stored_object' => $destinationStoredObject->getId(),
]);
$context = $this->contextManager->getContextByDocGeneratorTemplate($template);
$entity = $this
->entityManager
->objectManagerRegistry
->getManagerForClass($context->getEntityClass())
->find($context->getEntityClass(), $entityId)
;
@@ -82,17 +116,47 @@ class Generator implements GeneratorInterface
$data = $context->getData($template, $entity, $contextGenerationDataNormalized);
$destinationStoredObjectId = $destinationStoredObject instanceof StoredObject ? $destinationStoredObject->getId() : null;
$this->entityManager->clear();
gc_collect_cycles();
if (null !== $destinationStoredObjectId) {
$destinationStoredObject = $this->entityManager->find(StoredObject::class, $destinationStoredObjectId);
$destinationStoredObjectId = $destinationStoredObject->getId();
if ($clearEntityManagerDuringProcess) {
// we clean the entity manager
$this->objectManagerRegistry->getManagerForClass($context->getEntityClass())?->clear();
// this will force php to clean the memory
gc_collect_cycles();
}
if ($isTest && ($testFile instanceof File)) {
$templateDecrypted = file_get_contents($testFile->getPathname());
} else {
// as we potentially deleted the storedObject from memory, we have to restore it
$destinationStoredObject = $this->objectManagerRegistry
->getManagerForClass(StoredObject::class)
->find(StoredObject::class, $destinationStoredObjectId);
if ($generateDumpOnly) {
$content = Yaml::dump($data, 6);
/* @var StoredObject $destinationStoredObject */
$destinationStoredObject
->setType('application/yaml')
->setFilename(sprintf('%s_yaml', uniqid('doc_', true)))
->setStatus(StoredObject::STATUS_READY)
;
try {
$this->storedObjectManager->write($destinationStoredObject, $content);
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
throw new GeneratorException([$e->getMessage()], $e);
}
return $destinationStoredObject;
}
try {
$templateDecrypted = $this->storedObjectManager->read($template->getFile());
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
throw new GeneratorException([$e->getMessage()], $e);
}
try {
@@ -105,19 +169,10 @@ class Generator implements GeneratorInterface
$template->getFile()->getFilename()
);
} catch (TemplateException $e) {
$destinationStoredObject->addGenerationErrors(implode("\n", $e->getErrors()));
throw new GeneratorException($e->getErrors(), $e);
}
if (true === $isTest) {
$this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'is_test' => true,
'entity_id' => $entityId,
'destination_stored_object' => null === $destinationStoredObject ? null : $destinationStoredObject->getId(),
]);
return $generatedResource;
}
/* @var StoredObject $destinationStoredObject */
$destinationStoredObject
->setType($template->getFile()->getType())
@@ -125,15 +180,19 @@ class Generator implements GeneratorInterface
->setStatus(StoredObject::STATUS_READY)
;
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
try {
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
$this->entityManager->flush();
throw new GeneratorException([$e->getMessage()], $e);
}
$this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => $destinationStoredObject->getId(),
]);
return null;
return $destinationStoredObject;
}
}

View File

@@ -13,29 +13,48 @@ namespace Chill\DocGeneratorBundle\Service\Generator;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\MainBundle\Entity\User;
use Symfony\Component\HttpFoundation\File\File;
interface GeneratorInterface
{
/**
* @template T of File|null
* @template B of bool
* Generate a document and store the document on disk.
*
* @param B $isTest
* @param (B is true ? T : null) $testFile
* The given $destinationStoredObject will be updated with filename, status, and eventually errors will be stored
* into the object. The number of generation trial will also be incremented.
*
* @psalm-return (B is true ? string : null)
* This process requires a huge amount of data. For this reason, the entity manager will be cleaned during the process,
* unless the paarameter `$clearEntityManagerDuringProcess` is set on false.
*
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
* As the entity manager might be cleaned, the new instance of the stored object will be returned by this method.
*
* Ensure to store change in the database after each generation trial (call `EntityManagerInterface::flush`).
*
* @phpstan-impure
*
* @param StoredObject $destinationStoredObject will be update with filename, status and incremented of generation trials
*
* @throws StoredObjectManagerException if unable to decrypt the template or store the document
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null,
?User $creator = null
): ?string;
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject;
/**
* Generate a data dump, and store it within the `$destinationStoredObject`.
*/
public function generateDataDump(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
StoredObject $destinationStoredObject,
User $creator,
bool $clearEntityManagerDuringProcess = true,
): StoredObject;
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocGeneratorBundle\Service\Messenger;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
/**
* The OnAfterMessageHandledClearStoredObjectCache class is an event subscriber that clears the stored object cache
* after a specific message is handled or fails.
*/
final readonly class OnAfterMessageHandledClearStoredObjectCache implements EventSubscriberInterface
{
public function __construct(
private StoredObjectManagerInterface $storedObjectManager,
private LoggerInterface $logger,
) {
}
public static function getSubscribedEvents()
{
return [
WorkerMessageHandledEvent::class => [
['afterHandling', 0],
],
WorkerMessageFailedEvent::class => [
['afterFails', 0],
],
];
}
public function afterHandling(WorkerMessageHandledEvent $event): void
{
if ($event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) {
$this->clearStoredObjectCache();
}
}
public function afterFails(WorkerMessageFailedEvent $event): void
{
if ($event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) {
$this->clearStoredObjectCache();
}
}
private function clearStoredObjectCache(): void
{
$this->logger->debug('clear the cache after generation of a document');
$this->storedObjectManager->clearCache();
}
}

View File

@@ -11,10 +11,11 @@ declare(strict_types=1);
namespace Chill\DocGeneratorBundle\Service\Messenger;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorException;
use Chill\DocGeneratorBundle\tests\Service\Messenger\OnGenerationFailsTest;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -24,12 +25,22 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see OnGenerationFailsTest for test suite
*/
final readonly class OnGenerationFails implements EventSubscriberInterface
{
public const LOG_PREFIX = '[docgen failed] ';
public function __construct(private DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private EntityManagerInterface $entityManager, private LoggerInterface $logger, private MailerInterface $mailer, private StoredObjectRepository $storedObjectRepository, private TranslatorInterface $translator, private UserRepositoryInterface $userRepository)
{
public function __construct(
private DocGeneratorTemplateRepositoryInterface $docGeneratorTemplateRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private MailerInterface $mailer,
private StoredObjectRepositoryInterface $storedObjectRepository,
private TranslatorInterface $translator,
private UserRepositoryInterface $userRepository
) {
}
public static function getSubscribedEvents()
@@ -45,13 +56,12 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
return;
}
if (!$event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) {
$message = $event->getEnvelope()->getMessage();
if (!$message instanceof RequestGenerationMessage) {
return;
}
/** @var RequestGenerationMessage $message */
$message = $event->getEnvelope()->getMessage();
$this->logger->error(self::LOG_PREFIX.'Docgen failed', [
'stored_object_id' => $message->getDestinationStoredObjectId(),
'entity_id' => $message->getEntityId(),
@@ -79,16 +89,8 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
private function warnCreator(RequestGenerationMessage $message, WorkerMessageFailedEvent $event): void
{
$creatorId = $message->getCreatorId();
if (null === $creator = $this->userRepository->find($creatorId)) {
$this->logger->error(self::LOG_PREFIX.'Creator not found with given id', ['creator_id', $creatorId]);
return;
}
if (null === $creator->getEmail() || '' === $creator->getEmail()) {
$this->logger->info(self::LOG_PREFIX.'Creator does not have any email', ['user' => $creator->getUsernameCanonical()]);
if (null === $message->getSendResultToEmail() || '' === $message->getSendResultToEmail()) {
$this->logger->info(self::LOG_PREFIX.'No email associated with this request generation');
return;
}
@@ -96,7 +98,7 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
// if the exception is not a GeneratorException, we try the previous one...
$throwable = $event->getThrowable();
if (!$throwable instanceof GeneratorException) {
$throwable = $throwable->getPrevious();
$throwable = $throwable->getPrevious() ?? $throwable;
}
if ($throwable instanceof GeneratorException) {
@@ -111,8 +113,14 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
return;
}
if (null === $creator = $this->userRepository->find($message->getCreatorId())) {
$this->logger->error(self::LOG_PREFIX.'Creator not found');
return;
}
$email = (new TemplatedEmail())
->to($creator->getEmail())
->to($message->getSendResultToEmail())
->subject($this->translator->trans('docgen.failure_email.The generation of a document failed'))
->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig')
->context([

View File

@@ -11,15 +11,21 @@ declare(strict_types=1);
namespace Chill\DocGeneratorBundle\Service\Messenger;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\Generator;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Handle the request of document generation.
@@ -30,8 +36,17 @@ class RequestGenerationHandler implements MessageHandlerInterface
private const LOG_PREFIX = '[docgen message handler] ';
public function __construct(private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private readonly EntityManagerInterface $entityManager, private readonly Generator $generator, private readonly LoggerInterface $logger, private readonly StoredObjectRepository $storedObjectRepository, private readonly UserRepositoryInterface $userRepository)
{
public function __construct(
private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Generator $generator,
private readonly LoggerInterface $logger,
private readonly StoredObjectRepository $storedObjectRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly MailerInterface $mailer,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
private readonly TranslatorInterface $translator,
) {
}
public function __invoke(RequestGenerationMessage $message)
@@ -45,25 +60,59 @@ class RequestGenerationHandler implements MessageHandlerInterface
}
if ($destinationStoredObject->getGenerationTrialsCounter() >= self::AUTHORIZED_TRIALS) {
$this->logger->error(self::LOG_PREFIX.'Request generation abandoned: maximum number of retry reached', [
'template_id' => $message->getTemplateId(),
'destination_stored_object' => $message->getDestinationStoredObjectId(),
'trial' => $destinationStoredObject->getGenerationTrialsCounter(),
]);
throw new UnrecoverableMessageHandlingException('maximum number of retry reached');
}
$creator = $this->userRepository->find($message->getCreatorId());
// we increase the number of generation trial in the object, and, in the same time, update the counter
// on the database side. This ensure that, if the script fails for any reason (memory limit reached), the
// counter is inscreased
$destinationStoredObject->addGenerationTrial();
$this->entityManager->createQuery('UPDATE '.StoredObject::class.' s SET s.generationTrialsCounter = s.generationTrialsCounter + 1 WHERE s.id = :id')
->setParameter('id', $destinationStoredObject->getId())
->execute();
$this->generator->generateDocFromTemplate(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
false,
null,
$creator
);
try {
if ($message->isDumpOnly()) {
$destinationStoredObject = $this->generator->generateDataDump(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
$creator
);
$this->sendDataDump($destinationStoredObject, $message);
} else {
$destinationStoredObject = $this->generator->generateDocFromTemplate(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
$creator
);
}
} catch (StoredObjectManagerException|GeneratorException $e) {
$this->entityManager->flush();
$this->logger->error(self::LOG_PREFIX.'Request generation failed', [
'template_id' => $message->getTemplateId(),
'destination_stored_object' => $message->getDestinationStoredObjectId(),
'trial' => $destinationStoredObject->getGenerationTrialsCounter(),
'error' => $e->getTraceAsString(),
]);
throw $e;
}
$this->entityManager->flush();
$this->logger->info(self::LOG_PREFIX.'Request generation finished', [
'template_id' => $message->getTemplateId(),
@@ -71,4 +120,23 @@ class RequestGenerationHandler implements MessageHandlerInterface
'duration_int' => (new \DateTimeImmutable('now'))->getTimestamp() - $message->getCreatedAt()->getTimestamp(),
]);
}
private function sendDataDump(StoredObject $destinationStoredObject, RequestGenerationMessage $message): void
{
$url = $this->tempUrlGenerator->generate('GET', $destinationStoredObject->getFilename(), 3600);
$parts = [];
parse_str(parse_url((string) $url->url)['query'], $parts);
$validity = \DateTimeImmutable::createFromFormat('U', $parts['temp_url_expires']);
$email = (new TemplatedEmail())
->to($message->getSendResultToEmail())
->textTemplate('@ChillDocGenerator/Email/send_data_dump_to_admin.txt.twig')
->context([
'link' => $url->url,
'validity' => $validity,
])
->subject($this->translator->trans('docgen.data_dump_email.subject'));
$this->mailer->send($email);
}
}

View File

@@ -15,27 +15,33 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
class RequestGenerationMessage
final readonly class RequestGenerationMessage
{
private readonly int $creatorId;
private int $creatorId;
private readonly int $templateId;
private int $templateId;
private readonly int $destinationStoredObjectId;
private int $destinationStoredObjectId;
private readonly \DateTimeImmutable $createdAt;
private \DateTimeImmutable $createdAt;
private ?string $sendResultToEmail;
public function __construct(
User $creator,
DocGeneratorTemplate $template,
private readonly int $entityId,
private int $entityId,
StoredObject $destinationStoredObject,
private readonly array $contextGenerationData
private array $contextGenerationData,
private bool $isTest = false,
?string $sendResultToEmail = null,
private bool $dumpOnly = false,
) {
$this->creatorId = $creator->getId();
$this->templateId = $template->getId();
$this->destinationStoredObjectId = $destinationStoredObject->getId();
$this->createdAt = new \DateTimeImmutable('now');
$this->sendResultToEmail = $sendResultToEmail ?? $creator->getEmail();
}
public function getCreatorId(): int
@@ -67,4 +73,19 @@ class RequestGenerationMessage
{
return $this->createdAt;
}
public function isTest(): bool
{
return $this->isTest;
}
public function getSendResultToEmail(): ?string
{
return $this->sendResultToEmail;
}
public function isDumpOnly(): bool
{
return $this->dumpOnly;
}
}

View File

@@ -20,7 +20,9 @@ use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException;
use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
@@ -66,7 +68,11 @@ class GeneratorTest extends TestCase
$entityManager->find('DummyClass', Argument::type('int'))
->willReturn($entity);
$entityManager->clear()->shouldBeCalled();
$entityManager->flush()->shouldBeCalled();
$entityManager->flush()->shouldNotBeCalled();
$managerRegistry = $this->prophesize(ManagerRegistry::class);
$managerRegistry->getManagerForClass('DummyClass')->willReturn($entityManager->reveal());
$managerRegistry->getManagerForClass(StoredObject::class)->willReturn($entityManager->reveal());
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($templateStoredObject)->willReturn('template');
@@ -75,7 +81,7 @@ class GeneratorTest extends TestCase
$generator = new Generator(
$contextManagerInterface->reveal(),
$driver->reveal(),
$entityManager->reveal(),
$managerRegistry->reveal(),
new NullLogger(),
$storedObjectManager->reveal()
);
@@ -84,7 +90,8 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject
$destinationStoredObject,
new User()
);
}
@@ -95,7 +102,7 @@ class GeneratorTest extends TestCase
$generator = new Generator(
$this->prophesize(ContextManagerInterface::class)->reveal(),
$this->prophesize(DriverInterface::class)->reveal(),
$this->prophesize(EntityManagerInterface::class)->reveal(),
$this->prophesize(ManagerRegistry::class)->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
@@ -108,7 +115,8 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject
$destinationStoredObject,
new User()
);
}
@@ -136,10 +144,14 @@ class GeneratorTest extends TestCase
$entityManager->find(Argument::type('string'), Argument::type('int'))
->willReturn(null);
$managerRegistry = $this->prophesize(ManagerRegistry::class);
$managerRegistry->getManagerForClass('DummyClass')->willReturn($entityManager->reveal());
$managerRegistry->getManagerForClass(StoredObject::class)->willReturn($entityManager->reveal());
$generator = new Generator(
$contextManagerInterface->reveal(),
$this->prophesize(DriverInterface::class)->reveal(),
$entityManager->reveal(),
$managerRegistry->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
@@ -148,7 +160,8 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject
$destinationStoredObject,
new User()
);
}
}

View File

@@ -0,0 +1,107 @@
<?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\DocGeneratorBundle\tests\Service\Messenger;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Service\Messenger\OnAfterMessageHandledClearStoredObjectCache;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
/**
* @internal
*
* @coversNothing
*/
class OnAfterMessageHandledClearStoredObjectCacheTest extends TestCase
{
use ProphecyTrait;
public function testThatNotGenerationMessageDoesNotCallAClearCache(): void
{
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->clearCache()->shouldNotBeCalled();
$eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal());
$eventSubscriber->afterHandling($this->buildEventSuccess(new \stdClass()));
$eventSubscriber->afterFails($this->buildEventFailed(new \stdClass()));
}
public function testThatConcernedEventCallAClearCache(): void
{
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->clearCache()->shouldBeCalledTimes(2);
$eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal());
$eventSubscriber->afterHandling($this->buildEventSuccess($this->buildRequestGenerationMessage()));
$eventSubscriber->afterFails($this->buildEventFailed($this->buildRequestGenerationMessage()));
}
private function buildRequestGenerationMessage(
): RequestGenerationMessage {
$creator = new User();
$creator->setEmail('fake@example.com');
$class = new \ReflectionClass($creator);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($creator, 1);
$template ??= new DocGeneratorTemplate();
$class = new \ReflectionClass($template);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($template, 2);
$destinationStoredObject = new StoredObject();
$class = new \ReflectionClass($destinationStoredObject);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($destinationStoredObject, 3);
return new RequestGenerationMessage(
$creator,
$template,
1,
$destinationStoredObject,
[],
);
}
private function buildEventSubscriber(StoredObjectManagerInterface $storedObjectManager): OnAfterMessageHandledClearStoredObjectCache
{
return new OnAfterMessageHandledClearStoredObjectCache($storedObjectManager, new NullLogger());
}
private function buildEventFailed(object $message): WorkerMessageFailedEvent
{
$envelope = new Envelope($message);
return new WorkerMessageFailedEvent($envelope, 'testing', new \RuntimeException());
}
private function buildEventSuccess(object $message): WorkerMessageHandledEvent
{
$envelope = new Envelope($message);
return new WorkerMessageHandledEvent($envelope, 'test_receiver');
}
}

View File

@@ -0,0 +1,226 @@
<?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\DocGeneratorBundle\tests\Service\Messenger;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\DocGeneratorBundle\Service\Messenger\OnGenerationFails;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class OnGenerationFailsTest extends TestCase
{
use ProphecyTrait;
public function testNotConcernedMessageAreNotHandled(): void
{
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldNotBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send()->shouldNotBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal()
);
$event = $this->buildEvent(new \stdClass());
$eventSubscriber->onMessageFailed($event);
}
public function testMessageThatWillBeRetriedAreNotHandled(): void
{
$storedObject = new StoredObject();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldNotBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send()->shouldNotBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal()
);
$event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject));
$event->setForRetry();
$eventSubscriber->onMessageFailed($event);
}
public function testThatANotRetriyableEventWillMarkObjectAsFailed(): void
{
$storedObject = new StoredObject();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send(Argument::type(RawMessage::class), Argument::any())->shouldBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal(),
storedObject: $storedObject
);
$event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject));
$eventSubscriber->onMessageFailed($event);
self::assertEquals(StoredObject::STATUS_FAILURE, $storedObject->getStatus());
}
public function testThatANonRetryableEventSendAnEmail(): void
{
$storedObject = new StoredObject();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldBeCalled();
$mailer = $this->prophesize(MailerInterface::class);
$mailer->send(
Argument::that(function ($arg): bool {
if (!$arg instanceof Email) {
return false;
}
foreach ($arg->getTo() as $to) {
if ('test@test.com' === $to->getAddress()) {
return true;
}
}
return false;
}),
Argument::any()
)
->shouldBeCalled();
$eventSubscriber = $this->buildOnGenerationFailsEventSubscriber(
entityManager: $entityManager->reveal(),
mailer: $mailer->reveal(),
storedObject: $storedObject
);
$event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject, sendResultToEmail: 'test@test.com'));
$eventSubscriber->onMessageFailed($event);
}
private function buildRequestGenerationMessage(
StoredObject $destinationStoredObject,
?User $creator = null,
?DocGeneratorTemplate $template = null,
array $contextGenerationData = [],
bool $isTest = false,
?string $sendResultToEmail = null,
): RequestGenerationMessage {
if (null === $creator) {
$creator = new User();
$creator->setEmail('fake@example.com');
}
if (null === $creator->getId()) {
$class = new \ReflectionClass($creator);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($creator, 1);
}
$template ??= new DocGeneratorTemplate();
$class = new \ReflectionClass($template);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($template, 2);
$class = new \ReflectionClass($destinationStoredObject);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($destinationStoredObject, 3);
return new RequestGenerationMessage(
$creator,
$template,
1,
$destinationStoredObject,
$contextGenerationData,
$isTest,
$sendResultToEmail
);
}
private function buildOnGenerationFailsEventSubscriber(
?StoredObject $storedObject = null,
?EntityManagerInterface $entityManager = null,
?MailerInterface $mailer = null,
): OnGenerationFails {
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$storedObjectRepository->find(Argument::type('int'))->willReturn($storedObject ?? new StoredObject());
if (null === $entityManager) {
$entityManagerProphecy = $this->prophesize(EntityManagerInterface::class);
}
if (null === $mailer) {
$mailerProphecy = $this->prophesize(MailerInterface::class);
}
$translator = $this->prophesize(TranslatorInterface::class);
$translator->trans(Argument::type('string'))->will(fn ($args) => $args[0]);
$userRepository = $this->prophesize(UserRepositoryInterface::class);
$userRepository->find(Argument::type('int'))->willReturn(new User());
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$docGeneratorTemplateRepository->find(Argument::type('int'))->willReturn(new DocGeneratorTemplate());
return new OnGenerationFails(
$docGeneratorTemplateRepository->reveal(),
$entityManager ?? $entityManagerProphecy->reveal(),
new NullLogger(),
$mailer ?? $mailerProphecy->reveal(),
$storedObjectRepository->reveal(),
$translator->reveal(),
$userRepository->reveal()
);
}
private function buildEvent(object $message): WorkerMessageFailedEvent
{
$envelope = new Envelope($message);
return new WorkerMessageFailedEvent($envelope, 'testing', new \RuntimeException());
}
}

View File

@@ -0,0 +1,4 @@
docgen:
data_dump_email:
link_valid_until: >-
Ce lien est valide jusqu'au {validity, date, full}, {validity, time, medium}

View File

@@ -14,13 +14,31 @@ docgen:
Doc generation is pending: La génération de ce document est en cours
Come back later: Revenir plus tard
Send report to: Envoyer le rapport à
Send report errors to this email address: Les rapports d'erreurs seront envoyés à l'adresse email indiquée
Generate as creator: Générer en tant que
The document will be generated as the given creator: Le document sera généré à la place de l'utilisateur indiqué
Show data instead of generating: Montrer les données au lieu de générer le document
Any template configured: Aucun gabarit de document configuré
entity_id_placeholder: Identifiant de l'entité
failure_email:
The generation of a document failed: La génération d'un document a échoué
The generation of the document {template_name} failed: La génération d'un document à partir du modèle {{ template_name }} a échoué.
The generation of the document %template_name% failed: La génération d'un document à partir du modèle {{ template_name }} a échoué.
The following errors were encoutered: Les erreurs suivantes ont été rencontrées
Forward this email to your administrator for solving: Faites suivre ce message vers votre administrateur pour la résolution du problème.
References: Références
data_dump_email:
subject: Contenu des données de génération de document disponible
Dear: Cher
data_dump_ready_and_link: >-
Le contenu des données est disponible. Vous pouvez le télécharger à l'aide du lien suivant:
crud:
docgen_template:
index:
@@ -28,5 +46,4 @@ crud:
add_new: Créer
Show data instead of generating: Montrer les données au lieu de générer le document
Template file: Fichier modèle

View File

@@ -1,252 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
/**
* Provide endpoint for editing a document on the desktop using dav.
*
* This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice)
* and save the document online.
*
* To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the
* URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which
* they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read
* the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT.
*/
final readonly class WebdavController
{
private PropfindRequestAnalyzer $requestAnalyzer;
public function __construct(
private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
) {
$this->requestAnalyzer = new PropfindRequestAnalyzer();
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
*/
public function getDirectory(StoredObject $storedObject, string $access_token): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
'stored_object' => $storedObject,
'access_token' => $access_token,
])
);
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
*/
public function optionsDirectory(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
*/
public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$depth = $request->headers->get('depth');
if ('0' !== $depth && '1' !== $depth) {
throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
'stored_object' => $storedObject,
'properties' => $properties,
'last_modified' => $lastModified,
'etag' => $etag,
'content_length' => $length,
'depth' => (int) $depth,
'access_token' => $access_token,
]),
207
);
$response->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
*/
public function getDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return (new DavResponse($this->storedObjectManager->read($storedObject)))
->setEtag($this->storedObjectManager->etag($storedObject));
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
*/
public function headDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = new DavResponse('');
$response->headers->add(
[
'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
'Content-Type' => $storedObject->getType(),
'Etag' => $this->storedObjectManager->etag($storedObject),
]
);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
*/
public function optionsDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
*/
public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render(
'@ChillDocStore/Webdav/doc_props.xml.twig',
[
'stored_object' => $storedObject,
'properties' => $properties,
'etag' => $etag,
'last_modified' => $lastModified,
'content_length' => $length,
'access_token' => $access_token,
]
),
207
);
$response
->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"})
*/
public function putDocument(StoredObject $storedObject, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
/**
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
*/
private function parseDavRequest(string $content, StoredObject $storedObject): array
{
$xml = new \DOMDocument();
$xml->loadXML($content);
$properties = $this->requestAnalyzer->getRequestedProperties($xml);
$requested = array_keys(array_filter($properties, fn ($item) => true === $item));
if (
in_array('lastModified', $requested, true)
|| in_array('etag', $requested, true)
) {
$lastModified = $this->storedObjectManager->getLastModified($storedObject);
$etag = $this->storedObjectManager->etag($storedObject);
}
if (in_array('contentLength', $requested, true)) {
$length = $this->storedObjectManager->getContentLength($storedObject);
}
return [
$properties,
$lastModified ?? null,
$etag ?? null,
$length ?? null,
];
}
}

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Exception;
class ParseRequestException extends \UnexpectedValueException
{
}

View File

@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Request;
use Chill\DocStoreBundle\Dav\Exception\ParseRequestException;
/**
* @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>}
*/
class PropfindRequestAnalyzer
{
private const KNOWN_PROPS = [
'resourceType',
'contentType',
'lastModified',
'creationDate',
'contentLength',
'etag',
'supportedLock',
];
/**
* @return davProperties
*/
public function getRequestedProperties(\DOMDocument $request): array
{
$propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind');
if (0 === $propfinds->count()) {
throw new ParseRequestException('any propfind element found');
}
if (1 < $propfinds->count()) {
throw new ParseRequestException('too much propfind element found');
}
$propfind = $propfinds->item(0);
if (0 === $propfind->childNodes->count()) {
throw new ParseRequestException('no element under propfind');
}
$unknows = [];
$props = [];
foreach ($propfind->childNodes->getIterator() as $prop) {
/** @var \DOMNode $prop */
if (XML_ELEMENT_NODE !== $prop->nodeType) {
continue;
}
if ('propname' === $prop->nodeName) {
return $this->baseProps(true);
}
foreach ($prop->childNodes->getIterator() as $getProp) {
if (XML_ELEMENT_NODE !== $getProp->nodeType) {
continue;
}
if ('DAV:' !== $getProp->lookupNamespaceURI(null)) {
$unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName];
continue;
}
$props[] = match ($getProp->nodeName) {
'resourcetype' => 'resourceType',
'getcontenttype' => 'contentType',
'getlastmodified' => 'lastModified',
default => '',
};
}
}
$props = array_filter(array_values($props), fn (string $item) => '' !== $item);
return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows];
}
/**
* @return davProperties
*/
private function baseProps(bool $default = false): array
{
return
[
...array_combine(
self::KNOWN_PROPS,
array_fill(0, count(self::KNOWN_PROPS), $default)
),
'unknowns' => [],
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Response;
use Symfony\Component\HttpFoundation\Response;
class DavResponse extends Response
{
public function __construct($content = '', int $status = 200, array $headers = [])
{
parent::__construct($content, $status, $headers);
$this->headers->add(['DAV' => '1']);
}
}

View File

@@ -25,6 +25,11 @@ use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Represent a document stored in an object store.
*
* StoredObjects 's content should be read and written using the @see{StoredObjectManagerInterface}.
*
* The property `$deleteAt` allow a deletion of the document after the given date. But this property should
* be set before the document is actually written by the StoredObjectManager.
*
* @ORM\Entity
*
* @ORM\Table("chill_doc.stored_object")
@@ -117,6 +122,16 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*/
private int $generationTrialsCounter = 0;
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": null})
*/
private ?\DateTimeImmutable $deleteAt = null;
/**
* @ORM\Column(type="text", nullable=false, options={"default": ""})
*/
private string $generationErrors = '';
/**
* @param StoredObject::STATUS_* $status
*/
@@ -144,6 +159,11 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*/
public function getCreationDate(): \DateTime
{
if (null === $this->createdAt) {
// this scenario will quite never happens
return new \DateTime('now');
}
return \DateTime::createFromImmutable($this->createdAt);
}
@@ -303,4 +323,37 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
{
return self::STATUS_FAILURE === $this->getStatus();
}
public function getDeleteAt(): ?\DateTimeImmutable
{
return $this->deleteAt;
}
public function setDeleteAt(?\DateTimeImmutable $deleteAt): StoredObject
{
$this->deleteAt = $deleteAt;
return $this;
}
public function getGenerationErrors(): string
{
return $this->generationErrors;
}
/**
* Adds generation errors to the stored object.
*
* The existing generation errors are not removed
*
* @param string $generationErrors the generation errors to be added
*
* @return StoredObject the modified StoredObject instance
*/
public function addGenerationErrors(string $generationErrors): StoredObject
{
$this->generationErrors = $this->generationErrors.$generationErrors."\n";
return $this;
}
}

View File

@@ -14,11 +14,10 @@ namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
final class StoredObjectRepository implements ObjectRepository
final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface
{
private readonly EntityRepository $repository;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\Persistence\ObjectRepository;
/**
* @extends ObjectRepository<StoredObject>
*/
interface StoredObjectRepositoryInterface extends ObjectRepository
{
}

View File

@@ -17,22 +17,18 @@ window.addEventListener('DOMContentLoaded', function (e) {
canEdit: string,
storedObject: string,
buttonSmall: string,
davLink: string,
davLinkExpiration: string,
};
const
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
filename = datasets.filename,
canEdit = datasets.canEdit === '1',
small = datasets.buttonSmall === '1',
davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null,
davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null
small = datasets.buttonSmall === '1'
;
return { storedObject, filename, canEdit, small, davLink, davLinkExpiration };
return { storedObject, filename, canEdit, small };
},
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
methods: {
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
this.$data.storedObject.status = newStatus.status;

View File

@@ -7,9 +7,6 @@
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
@@ -39,7 +36,6 @@ import {
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
@@ -61,16 +57,6 @@ interface DocumentActionButtonsGroupConfig {
* If set, will execute this function before leaving to the editor
*/
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
/**
* a link to download and edit file using webdav
*/
davLink?: string,
/**
* the expiration date of the download, as a unix timestamp
*/
davLinkExpiration?: number,
}
const emit = defineEmits<{
@@ -82,7 +68,7 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
canEdit: true,
canDownload: true,
canConvertPdf: true,
returnPath: window.location.pathname + window.location.search + window.location.hash
returnPath: window.location.pathname + window.location.search + window.location.hash,
});
/**

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {computed, reactive} from "vue";
export interface DesktopEditButtonConfig {
editLink: null,
classes: { [k: string]: boolean },
expirationLink: number|Date,
}
interface DesktopEditButtonState {
modalOpened: boolean
};
const state: DesktopEditButtonState = reactive({modalOpened: false});
const props = defineProps<DesktopEditButtonConfig>();
const buildCommand = computed<string>(() => 'vnd.libreoffice.command:ofe|u|' + props.editLink);
const editionUntilFormatted = computed<string>(() => {
let d;
if (props.expirationLink instanceof Date) {
d = props.expirationLink;
} else {
d = new Date(props.expirationLink * 1000);
}
console.log(props.expirationLink);
return (new Intl.DateTimeFormat(undefined, {'dateStyle': 'long', 'timeStyle': 'medium'})).format(d);
});
</script>
<template>
<teleport to="body">
<modal v-if="state.modalOpened" @close="state.modalOpened=false">
<template v-slot:body>
<div class="desktop-edit">
<p class="center">Veuillez enregistrer vos modifications avant le</p>
<p><strong>{{ editionUntilFormatted }}</strong></p>
<p><a class="btn btn-primary" :href="buildCommand">Ouvrir le document pour édition</a></p>
<p><small>Le document peut être édité uniquement en utilisant Libre Office.</small></p>
<p><small>En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.</small></p>
<p><small>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small></p>
</div>
</template>
</modal>
</teleport>
<a :class="props.classes" @click="state.modalOpened = true">
<i class="fa fa-desktop"></i>
Éditer sur le bureau
</a>
</template>
<style scoped lang="scss">
.desktop-edit {
text-align: center;
}
</style>

View File

@@ -3,7 +3,5 @@
data-download-buttons
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
data-can-edit="{{ can_edit ? '1' : '0' }}"
data-dav-link="{{ dav_link|escape('html_attr') }}"
data-dav-link-expiration="{{ dav_link_expiration|escape('html_attr') }}"
{% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %}
{% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div>

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory for {{ stored_object.uuid }}</title>
</head>
<body>
<ul>
<li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li>
</ul>
</body>
</html>

View File

@@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href>
{% if properties.resourceType or properties.contentType %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype><d:collection/></d:resourcetype>
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
{% if depth == 1 %}
<d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype/>
{% endif %}
{% if properties.creationDate %}
<d:creationdate />
{% endif %}
{% if properties.lastModified %}
{% if last_modified is not same as null %}
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
{% else %}
<d:getlastmodified />
{% endif %}
{% endif %}
{% if properties.contentLength %}
{% if content_length is not same as null %}
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
{% else %}
<d:getcontentlength />
{% endif %}
{% endif %}
{% if properties.etag %}
{% if etag is not same as null %}
<d:getetag>"{{ etag }}"</d:getetag>
{% else %}
<d:getetag />
{% endif %}
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
{% endif %}
</d:multistatus>

View File

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype/>
{% endif %}
{% if properties.creationDate %}
<d:creationdate />
{% endif %}
{% if properties.lastModified %}
{% if last_modified is not same as null %}
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
{% else %}
<d:getlastmodified />
{% endif %}
{% endif %}
{% if properties.contentLength %}
{% if content_length is not same as null %}
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
{% else %}
<d:getcontentlength />
{% endif %}
{% endif %}
{% if properties.etag %}
{% if etag is not same as null %}
<d:getetag>"{{ etag }}"</d:getetag>
{% else %}
<d:getetag />
{% endif %}
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
</d:multistatus>

View File

@@ -1,7 +0,0 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block content %}
<p>document uuid: {{ stored_object.uuid }}</p>
<p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}</p>
<a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a>
{% endblock %}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
/**
* Role to edit or see the stored object content.
*/
enum StoredObjectRoleEnum: string
{
case SEE = 'SEE';
case EDIT = 'SEE_AND_EDIT';
}

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for the content of a stored object.
*
* This is in use to allow or disallow the edition of the stored object's content.
*/
class StoredObjectVoter extends Voter
{
protected function supports($attribute, $subject): bool
{
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
&& $subject instanceof StoredObject;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var StoredObject $subject */
if (
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
) {
return false;
}
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
return false;
}
$askedRole = StoredObjectRoleEnum::from($attribute);
$tokenRoleAuthorization =
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
return match ($askedRole) {
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
};
}
}

View File

@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Extract the JWT Token from the segment of the dav endpoints.
*
* A segment is a separation inside the string, using the character "/".
*
* For recognizing the JWT, the first segment must be "dav", and the second one must be
* the JWT endpoint.
*/
final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function extract(Request $request): false|string
{
$uri = $request->getRequestUri();
$segments = array_values(
array_filter(
explode('/', $uri),
fn ($item) => '' !== trim($item)
)
);
if (2 > count($segments)) {
$this->logger->info('not enough segment for parsing URL');
return false;
}
if ('dav' !== $segments[0]) {
$this->logger->info('the first segment of the url must be DAV');
return false;
}
return $segments[1];
}
}

View File

@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Events;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Store some data from the JWT's payload inside the token's attributes.
*/
class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface
{
final public const STORED_OBJECT = 'stored_object';
final public const ACTIONS = 'stored_objects_actions';
public static function getSubscribedEvents(): array
{
return [
Events::JWT_AUTHENTICATED => ['onJWTAuthenticated', 0],
];
}
public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void
{
$payload = $event->getPayload();
if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) {
return;
}
$token = $event->getToken();
$token->setAttribute(self::ACTIONS, match ($payload['e']) {
0 => StoredObjectRoleEnum::SEE,
1 => StoredObjectRoleEnum::EDIT,
default => throw new \UnexpectedValueException('unsupported value for e parameter')
});
$token->setAttribute(self::STORED_OBJECT, $payload['so']);
}
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\Security\Core\Security;
/**
* Provide a JWT Token which will be valid for viewing or editing a document.
*/
final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface
{
public function __construct(
private JWTTokenManagerInterface $JWTTokenManager,
private Security $security,
) {
}
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string
{
return $this->JWTTokenManager->createFromPayload($this->security->getUser(), [
'dav' => 1,
'e' => match ($roleEnum) {
StoredObjectRoleEnum::SEE => 0,
StoredObjectRoleEnum::EDIT => 1,
},
'so' => $storedObject->getUuid(),
]);
}
public function getTokenExpiration(string $tokenString): \DateTimeImmutable
{
$jwt = $this->JWTTokenManager->parse($tokenString);
return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']);
}
}

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
/**
* Provide a JWT Token which will be valid for viewing or editing a document.
*/
interface JWTDavTokenProviderInterface
{
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string;
public function getTokenExpiration(string $tokenString): \DateTimeImmutable;
}

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints.
*/
class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator
{
public function __construct(
JWTTokenManagerInterface $jwtManager,
EventDispatcherInterface $dispatcher,
TokenExtractorInterface $tokenExtractor,
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
TokenStorageInterface $preAuthenticationTokenStorage,
TranslatorInterface $translator = null,
) {
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
}
protected function getTokenExtractor()
{
return $this->davOnUrlTokenExtractor;
}
}

View File

@@ -57,62 +57,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $this->extractLastModifiedFromResponse($response);
}
public function getContentLength(StoredObject $document): int
{
if ([] === $document->getKeyInfos()) {
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractContentLengthFromResponse($response);
}
return strlen($this->read($document));
}
public function etag(StoredObject $document): string
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractEtagFromResponse($response, $document);
}
public function read(StoredObject $document): string
{
$response = $this->getResponseFromCache($document);
@@ -160,6 +104,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
)
: $clearContent;
$headers = [];
if (null !== $document->getDeleteAt()) {
$headers['X-Delete-At'] = $document->getDeleteAt()->getTimestamp();
}
try {
$response = $this
->client
@@ -174,6 +124,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->url,
[
'body' => $encryptedContent,
'headers' => $headers,
]
);
} catch (TransportExceptionInterface $exception) {
@@ -185,6 +136,11 @@ final class StoredObjectManager implements StoredObjectManagerInterface
}
}
public function clearCache(): void
{
$this->inMemory = [];
}
private function extractLastModifiedFromResponse(ResponseInterface $response): \DateTimeImmutable
{
$lastModifiedString = (($response->getHeaders()['last-modified'] ?? [])[0] ?? '');
@@ -202,22 +158,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $date;
}
private function extractContentLengthFromResponse(ResponseInterface $response): int
{
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
}
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
{
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
if ('' === $etag) {
return null;
}
return $etag;
}
private function fillCache(StoredObject $document): void
{
try {

View File

@@ -12,19 +12,20 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
interface StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface;
public function getContentLength(StoredObject $document): int;
/**
* Get the content of a StoredObject.
*
* @param StoredObject $document the document
*
* @return string the retrieved content in clear
*
* @throws StoredObjectManagerException if unable to read or decrypt the content
*/
public function read(StoredObject $document): string;
@@ -33,8 +34,10 @@ interface StoredObjectManagerInterface
*
* @param StoredObject $document the document
* @param $clearContent The content to store in clear
*
* @throws StoredObjectManagerException
*/
public function write(StoredObject $document, string $clearContent): void;
public function etag(StoredObject $document): string;
public function clearCache(): void;
}

View File

@@ -13,9 +13,6 @@ namespace Chill\DocStoreBundle\Templating;
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment;
@@ -123,12 +120,8 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
public function __construct(
private DiscoveryInterface $discovery,
private NormalizerInterface $normalizer,
private JWTDavTokenProviderInterface $davTokenProvider,
private UrlGeneratorInterface $urlGenerator,
) {
public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer)
{
}
/**
@@ -139,7 +132,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
*/
public function isEditable(StoredObject $document): bool
{
return in_array($document->getType(), self::SUPPORTED_MIMES, true);
return \in_array($document->getType(), self::SUPPORTED_MIMES, true);
}
/**
@@ -151,26 +144,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
*/
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
{
$accessToken = $this->davTokenProvider->createToken(
$document,
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
);
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
'document' => $document,
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'title' => $title,
'can_edit' => $canEdit,
'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options],
'dav_link' => $this->urlGenerator->generate(
'chill_docstore_dav_document_get',
[
'uuid' => $document->getUuid(),
'access_token' => $accessToken,
],
UrlGeneratorInterface::ABSOLUTE_URL,
),
'dav_link_expiration' => $this->davTokenProvider->getTokenExpiration($accessToken)->format('U'),
]);
}

View File

@@ -1,410 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\WebdavController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class WebdavControllerTest extends KernelTestCase
{
use ProphecyTrait;
private \Twig\Environment $engine;
protected function setUp(): void
{
self::bootKernel();
$this->engine = self::$container->get(\Twig\Environment::class);
}
private function buildController(): WebdavController
{
$storedObjectManager = new MockedStoredObjectManager();
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class))
->willReturn(true);
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
}
private function buildDocument(): StoredObject
{
$object = (new StoredObject())
->setType('application/vnd.oasis.opendocument.text');
$reflectionObject = new \ReflectionClass($object);
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
$reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
return $object;
}
public function testGet(): void
{
$controller = $this->buildController();
$response = $controller->getDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('abcde', $response->getContent());
self::assertContains('etag', $response->headers->keys());
self::assertStringContainsString('ab56b4', $response->headers->get('etag'));
}
public function testOptionsOnDocument(): void
{
$controller = $this->buildController();
$response = $controller->optionsDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('allow', $response->headers->keys());
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
self::assertStringContainsString($method, $response->headers->get('allow'));
}
self::assertContains('dav', $response->headers->keys());
self::assertStringContainsString('1', $response->headers->get('dav'));
}
public function testOptionsOnDirectory(): void
{
$controller = $this->buildController();
$response = $controller->optionsDirectory($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('allow', $response->headers->keys());
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
self::assertStringContainsString($method, $response->headers->get('allow'));
}
self::assertContains('dav', $response->headers->keys());
self::assertStringContainsString('1', $response->headers->get('dav'));
}
/**
* @dataProvider generateDataPropfindDocument
*/
public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
{
$controller = $this->buildController();
$request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND');
$response = $controller->propfindDocument($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys());
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
}
/**
* @dataProvider generateDataPropfindDirectory
*/
public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
{
$controller = $this->buildController();
$request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND');
$request->headers->add(['Depth' => '0']);
$response = $controller->propfindDirectory($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys());
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
}
public function testHeadDocument(): void
{
$controller = $this->buildController();
$response = $controller->headDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('content-length', $response->headers->keys());
self::assertContains('content-type', $response->headers->keys());
self::assertContains('etag', $response->headers->keys());
self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag'));
self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type'));
self::assertEquals(5, $response->headers->get('content-length'));
}
public static function generateDataPropfindDocument(): iterable
{
$content =
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML;
$response =
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" >
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:resourcetype/>
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML;
yield [$content, 207, $response, 'get IsReadOnly and contenttype from server'];
$content =
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML;
$response =
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML;
yield [$content, 207, $response, 'get property IsReadOnly'];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:BaseURI/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'Test requesting an unknow property',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<getlastmodified xmlns="DAV:"/>
</prop>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test getting the last modified date',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<propname/>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:resourcetype/>
<d:creationdate/>
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
<!-- <d:getcontentlength/> -->
<d:getcontentlength>5</d:getcontentlength>
<!-- <d:getlastmodified/> -->
<d:getetag>"ab56b4d92b40713acc5af89985d4b786"</d:getetag>
<!--
<d:supportedlock/>
<d:lockdiscovery/>
-->
<!-- <d:getcontenttype/> -->
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test finding all properties',
];
}
public static function generateDataPropfindDirectory(): iterable
{
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
<!--
<d:supportedlock>
<d:lockentry>
<d:lockscope><d:exclusive/></d:lockscope>
<d:locktype><d:write/></d:locktype>
</d:lockentry>
<d:lockentry>
<d:lockscope><d:shared/></d:lockscope>
<d:locktype><d:write/></d:locktype>
</d:lockentry>
</d:supportedlock>
-->
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test resourceType and IsReadOnly ',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><CreatableContentsInfo xmlns="http://ucb.openoffice.org/dav/props/"/></prop></propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" >
<ns0:CreatableContentsInfo/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test creatableContentsInfo',
];
}
}
class MockedStoredObjectManager implements StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface
{
return new \DateTimeImmutable('2023-09-13T14:15');
}
public function getContentLength(StoredObject $document): int
{
return 5;
}
public function read(StoredObject $document): string
{
return 'abcde';
}
public function write(StoredObject $document, string $clearContent): void
{
}
public function etag(StoredObject $document): string
{
return 'ab56b4d92b40713acc5af89985d4b786';
}
}

View File

@@ -1,134 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Dav\Request;
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class PropfindRequestAnalyzerTest extends TestCase
{
/**
* @dataProvider provideRequestedProperties
*/
public function testGetRequestedProperties(string $xml, array $expected): void
{
$analyzer = new PropfindRequestAnalyzer();
$request = new \DOMDocument();
$request->loadXML($xml);
$actual = $analyzer->getRequestedProperties($request);
foreach ($expected as $key => $value) {
if ('unknowns' === $key) {
continue;
}
self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values");
self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}");
}
if (array_key_exists('unknowns', $expected)) {
self::assertEquals(count($expected['unknowns']), count($actual['unknowns']));
self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']);
}
}
public function provideRequestedProperties(): iterable
{
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML,
[
'resourceType' => false,
'contentType' => false,
'lastModified' => false,
'creationDate' => false,
'contentLength' => false,
'etag' => false,
'supportedLock' => false,
'unknowns' => [
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'],
],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<propname/>
</propfind>
XML,
[
'resourceType' => true,
'contentType' => true,
'lastModified' => true,
'creationDate' => true,
'contentLength' => true,
'etag' => true,
'supportedLock' => true,
'unknowns' => [],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<getlastmodified xmlns="DAV:"/>
</prop>
</propfind>
XML,
[
'resourceType' => false,
'contentType' => false,
'lastModified' => true,
'creationDate' => false,
'contentLength' => false,
'etag' => false,
'supportedLock' => false,
'unknowns' => [],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML,
[
'resourceType' => true,
'contentType' => true,
'lastModified' => false,
'creationDate' => false,
'contentLength' => false,
'etag' => false,
'supportedLock' => false,
'unknowns' => [
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'],
],
],
];
}
}

View File

@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataVote
*/
public function testVote(TokenInterface $token, null|object $subject, string $attribute, mixed $expected): void
{
$voter = new StoredObjectVoter();
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
}
public function provideDataVote(): iterable
{
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()),
new \stdClass(),
'SOMETHING',
VoterInterface::ACCESS_ABSTAIN,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
'SOMETHING',
VoterInterface::ACCESS_ABSTAIN,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
}
private function buildToken(StoredObjectRoleEnum $storedObjectRoleEnum = null, StoredObject $storedObject = null): TokenInterface
{
$token = $this->prophesize(TokenInterface::class);
if (null !== $storedObjectRoleEnum) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum);
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException());
}
if (null !== $storedObject) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString());
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException());
}
return $token->reveal();
}
}

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
*
* @coversNothing
*/
class DavOnUrlTokenExtractorTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataUri
*/
public function testExtract(string $uri, false|string $expected): void
{
$request = $this->prophesize(Request::class);
$request->getRequestUri()->willReturn($uri);
$extractor = new DavOnUrlTokenExtractor(new NullLogger());
$actual = $extractor->extract($request->reveal());
self::assertEquals($expected, $actual);
}
/**
* @phpstan-pure
*/
public static function provideDataUri(): iterable
{
yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789'];
yield ['/dav/123456789', '123456789'];
yield ['/not-dav/123456978', false];
yield ['/dav', false];
yield ['/', false];
}
}

View File

@@ -9,7 +9,7 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests;
namespace Chill\DocStoreBundle\Tests\Service;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
@@ -117,6 +117,41 @@ final class StoredObjectManagerTest extends TestCase
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
}
public function testWriteWithDeleteAt()
{
$storedObject = new StoredObject();
$expectedRequests = [
function ($method, $url, $options): MockResponse {
self::assertEquals('PUT', $method);
self::assertArrayHasKey('headers', $options);
self::assertIsArray($options['headers']);
self::assertCount(0, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At')));
return new MockResponse('', ['http_code' => 201]);
},
function ($method, $url, $options): MockResponse {
self::assertEquals('PUT', $method);
self::assertArrayHasKey('headers', $options);
self::assertIsArray($options['headers']);
self::assertCount(1, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At')));
self::assertContains('X-Delete-At: 1711014260', $options['headers']);
return new MockResponse('', ['http_code' => 201]);
},
];
$client = new MockHttpClient($expectedRequests);
$manager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject));
$manager->write($storedObject, 'ok');
// with a deletedAt date
$storedObject->setDeleteAt(\DateTimeImmutable::createFromFormat('U', '1711014260'));
$manager->write($storedObject, 'ok');
}
private function getHttpClient(string $encodedContent): HttpClientInterface
{
$callback = static function ($method, $url, $options) use ($encodedContent) {

View File

@@ -34,11 +34,6 @@ services:
autoconfigure: true
autowire: true
Chill\DocStoreBundle\Security\:
resource: './../Security'
autoconfigure: true
autowire: true
Chill\DocStoreBundle\Serializer\Normalizer\:
autowire: true
resource: '../Serializer/Normalizer/'

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240322100107 extends AbstractMigration
{
public function getDescription(): string
{
return 'StoredObject: add deleteAt and generationErrors columns';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object ADD deleteAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD generationErrors TEXT DEFAULT \'\' NOT NULL');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object.deleteAt IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object DROP deleteAt');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP generationErrors');
}
}

View File

@@ -17,10 +17,11 @@ use Chill\EventBundle\Form\EventType;
use Chill\EventBundle\Form\Type\PickEventType;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\Type\PickPersonType;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Csv;
@@ -37,53 +38,26 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class EventController.
*/
class EventController extends AbstractController
final class EventController extends AbstractController
{
/**
* @var AuthorizationHelper
*/
protected $authorizationHelper;
/**
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* @var FormFactoryInterface
*/
protected $formFactoryInterface;
/**
* @var PaginatorFactory
*/
protected $paginator;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* EventController constructor.
*/
public function __construct(
EventDispatcherInterface $eventDispatcher,
AuthorizationHelper $authorizationHelper,
FormFactoryInterface $formFactoryInterface,
TranslatorInterface $translator,
PaginatorFactory $paginator
private readonly EventDispatcherInterface $eventDispatcher,
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly FormFactoryInterface $formFactoryInterface,
private readonly TranslatorInterface $translator,
private readonly PaginatorFactory $paginator,
private readonly Security $security,
) {
$this->eventDispatcher = $eventDispatcher;
$this->authorizationHelper = $authorizationHelper;
$this->formFactoryInterface = $formFactoryInterface;
$this->translator = $translator;
$this->paginator = $paginator;
}
/**
@@ -181,7 +155,7 @@ class EventController extends AbstractController
$this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person);
$reachablesCircles = $this->authorizationHelper->getReachableCircles(
$reachablesCircles = $this->authorizationHelper->getReachableScopes(
$this->getUser(),
EventVoter::SEE,
$person->getCenter()
@@ -233,6 +207,12 @@ class EventController extends AbstractController
*/
public function newAction(?Center $center, Request $request)
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('not a regular user. Maybe an administrator ?');
}
if (null === $center) {
$center_id = $request->query->get('center_id');
$center = $this->getDoctrine()->getRepository(Center::class)->find($center_id);
@@ -240,6 +220,7 @@ class EventController extends AbstractController
$entity = new Event();
$entity->setCenter($center);
$entity->setLocation($user->getCurrentLocation());
$form = $this->createCreateForm($entity);
$form->handleRequest($request);
@@ -282,7 +263,7 @@ class EventController extends AbstractController
}
$form = $this->formFactoryInterface
->createNamedBuilder(null, FormType::class, null, [
->createNamedBuilder('', FormType::class, null, [
'csrf_protection' => false,
])
->setMethod('GET')
@@ -323,7 +304,7 @@ class EventController extends AbstractController
}
$this->denyAccessUnlessGranted(
'CHILL_EVENT_SEE_DETAILS',
EventVoter::SEE_DETAILS,
$event,
'You are not allowed to see details on this event'
);
@@ -367,7 +348,7 @@ class EventController extends AbstractController
$this->addFlash('success', $this->translator
->trans('The event was updated'));
return $this->redirectToRoute('chill_event__event_edit', ['event_id' => $event_id]);
return $this->redirectToRoute('chill_event__event_show', ['event_id' => $event_id]);
}
return $this->render('@ChillEvent/Event/edit.html.twig', [
@@ -385,7 +366,7 @@ class EventController extends AbstractController
{
/** @var \Symfony\Component\Form\FormBuilderInterface $builder */
$builder = $this
->get('form.factory')
->formFactoryInterface
->createNamedBuilder(
null,
FormType::class,
@@ -430,11 +411,9 @@ class EventController extends AbstractController
*/
protected function createAddParticipationByPersonForm(Event $event)
{
/** @var \Symfony\Component\Form\FormBuilderInterface $builder */
$builder = $this
->get('form.factory')
$builder = $this->formFactoryInterface
->createNamedBuilder(
null,
'',
FormType::class,
null,
[
@@ -444,22 +423,17 @@ class EventController extends AbstractController
]
);
$builder->add('person_id', PickPersonType::class, [
'role' => 'CHILL_EVENT_CREATE',
'centers' => $event->getCenter(),
$builder->add('person_id', PickPersonDynamicType::class, [
'as_id' => true,
'multiple' => false,
'submit_on_adding_new_entity' => true,
'label' => 'Add a participation',
]);
$builder->add('event_id', HiddenType::class, [
'data' => $event->getId(),
]);
$builder->add(
'submit',
SubmitType::class,
[
'label' => 'Add a participation',
]
);
dump($event->getId());
return $builder->getForm();
}
@@ -469,7 +443,7 @@ class EventController extends AbstractController
*/
protected function createExportByFormatForm()
{
$builder = $this->createFormBuilder()
$builder = $this->createFormBuilder(['format' => 'xlsx'])
->add('format', ChoiceType::class, [
'choices' => [
'xlsx' => 'xlsx',

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Controller;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\EventType;
use Chill\EventBundle\Repository\EventACLAwareRepositoryInterface;
use Chill\EventBundle\Repository\EventTypeRepository;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
final readonly class EventListController
{
public function __construct(
private Environment $environment,
private EventACLAwareRepositoryInterface $eventACLAwareRepository,
private EventTypeRepository $eventTypeRepository,
private FilterOrderHelperFactory $filterOrderHelperFactory,
private FormFactoryInterface $formFactory,
private PaginatorFactoryInterface $paginatorFactory,
private TranslatableStringHelperInterface $translatableStringHelper,
private UrlGeneratorInterface $urlGenerator,
) {
}
/**
* @Route("{_locale}/event/event/list", name="chill_event_event_list")
*/
public function __invoke(): Response
{
$filter = $this->buildFilterOrder();
$filterData = [
'q' => (string) $filter->getQueryString(),
'dates' => $filter->getDateRangeData('dates'),
'event_types' => $filter->getEntityChoiceData('event_types'),
];
$total = $this->eventACLAwareRepository->countAllViewable($filterData);
$pagination = $this->paginatorFactory->create($total);
$events = $this->eventACLAwareRepository->findAllViewable($filterData, $pagination->getCurrentPageFirstItemNumber(), $pagination->getItemsPerPage());
$eventForms = [];
foreach ($events as $event) {
$eventForms[$event->getId()] = $this->createAddParticipationByPersonForm($event)->createView();
}
return new Response($this->environment->render(
'@ChillEvent/Event/page_list.html.twig',
[
'events' => $events,
'pagination' => $pagination,
'eventForms' => $eventForms,
'filter' => $filter,
]
));
}
private function buildFilterOrder(): FilterOrderHelper
{
$types = $this->eventTypeRepository->findAllActive();
$builder = $this->filterOrderHelperFactory->create(__METHOD__);
$builder
->addDateRange('dates', 'event.filter.event_dates')
->addSearchBox(['name'])
->addEntityChoice('event_types', 'event.filter.event_types', EventType::class, $types, [
'choice_label' => fn (EventType $e) => $this->translatableStringHelper->localize($e->getName()),
]);
return $builder->build();
}
private function createAddParticipationByPersonForm(Event $event): FormInterface
{
$builder = $this->formFactory
->createNamedBuilder(
'',
FormType::class,
null,
[
'method' => 'GET',
'action' => $this->urlGenerator->generate('chill_event_participation_new'),
'csrf_protection' => false,
]
);
$builder->add('person_id', PickPersonDynamicType::class, [
'as_id' => true,
'multiple' => false,
'submit_on_adding_new_entity' => true,
'label' => 'Add a participation',
]);
$builder->add('event_id', HiddenType::class, [
'data' => $event->getId(),
]);
return $builder->getForm();
}
}

View File

@@ -14,7 +14,10 @@ namespace Chill\EventBundle\Controller;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\Participation;
use Chill\EventBundle\Form\ParticipationType;
use Chill\EventBundle\Repository\EventRepository;
use Chill\EventBundle\Security\Authorization\ParticipationVoter;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\Common\Collections\Collection;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -28,13 +31,17 @@ use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class ParticipationController.
*/
class ParticipationController extends AbstractController
final class ParticipationController extends AbstractController
{
/**
* ParticipationController constructor.
*/
public function __construct(private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator)
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly TranslatorInterface $translator,
private readonly EventRepository $eventRepository,
private readonly PersonRepository $personRepository,
) {
}
/**
@@ -230,6 +237,7 @@ class ParticipationController extends AbstractController
return $this->render('@ChillEvent/Participation/new.html.twig', [
'form' => $form->createView(),
'participation' => $participation,
'ignored_participations' => [],
]);
}
@@ -539,7 +547,7 @@ class ParticipationController extends AbstractController
* If the request is multiple, the $participation object is cloned.
* Limitations: the $participation should not be persisted.
*
* @return Participation|Participation[] return one single participation if $multiple == false
* @return Participation|list<Participation> return one single participation if $multiple == false
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException if the event/person is not found
* @throws \Symfony\Component\Security\Core\Exception\AccessDeniedException if the user does not have access to event/person
@@ -556,30 +564,25 @@ class ParticipationController extends AbstractController
}
$event_id = $request->query->getInt('event_id', 0); // sf4 check:
// prevent error: `Argument 2 passed to ::getInt() must be of the type int, null given`
if (null !== $event_id) {
$event = $em->getRepository(Event::class)
->find($event_id);
$event = $this->eventRepository->find($event_id);
if (null === $event) {
throw $this->createNotFoundException('The event with id '.$event_id.' is not found');
}
$this->denyAccessUnlessGranted(
'CHILL_EVENT_SEE',
$event,
'The user is not allowed to see the event'
);
$participation->setEvent($event);
if (null === $event) {
throw $this->createNotFoundException('The event with id '.$event_id.' is not found');
}
$this->denyAccessUnlessGranted(
'CHILL_EVENT_SEE',
$event,
'The user is not allowed to see the event'
);
$participation->setEvent($event);
// this script should be able to handle multiple, so we translate
// single person_id in an array
$persons_ids = $request->query->has('person_id') ?
[$request->query->getInt('person_id', 0)] // sf4 check:
// prevent error: `Argument 2 passed to ::getInt() must be of the type int, null given`
[$request->query->get('person_id', 0)]
: explode(',', (string) $request->query->get('persons_ids'));
$participations = [];
@@ -588,15 +591,14 @@ class ParticipationController extends AbstractController
$participation = \count($persons_ids) > 1 ? clone $participation : $participation;
if (null !== $person_id) {
$person = $em->getRepository(\Chill\PersonBundle\Entity\Person::class)
->find($person_id);
$person = $this->personRepository->find($person_id);
if (null === $person) {
throw $this->createNotFoundException('The person with id '.$person_id.' is not found');
}
$this->denyAccessUnlessGranted(
'CHILL_PERSON_SEE',
PersonVoter::SEE,
$person,
'The user is not allowed to see the person'
);

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\EventBundle\DependencyInjection;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\EventBundle\Security\Authorization\ParticipationVoter;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
@@ -33,10 +34,8 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
$loader->load('services/authorization.yaml');
$loader->load('services/controller.yaml');
$loader->load('services/fixtures.yaml');
$loader->load('services/forms.yaml');
$loader->load('services/menu.yaml');
$loader->load('services/repositories.yaml');
$loader->load('services/search.yaml');
$loader->load('services/timeline.yaml');
@@ -61,6 +60,8 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface
EventVoter::SEE_DETAILS => [EventVoter::SEE],
EventVoter::UPDATE => [EventVoter::SEE_DETAILS],
EventVoter::CREATE => [EventVoter::SEE_DETAILS],
ParticipationVoter::SEE_DETAILS => [ParticipationVoter::SEE],
ParticipationVoter::UPDATE => [ParticipationVoter::SEE_DETAILS],
],
]);
}

View File

@@ -11,15 +11,23 @@ declare(strict_types=1);
namespace Chill\EventBundle\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\HasCenterInterface;
use Chill\MainBundle\Entity\HasScopeInterface;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Class Event.
@@ -30,10 +38,15 @@ use Doctrine\ORM\Mapping as ORM;
*
* @ORM\HasLifecycleCallbacks
*/
class Event implements HasCenterInterface, HasScopeInterface
class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")A
*
* @Assert\NotNull()
*/
private ?Center $center = null;
@@ -63,6 +76,8 @@ class Event implements HasCenterInterface, HasScopeInterface
/**
* @ORM\Column(type="string", length=150)
*
* @Assert\NotBlank()
*/
private ?string $name = null;
@@ -77,15 +92,45 @@ class Event implements HasCenterInterface, HasScopeInterface
/**
* @ORM\ManyToOne(targetEntity="Chill\EventBundle\Entity\EventType")
*
* @Assert\NotNull()
*/
private ?EventType $type = null;
/**
* @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_")
*/
private CommentEmbeddable $comment;
/**
* @ORM\ManyToOne(targetEntity=Location::class)
*
* @ORM\JoinColumn(nullable=true)
*/
private ?Location $location = null;
/**
* @var Collection<StoredObject>
*
* @ORM\ManyToMany(targetEntity=StoredObject::class, cascade={"persist","refresh"})
*
* @ORM\JoinTable("chill_event_event_documents")
*/
private Collection $documents;
/**
* @ORM\Column(type="decimal", precision=10, scale=4, nullable=true, options={"default": null})
*/
private string $organizationCost = '0.0';
/**
* Event constructor.
*/
public function __construct()
{
$this->participations = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->comment = new CommentEmbeddable();
}
/**
@@ -100,6 +145,22 @@ class Event implements HasCenterInterface, HasScopeInterface
return $this;
}
public function addDocument(StoredObject $storedObject): self
{
if ($this->documents->contains($storedObject)) {
$this->documents[] = $storedObject;
}
return $this;
}
public function removeDocument(StoredObject $storedObject): self
{
$this->documents->removeElement($storedObject);
return $this;
}
/**
* @return Center
*/
@@ -136,7 +197,7 @@ class Event implements HasCenterInterface, HasScopeInterface
return $this->id;
}
public function getModerator(): User|null
public function getModerator(): ?User
{
return $this->moderator;
}
@@ -259,4 +320,44 @@ class Event implements HasCenterInterface, HasScopeInterface
return $this;
}
public function getComment(): CommentEmbeddable
{
return $this->comment;
}
public function setComment(CommentEmbeddable $comment): void
{
$this->comment = $comment;
}
public function getLocation(): ?Location
{
return $this->location;
}
public function setLocation(?Location $location): void
{
$this->location = $location;
}
public function getDocuments(): Collection
{
return $this->documents;
}
public function setDocuments(Collection $documents): void
{
$this->documents = $documents;
}
public function getOrganizationCost(): string
{
return $this->organizationCost;
}
public function setOrganizationCost(string $organizationCost): void
{
$this->organizationCost = $organizationCost;
}
}

View File

@@ -11,13 +11,17 @@ declare(strict_types=1);
namespace Chill\EventBundle\Entity;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\HasCenterInterface;
use Chill\MainBundle\Entity\HasScopeInterface;
use Chill\MainBundle\Entity\Scope;
use Chill\PersonBundle\Entity\Person;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
@@ -26,12 +30,20 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* @ORM\Entity(
* repositoryClass="Chill\EventBundle\Repository\ParticipationRepository")
*
* @ORM\Table(name="chill_event_participation")
* @ORM\Table(name="chill_event_participation", uniqueConstraints={
*
* @ORM\UniqueConstraint(name="chill_event_participation_event_person_unique_idx", columns={"event_id", "person_id"})
* })
*
* @ORM\HasLifecycleCallbacks
*
* @UniqueEntity({"event", "person"}, message="event.validation.person_already_participate_to_event")
*/
class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterface
class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterface, TrackUpdateInterface, TrackCreationInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\ManyToOne(
* targetEntity="Chill\EventBundle\Entity\Event",
@@ -48,13 +60,10 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
private ?int $id = null;
/**
* @ORM\Column(type="datetime")
*/
private ?\DateTime $lastUpdate = null;
/**
* @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\Person")
*
* @Assert\NotNull()
*/
private ?Person $person = null;
@@ -65,12 +74,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* @ORM\ManyToOne(targetEntity="Chill\EventBundle\Entity\Status")
*
* @Assert\NotNull()
*/
private ?Status $status = null;
/**
* @return Center
*/
public function getCenter()
{
if (null === $this->getEvent()) {
@@ -83,17 +91,15 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get event.
*/
public function getEvent(): Event|null
public function getEvent(): ?Event
{
return $this->event;
}
/**
* Get id.
*
* @return int
*/
public function getId()
public function getId(): int
{
return $this->id;
}
@@ -101,11 +107,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get lastUpdate.
*
* @return \DateTime
* @return \DateTimeInterface|null
*/
public function getLastUpdate()
{
return $this->lastUpdate;
return $this->getUpdatedAt();
}
/**
@@ -121,7 +127,7 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get role.
*/
public function getRole(): Role|null
public function getRole(): ?Role
{
return $this->role;
}
@@ -141,7 +147,7 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get status.
*/
public function getStatus(): Status|null
public function getStatus(): ?Status
{
return $this->status;
}
@@ -235,10 +241,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setEvent(?Event $event = null)
{
if ($this->event !== $event) {
$this->update();
}
$this->event = $event;
return $this;
@@ -251,10 +253,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setPerson(?Person $person = null)
{
if ($person !== $this->person) {
$this->update();
}
$this->person = $person;
return $this;
@@ -267,9 +265,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setRole(?Role $role = null)
{
if ($role !== $this->role) {
$this->update();
}
$this->role = $role;
return $this;
@@ -282,10 +277,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setStatus(?Status $status = null)
{
if ($this->status !== $status) {
$this->update();
}
$this->status = $status;
return $this;
@@ -295,11 +286,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
* Set lastUpdate.
*
* @return Participation
*
* @deprecated
*/
protected function update()
{
$this->lastUpdate = new \DateTime('now');
return $this;
}
}

View File

@@ -11,12 +11,18 @@ declare(strict_types=1);
namespace Chill\EventBundle\Form;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\StoredObjectType;
use Chill\EventBundle\Form\Type\PickEventTypeType;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillDateTimeType;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\PickUserLocationType;
use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Form\Type\UserPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -47,6 +53,28 @@ class EventType extends AbstractType
'class' => '',
],
'required' => false,
])
->add('location', PickUserLocationType::class, [
'label' => 'event.fields.location',
])
->add('comment', CommentType::class, [
'label' => 'Comment',
'required' => false,
])
->add('documents', ChillCollectionType::class, [
'entry_type' => StoredObjectType::class,
'entry_options' => [
'has_title' => true,
],
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => fn (StoredObject $storedObject): bool => '' === $storedObject->getFilename(),
'button_remove_label' => 'event.form.remove_document',
'button_add_label' => 'event.form.add_document',
])
->add('organizationCost', MoneyType::class, [
'label' => 'event.fields.organizationCost',
'help' => 'event.form.organisationCost_help',
]);
}

View File

@@ -114,7 +114,7 @@ final class PickEventType extends AbstractType
} else {
$centers = $this->authorizationHelper->getReachableCenters(
$user,
(string) $options['role']->getRole()
$options['role']
);
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Menu;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class SectionMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(
private Security $security,
private TranslatorInterface $translator,
) {
}
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
if ($this->security->isGranted(EventVoter::SEE)) {
$menu->addChild(
$this->translator->trans('Events'),
[
'route' => 'chill_event_event_list',
]
)->setExtras([
'order' => 250,
]);
}
}
public static function getMenuIds(): array
{
return ['section'];
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Repository;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\Participation;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;
final readonly class EventACLAwareRepository implements EventACLAwareRepositoryInterface
{
public function __construct(
private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser,
private EntityManagerInterface $entityManager,
private Security $security,
) {
}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countAllViewable(array $filters): int
{
if (!$this->security->getUser() instanceof User) {
return 0;
}
$qb = $this->buildQueryByAllViewable($filters);
$this->addFilters($filters, $qb);
$qb->select('COUNT(event.id)');
return $qb->getQuery()->getSingleScalarResult();
}
public function findAllViewable(array $filters, int $offset = 0, int $limit = 50): array
{
if (!$this->security->getUser() instanceof User) {
return [];
}
$qb = $this->buildQueryByAllViewable($filters)->select('event');
$this->addFilters($filters, $qb);
$qb->setFirstResult($offset)->setMaxResults($limit);
$qb->addOrderBy('event.date', 'DESC');
return $qb->getQuery()->getResult();
}
private function addFilters(array $filters, QueryBuilder $qb): void
{
if (($filters['q'] ?? '') !== '') {
$qb->andWhere('event.name LIKE :content');
$qb->setParameter('content', '%'.$filters['q'].'%');
}
if (array_key_exists('dates', $filters)) {
$dates = $filters['dates'];
if (null !== ($dates['from'] ?? null)) {
$qb->andWhere('event.date >= :date_from');
$qb->setParameter('date_from', $dates['from']);
}
if (null !== ($dates['to'] ?? null)) {
$qb->andWhere('event.date <= :date_to');
$qb->setParameter('date_to', $dates['to']);
}
}
if (0 < count($filters['event_types'] ?? [])) {
$qb->andWhere('event.type IN (:event_types)');
$qb->setParameter('event_types', $filters['event_types']);
}
}
public function buildQueryByAllViewable(array $filters): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder();
$qb->from(Event::class, 'event');
$aclConditions = $qb->expr()->orX();
$i = 0;
foreach ($this->authorizationHelperForCurrentUser->getReachableCenters(EventVoter::SEE) as $center) {
foreach ($this->authorizationHelperForCurrentUser->getReachableScopes(EventVoter::SEE, $center) as $scopes) {
$aclConditions->add(
$qb->expr()->andX(
'event.circle IN (:scopes_'.$i.')',
$qb->expr()->orX(
'event.center = :center_'.$i,
$qb->expr()->exists(
'SELECT 1 FROM '.Participation::class.' participation_'.$i.' JOIN participation_'.$i.'.event event_'.$i.
' JOIN '.Person\PersonCenterHistory::class.' person_center_history_'.$i.
' WITH IDENTITY(person_center_history_'.$i.'.person) = IDENTITY(participation_'.$i.'.person) '.
' AND event_'.$i.'.date <= person_center_history_'.$i.'.startDate AND (person_center_history_'.$i.'.endDate IS NULL OR person_center_history_'.$i.'.endDate > event_'.$i.'.date) '.
' WHERE participation_'.$i.'.event = event'
)
)
)
);
$qb->setParameter('scopes_'.$i, $scopes);
$qb->setParameter('center_'.$i, $center);
++$i;
}
}
if (0 === $i) {
$aclConditions->add('FALSE = TRUE');
}
$qb
->andWhere(
$qb->expr()->orX(
'event.createdBy = :user',
$aclConditions
)
);
$qb->setParameter('user', $this->security->getUser());
return $qb;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Repository;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\EventType;
interface EventACLAwareRepositoryInterface
{
/**
* @param array{q?: string, dates?: array{from?: \DateTimeImmutable|null, to?: \DateTimeImmutable|null}, event_types?: list<EventType>} $filters
*/
public function countAllViewable(array $filters): int;
/**
* @param array{q?: string, dates?: array{from?: \DateTimeImmutable|null, to?: \DateTimeImmutable|null}, event_types?: list<EventType>} $filters
*
* @return list<Event>
*/
public function findAllViewable(array $filters, int $offset = 0, int $limit = 50): array;
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Repository;
use Chill\EventBundle\Entity\EventType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends ServiceEntityRepository<EventType>
*/
final class EventTypeRepository extends ServiceEntityRepository
{
public function __construct(
ManagerRegistry $registry,
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator
) {
parent::__construct($registry, EventType::class);
}
/**
* @return list<EventType>
*/
public function findAllActive(): array
{
$dql = 'SELECT et FROM '.EventType::class.' et WHERE et.active = TRUE ORDER BY JSON_EXTRACT(et.name, :lang)';
return $this->entityManager->createQuery($dql)
->setParameter('lang', $this->translator->getLocale())
->getResult();
}
}

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