mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-10 16:55:00 +00:00
Compare commits
189 Commits
fix-compil
...
2.22.0
Author | SHA1 | Date | |
---|---|---|---|
145419a76b | |||
b1ba5cc608 | |||
87a6757e5e | |||
47f4cfddbb
|
|||
e95f9e9846 | |||
1f4bef754d
|
|||
19e34d5dc0
|
|||
fab00f679c
|
|||
791b3776c5
|
|||
6bd38f1a58
|
|||
68d21c9267
|
|||
e7ca89e0c1
|
|||
fc8bc33ba9
|
|||
cbd9489810
|
|||
90b615c5b2
|
|||
5ca222b501 | |||
3e4495dd6e
|
|||
bca0d04201
|
|||
f66ac50571 | |||
b454774836
|
|||
008f344e49
|
|||
90bfd87ec6
|
|||
cc0030c1cd
|
|||
d60ba3ecb2
|
|||
cd5001ac74 | |||
98f47ac512
|
|||
31b541d12f
|
|||
72045ce082
|
|||
0bfb3de465
|
|||
9ec4c77fb7
|
|||
77c53972c8
|
|||
350d991a85
|
|||
0ce9cdd07a
|
|||
1993fac1c4
|
|||
83883567a2
|
|||
29d57934a1
|
|||
f43d79c940
|
|||
be730679c8
|
|||
f62f1891d8
|
|||
ebb856fe85
|
|||
61877e0157
|
|||
4c3f082163
|
|||
35109133f6
|
|||
a220dad83b
|
|||
9eb571549b
|
|||
db8257d230 | |||
bce93efe83 | |||
06401af801 | |||
ea1d4c48f2
|
|||
|
33cba27dd4 | ||
27b0ec0ae7 | |||
9f141468c7
|
|||
56d173046d
|
|||
059e4a0acd
|
|||
111a21fcec
|
|||
775535e683
|
|||
47a928a6cd
|
|||
0dd58cebec
|
|||
4cff706306
|
|||
fca929f56f
|
|||
8d44bb2c32
|
|||
a57e6c0cc9
|
|||
3fe870ba71
|
|||
6f6683f549
|
|||
146e0090fb
|
|||
20291026fb
|
|||
85d6765178 | |||
30955752c3 | |||
f7f7e1cb1d | |||
651c455bdf | |||
d50d067bf7 | |||
46c647cbb7 | |||
a7ec7c9f37 | |||
da83b1e98c | |||
536c2622c7 | |||
cb70c322a4 | |||
89c231de41
|
|||
c773f9c6db
|
|||
4c05d1e026 | |||
3e2ff463bc
|
|||
59005e83c4 | |||
|
ab6feef371 | ||
8516e89c9c | |||
c9e13be736 | |||
b9b342fe44 | |||
31f29f0bc5 | |||
0bc9fff825 | |||
25f93e8a89 | |||
4e0d8e4def | |||
1ecc825945 | |||
addc623add | |||
1b96deb4ee | |||
f510acd170 | |||
835409cb94 | |||
2121b3ef28 | |||
6c9101c167 | |||
b46883fe36 | |||
8d58805abd | |||
c3a799cb7d | |||
bc683b28d6 | |||
d91b1a70bf | |||
853014d8d2 | |||
ad6154a1e4 | |||
50c04382ef | |||
d62e9ce269 | |||
2149ef1cb4 | |||
d15fbadd27 | |||
fbbf421d8b | |||
fe695f1a14 | |||
d0ec6f9819 | |||
4091efc72e | |||
b577bd7123 | |||
dbb9feb129 | |||
e8b95f8491 | |||
8de37a9ef3 | |||
8fd6986c47
|
|||
807f1b4aa1
|
|||
f3002631ea
|
|||
9e667d4de4 | |||
fc88a5f40d | |||
9ff7aef3fc | |||
4f08019618
|
|||
2a58330832 | |||
a2cea3df02
|
|||
9ac43ecf5b | |||
f78f5e8419
|
|||
ccf3324bc2
|
|||
dfe780f0f5 | |||
dd056efa0d | |||
18c0b6a47f | |||
df0afcd228
|
|||
d66933c8b5 | |||
0ff51b0a5c | |||
d7f4895248
|
|||
7aee722957 | |||
5880858191 | |||
96105b101f | |||
d29415317b | |||
2ad3bbe96f | |||
1d636f5e9e
|
|||
f0dbb17172 | |||
f1dbc17dad | |||
09578a775c
|
|||
c888b5b84f | |||
27d76d9579 | |||
5b714f17be | |||
bbb167bb85 | |||
d713087dcb | |||
569aeeef87 | |||
97f2c75de8 | |||
4a2078dc65 | |||
00444e1e56 | |||
0b739fda34 | |||
9b8e143855 | |||
a533ab77ed | |||
087032881b | |||
82667a1c0f | |||
db6408926b | |||
f5c7ab6ef0 | |||
a13ada2937 | |||
3be8a39a1a | |||
d7eb1e01da | |||
bd62202d22 | |||
0e3de2ec8a | |||
aa2a398f9e | |||
33187448a0 | |||
a4482ad28b | |||
8ed5a023e8 | |||
653ac1d62b | |||
499009ac43 | |||
192b161e78 | |||
1b1f355123 | |||
39a863448c | |||
0c1a4a5f59 | |||
6f358ee1a9 | |||
0f36b9349b | |||
d18cc29acf | |||
4220d1a2d3 | |||
1ae27152c2 | |||
b946f8c10a | |||
62d6106801 | |||
89fb87f71f | |||
1337360690 | |||
9324c33caf | |||
c2dd9ef676 | |||
a42d7231d9 | |||
38deaf6f36 | |||
04fc5b6614 | |||
384b2be577 |
3
.changes/v2.16.2.md
Normal file
3
.changes/v2.16.2.md
Normal 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
5
.changes/v2.16.3.md
Normal 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
9
.changes/v2.17.0.md
Normal 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
5
.changes/v2.18.0.md
Normal 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
3
.changes/v2.18.1.md
Normal 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
3
.changes/v2.18.2.md
Normal 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
|
20
.changes/v2.19.0.md
Normal file
20
.changes/v2.19.0.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## v2.19.0 - 2024-05-14
|
||||
### Feature
|
||||
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
||||
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
|
||||
### Fixed
|
||||
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
|
||||
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
|
||||
### UX
|
||||
* Form for document generation moved to the top of document list page
|
||||
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
|
||||
|
||||
|
||||
### Traduction francophone des principaux changements
|
||||
|
||||
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
|
||||
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
|
||||
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
|
||||
- correction du filtre "filtrer par zone géographique"
|
||||
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
|
||||
- module événement: améliorerations graphiques
|
21
.changes/v2.20.0.md
Normal file
21
.changes/v2.20.0.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## v2.20.0 - 2024-06-05
|
||||
### Fixed
|
||||
* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions.
|
||||
* Added translations for choices of durations (> 5 hours)
|
||||
### Feature
|
||||
* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security);
|
||||
|
||||
This endpoint should be added to make the endpoint works properly:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
firewalls:
|
||||
dav:
|
||||
pattern: ^/dav
|
||||
provider: chain_provider
|
||||
stateless: true
|
||||
guard:
|
||||
authenticators:
|
||||
- Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator
|
||||
|
||||
```
|
3
.changes/v2.20.1.md
Normal file
3
.changes/v2.20.1.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v2.20.1 - 2024-06-05
|
||||
### Fixed
|
||||
* Do not allow StoredObjectCreated for edit and convert buttons
|
31
.changes/v2.21.0.md
Normal file
31
.changes/v2.21.0.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## v2.21.0 - 2024-06-18
|
||||
### Feature
|
||||
* Add flash menu buttons in search results, to open directly a new calendar, or a new activity in an accompanying period
|
||||
* ([#122](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/122)) Improve the list of calendar in the search results: make all calendar clicable, and display a list of calendars
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add start date and end date on filters "filter course by referrer job" and "filter course by referrer scope"
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] the aggregator "Group by referrer" now accept a date range.
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's scope"
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's jobs"
|
||||
* ([#168](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/168) In the UX, display user job and service at the time when he performs an action:
|
||||
now, the job and service is shown:
|
||||
* at the activity's date,
|
||||
* at the appointment's date,
|
||||
* when the user is marked as referrer for an accompanying period work,
|
||||
* when the user apply a transition in a workflow,
|
||||
* when the user updates or creates "something" ("created/updated by ... at ..."),
|
||||
* or when he wrote a comment,
|
||||
* …
|
||||
|
||||
### Traduction francophone
|
||||
* Ajout d'un menu "flash" dans les résultats de recherche, pour créer un rendez-vous ou un échange dans un parcours depuis les résultats de recherche;
|
||||
* Améliore la liste des rendez-vous dans les résultats de recherche: les rendez-vous sont cliquables;
|
||||
* [exports] Ajout d'intervalles de dates pour des filtres et regroupements des parcours par référent, métier du référent, service du référent;
|
||||
* Affiche le métier et le service des utilisateurs à la date à laquelle il a exécuté une action. Le métier et le service est affiché:
|
||||
* à la date d'un échange,
|
||||
* au jour d'un rendez-vous,
|
||||
* quand l'utilisateur est devenu référent d'un parcours d'accompagnement,
|
||||
* quand il a appliqué une transition sur un workflow,
|
||||
* quand il a mise à jour ou créé une fiche, dans les mentions "créé / mise à jour par ..., le ...",
|
||||
* quand il a mis à jour un commentaire,
|
||||
* …
|
||||
|
6
.changes/v2.22.0.md
Normal file
6
.changes/v2.22.0.md
Normal file
@@ -0,0 +1,6 @@
|
||||
## v2.22.0 - 2024-06-25
|
||||
### Feature
|
||||
* ([#216](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/216)) [event bundle] exports added for the event module
|
||||
|
||||
### Traduction francophone
|
||||
* Exports sont ajoutés pour la module événement.
|
@@ -23,3 +23,7 @@ max_line_length = 0
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
|
||||
[.rst]
|
||||
ident_size = 3
|
||||
ident_style = space
|
||||
|
||||
|
@@ -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
|
||||
|
120
CHANGELOG.md
120
CHANGELOG.md
@@ -6,6 +6,126 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
|
||||
## v2.22.0 - 2024-06-25
|
||||
### Feature
|
||||
* ([#216](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/216)) [event bundle] exports added for the event module
|
||||
|
||||
### Traduction francophone
|
||||
* Exports sont ajoutés pour la module événement.
|
||||
|
||||
## v2.21.0 - 2024-06-18
|
||||
### Feature
|
||||
* Add flash menu buttons in search results, to open directly a new calendar, or a new activity in an accompanying period
|
||||
* ([#122](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/122)) Improve the list of calendar in the search results: make all calendar clicable, and display a list of calendars
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add start date and end date on filters "filter course by referrer job" and "filter course by referrer scope"
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] the aggregator "Group by referrer" now accept a date range.
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's scope"
|
||||
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's jobs"
|
||||
* ([#168](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/168) In the UX, display user job and service at the time when he performs an action:
|
||||
now, the job and service is shown:
|
||||
* at the activity's date,
|
||||
* at the appointment's date,
|
||||
* when the user is marked as referrer for an accompanying period work,
|
||||
* when the user apply a transition in a workflow,
|
||||
* when the user updates or creates "something" ("created/updated by ... at ..."),
|
||||
* or when he wrote a comment,
|
||||
* …
|
||||
|
||||
### Traduction francophone
|
||||
* Ajout d'un menu "flash" dans les résultats de recherche, pour créer un rendez-vous ou un échange dans un parcours depuis les résultats de recherche;
|
||||
* Améliore la liste des rendez-vous dans les résultats de recherche: les rendez-vous sont cliquables;
|
||||
* [exports] Ajout d'intervalles de dates pour des filtres et regroupements des parcours par référent, métier du référent, service du référent;
|
||||
* Affiche le métier et le service des utilisateurs à la date à laquelle il a exécuté une action. Le métier et le service est affiché:
|
||||
* à la date d'un échange,
|
||||
* au jour d'un rendez-vous,
|
||||
* quand l'utilisateur est devenu référent d'un parcours d'accompagnement,
|
||||
* quand il a appliqué une transition sur un workflow,
|
||||
* quand il a mise à jour ou créé une fiche, dans les mentions "créé / mise à jour par ..., le ...",
|
||||
* quand il a mis à jour un commentaire,
|
||||
* …
|
||||
|
||||
|
||||
## v2.20.1 - 2024-06-05
|
||||
### Fixed
|
||||
* Do not allow StoredObjectCreated for edit and convert buttons
|
||||
|
||||
## v2.20.0 - 2024-06-05
|
||||
### Fixed
|
||||
* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions.
|
||||
* Added translations for choices of durations (> 5 hours)
|
||||
### Feature
|
||||
* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security);
|
||||
|
||||
This endpoint should be added to make the endpoint works properly:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
firewalls:
|
||||
dav:
|
||||
pattern: ^/dav
|
||||
provider: chain_provider
|
||||
stateless: true
|
||||
guard:
|
||||
authenticators:
|
||||
- Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator
|
||||
|
||||
```
|
||||
|
||||
## v2.19.0 - 2024-05-14
|
||||
### Feature
|
||||
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
||||
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
|
||||
### Fixed
|
||||
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
|
||||
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
|
||||
### UX
|
||||
* Form for document generation moved to the top of document list page
|
||||
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
|
||||
|
||||
|
||||
### Traduction francophone des principaux changements
|
||||
|
||||
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
|
||||
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
|
||||
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
|
||||
- correction du filtre "filtrer par zone géographique"
|
||||
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
|
||||
- module événement: améliorerations graphiques
|
||||
|
||||
## 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
|
||||
|
@@ -9,6 +9,7 @@
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-dom": "*",
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-redis": "*",
|
||||
@@ -75,7 +76,7 @@
|
||||
"phpunit/phpunit": ">= 7.5",
|
||||
"psalm/plugin-phpunit": "^0.18.4",
|
||||
"psalm/plugin-symfony": "^4.0.2",
|
||||
"rector/rector": "^0.17.7",
|
||||
"rector/rector": "1.1.1",
|
||||
"symfony/debug-bundle": "^5.1",
|
||||
"symfony/dotenv": "^4.4",
|
||||
"symfony/maker-bundle": "^1.20",
|
||||
|
@@ -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
|
||||
|
@@ -8,6 +8,16 @@ Chill can store a list of geolocated address references, which are used to sugge
|
||||
|
||||
Those addresses may be load from a dedicated source.
|
||||
|
||||
Countries
|
||||
=========
|
||||
|
||||
In order to load addresses into the chill application we first have to make sure that a list of countries is present.
|
||||
To import the countries run the following command.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
bin/console chill:main:countries:populate
|
||||
|
||||
In France
|
||||
=========
|
||||
|
||||
|
@@ -42,11 +42,13 @@
|
||||
"@fullcalendar/vue3": "^6.1.4",
|
||||
"@popperjs/core": "^2.9.2",
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"dropzone": "^5.7.6",
|
||||
"es6-promise": "^4.2.8",
|
||||
"leaflet": "^1.7.1",
|
||||
"marked": "^12.0.2",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"mime": "^3.0.0",
|
||||
"mime": "^4.0.0",
|
||||
"swagger-ui": "^4.15.5",
|
||||
"vis-network": "^9.1.0",
|
||||
"vue": "^3.2.37",
|
||||
|
6
phpstan-baseline-2024-05.neon
Normal file
6
phpstan-baseline-2024-05.neon
Normal file
@@ -0,0 +1,6 @@
|
||||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\<array\\<float\\|int\\|string\\|Stringable\\|null\\>\\>, iterable\\<array\\<string, bool\\|int\\|string\\>\\> given\\.$#"
|
||||
count: 1
|
||||
path: src/Bundle/ChillMainBundle/Controller/UserExportController.php
|
@@ -31,4 +31,5 @@ includes:
|
||||
- phpstan-baseline-level-3.neon
|
||||
- phpstan-baseline-level-4.neon
|
||||
- phpstan-baseline-level-5.neon
|
||||
- phpstan-baseline-2024-05.neon
|
||||
|
||||
|
11
rector.php
11
rector.php
@@ -32,9 +32,13 @@ return static function (RectorConfig $rectorConfig): void {
|
||||
//define sets of rules
|
||||
$rectorConfig->sets([
|
||||
LevelSetList::UP_TO_PHP_82,
|
||||
\Rector\Symfony\Set\SymfonyLevelSetList::UP_TO_SYMFONY_44,
|
||||
\Rector\Symfony\Set\SymfonySetList::SYMFONY_40,
|
||||
\Rector\Symfony\Set\SymfonySetList::SYMFONY_41,
|
||||
\Rector\Symfony\Set\SymfonySetList::SYMFONY_42,
|
||||
\Rector\Symfony\Set\SymfonySetList::SYMFONY_43,
|
||||
\Rector\Symfony\Set\SymfonySetList::SYMFONY_44,
|
||||
\Rector\Doctrine\Set\DoctrineSetList::DOCTRINE_CODE_QUALITY,
|
||||
\Rector\PHPUnit\Set\PHPUnitLevelSetList::UP_TO_PHPUNIT_90,
|
||||
\Rector\PHPUnit\Set\PHPUnitSetList::PHPUNIT_90,
|
||||
]);
|
||||
|
||||
// some routes are added twice if it remains activated
|
||||
@@ -45,9 +49,6 @@ return static function (RectorConfig $rectorConfig): void {
|
||||
|
||||
// skip some path...
|
||||
$rectorConfig->skip([
|
||||
// we need to discuss this: are we going to have FALSE in tests instead of an error ?
|
||||
\Rector\Php71\Rector\FuncCall\CountOnNullRector::class,
|
||||
|
||||
// we must adapt service definition
|
||||
\Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class,
|
||||
\Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class,
|
||||
|
@@ -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()
|
||||
|
@@ -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()
|
||||
),
|
||||
|
@@ -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,
|
||||
|
@@ -15,11 +15,10 @@ use Chill\ActivityBundle\Entity\Activity;
|
||||
use Chill\ActivityBundle\Entity\ActivityPresence;
|
||||
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
|
||||
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
||||
use Chill\DocStoreBundle\Form\StoredObjectType;
|
||||
use Chill\DocStoreBundle\Form\CollectionStoredObjectType;
|
||||
use Chill\MainBundle\Entity\Center;
|
||||
use Chill\MainBundle\Entity\Location;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||
use Chill\MainBundle\Form\Type\CommentType;
|
||||
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
||||
@@ -95,7 +94,7 @@ class ActivityType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod|null $accompanyingPeriod */
|
||||
/** @var AccompanyingPeriod|null $accompanyingPeriod */
|
||||
$accompanyingPeriod = null;
|
||||
|
||||
if ($options['accompanyingPeriod'] instanceof AccompanyingPeriod) {
|
||||
@@ -276,16 +275,9 @@ class ActivityType extends AbstractType
|
||||
}
|
||||
|
||||
if ($activityType->isVisible('documents')) {
|
||||
$builder->add('documents', ChillCollectionType::class, [
|
||||
'entry_type' => StoredObjectType::class,
|
||||
$builder->add('documents', CollectionStoredObjectType::class, [
|
||||
'label' => $activityType->getLabel('documents'),
|
||||
'required' => $activityType->isRequired('documents'),
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'button_add_label' => 'activity.Insert a document',
|
||||
'button_remove_label' => 'activity.Remove a document',
|
||||
'empty_collection_explain' => 'No documents',
|
||||
'entry_options' => ['has_title' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,51 @@
|
||||
<?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\Menu;
|
||||
|
||||
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
||||
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
|
||||
use Knp\Menu\MenuItem;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
final readonly class AccompanyingCourseQuickMenuBuilder implements LocalMenuBuilderInterface
|
||||
{
|
||||
public function __construct(private Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getMenuIds(): array
|
||||
{
|
||||
return ['accompanying_course_quick_menu'];
|
||||
}
|
||||
|
||||
public function buildMenu($menuId, MenuItem $menu, array $parameters)
|
||||
{
|
||||
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod $accompanyingCourse */
|
||||
$accompanyingCourse = $parameters['accompanying-course'];
|
||||
|
||||
if ($this->security->isGranted(ActivityVoter::CREATE, $accompanyingCourse)) {
|
||||
$menu
|
||||
->addChild('Create a new activity in accompanying course', [
|
||||
'route' => 'chill_activity_activity_new',
|
||||
'routeParameters' => [
|
||||
// 'activityType_id' => '',
|
||||
'accompanying_period_id' => $accompanyingCourse->getId(),
|
||||
],
|
||||
])
|
||||
->setExtras([
|
||||
'order' => 10,
|
||||
'icon' => 'plus',
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
|
@@ -68,7 +68,7 @@
|
||||
<div class="wl-col title"><h3>{{ 'Referrer'|trans }}</h3></div>
|
||||
<div class="wl-col list">
|
||||
<p class="wl-item">
|
||||
<span class="badge-user">{{ activity.user|chill_entity_render_box }}</span>
|
||||
<span class="badge-user">{{ activity.user|chill_entity_render_box({'at_date': activity.date}) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -87,7 +87,8 @@
|
||||
<li>
|
||||
{% if bloc.type == 'user' %}
|
||||
<span class="badge-user">
|
||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
|
||||
hello
|
||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ _self.insert_onthefly(bloc.type, item) }}
|
||||
@@ -114,7 +115,7 @@
|
||||
<li>
|
||||
{% if bloc.type == 'user' %}
|
||||
<span class="badge-user">
|
||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
|
||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ _self.insert_onthefly(bloc.type, item) }}
|
||||
@@ -142,7 +143,7 @@
|
||||
<span class="wl-item">
|
||||
{% if bloc.type == 'user' %}
|
||||
<span class="badge-user">
|
||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
|
||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
|
||||
{%- if context == 'calendar_accompanyingCourse' or context == 'calendar_person' %}
|
||||
{% set invite = entity.inviteForUser(item) %}
|
||||
{% if invite is not null %}
|
||||
|
@@ -92,7 +92,9 @@
|
||||
{% endif %}
|
||||
|
||||
{%- if edit_form.documents is defined -%}
|
||||
{{ form_row(edit_form.documents) }}
|
||||
{{ form_label(edit_form.documents) }}
|
||||
{{ form_errors(edit_form.documents) }}
|
||||
{{ form_widget(edit_form.documents) }}
|
||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
||||
{% endif %}
|
||||
|
||||
|
@@ -41,7 +41,7 @@
|
||||
{% if activity.user and t.userVisible %}
|
||||
<li>
|
||||
<span class="item-key">{{ 'Referrer'|trans ~ ': ' }}</span>
|
||||
<span class="badge-user">{{ activity.user|chill_entity_render_box }}</span>
|
||||
<span class="badge-user">{{ activity.user|chill_entity_render_box({'at_date': activity.date}) }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
|
@@ -37,7 +37,7 @@
|
||||
{%- if entity.user is not null %}
|
||||
<dt class="inline">{{ 'Referrer'|trans|capitalize }}</dt>
|
||||
<dd>
|
||||
<span class="badge-user">{{ entity.user|chill_entity_render_box }}</span>
|
||||
<span class="badge-user">{{ entity.user|chill_entity_render_box({'at_date': entity.date}) }}</span>
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
|
@@ -145,7 +145,7 @@ class ActivityVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn
|
||||
throw new \RuntimeException('Could not determine context of activity.');
|
||||
}
|
||||
} elseif ($subject instanceof AccompanyingPeriod) {
|
||||
if (AccompanyingPeriod::STEP_CLOSED === $subject->getStep()) {
|
||||
if (AccompanyingPeriod::STEP_CLOSED === $subject->getStep() || AccompanyingPeriod::STEP_DRAFT === $subject->getStep()) {
|
||||
if (\in_array($attribute, [self::UPDATE, self::CREATE, self::DELETE], true)) {
|
||||
return false;
|
||||
}
|
||||
|
@@ -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,
|
||||
]);
|
||||
|
@@ -60,7 +60,7 @@ final class TranslatableActivityTypeTest extends KernelTestCase
|
||||
$this->assertInstanceOf(
|
||||
ActivityType::class,
|
||||
$form->getData()['type'],
|
||||
'The data is an instance of Chill\\ActivityBundle\\Entity\\ActivityType'
|
||||
'The data is an instance of Chill\ActivityBundle\Entity\ActivityType'
|
||||
);
|
||||
$this->assertEquals($type->getId(), $form->getData()['type']->getId());
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -77,6 +77,18 @@ Choose a type: Choisir un type
|
||||
4 hours: 4 heures
|
||||
4 hours 30: 4 heures 30
|
||||
5 hours: 5 heures
|
||||
5 hours 30: 5 heure 30
|
||||
6 hours: 6 heures
|
||||
6 hours 30: 6 heure 30
|
||||
7 hours: 7 heures
|
||||
7 hours 30: 7 heure 30
|
||||
8 hours: 8 heures
|
||||
8 hours 30: 8 heure 30
|
||||
9 hours: 9 heures
|
||||
9 hours 30: 9 heure 30
|
||||
10 hours: 10 heures
|
||||
11 hours: 11 heures
|
||||
12 hours: 12 heures
|
||||
Concerned groups: Parties concernées par l'échange
|
||||
Persons in accompanying course: Usagers du parcours
|
||||
Third persons: Tiers non-pro.
|
||||
@@ -210,6 +222,7 @@ Documents label: Libellé du champ Documents
|
||||
# activity type category admin
|
||||
ActivityTypeCategory list: Liste des catégories des types d'échange
|
||||
Create a new activity type category: Créer une nouvelle catégorie de type d'échange
|
||||
Create a new activity in accompanying course: Créer un échange dans le parcours
|
||||
|
||||
# activity delete
|
||||
Remove activity: Supprimer un échange
|
||||
@@ -396,7 +409,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%'
|
||||
|
@@ -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,
|
||||
|
@@ -49,13 +49,13 @@
|
||||
<li>
|
||||
<span>
|
||||
<abbr class="referrer" title={{ 'Created by'|trans }}>{{ 'By'|trans }}:</abbr>
|
||||
<b>{{ entity.createdBy|chill_entity_render_box }}</b>
|
||||
<b>{{ entity.createdBy|chill_entity_render_box({'at_date': entity.date}) }}</b>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<abbr class="referrer" title={{ 'Created for'|trans }}>{{ 'For'|trans }}:</abbr>
|
||||
<b>{{ entity.agent|chill_entity_render_box }}</b>
|
||||
<b>{{ entity.agent|chill_entity_render_box({'at_date': entity.date}) }}</b>
|
||||
|
||||
</span>
|
||||
</li>
|
||||
|
@@ -18,10 +18,10 @@
|
||||
<dd>{{ entity.type|chill_entity_render_box }}</dd>
|
||||
|
||||
<dt class="inline">{{ 'Created by'|trans }}</dt>
|
||||
<dd>{{ entity.createdBy }}</dd>
|
||||
<dd>{{ entity.createdBy|chill_entity_render_box({'at_date': entity.date}) }}</dd>
|
||||
|
||||
<dt class="inline">{{ 'Created for'|trans }}</dt>
|
||||
<dd>{{ entity.agent }}</dd>
|
||||
<dd>{{ entity.agent|chill_entity_render_box({'at_date': entity.date}) }}</dd>
|
||||
|
||||
<dt class="inline">{{ 'Asideactivity location'|trans }}</dt>
|
||||
{%- if entity.location.name is defined -%}
|
||||
|
@@ -0,0 +1,66 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
@@ -72,21 +72,21 @@ days: jours
|
||||
1 hour 30: 1 heure 30
|
||||
1 hour 45: 1 heure 45
|
||||
2 hours: 2 heures
|
||||
2 hours 30: 2 heure 30
|
||||
2 hours 30: 2 heures 30
|
||||
3 hours: 3 heures
|
||||
3 hours 30: 3 heure 30
|
||||
3 hours 30: 3 heures 30
|
||||
4 hours: 4 heures
|
||||
4 hours 30: 4 heure 30
|
||||
4 hours 30: 4 heures 30
|
||||
5 hours: 5 heures
|
||||
5 hours 30: 5 heure 30
|
||||
5 hours 30: 5 heures 30
|
||||
6 hours: 6 heures
|
||||
6 hours 30: 6 heure 30
|
||||
6 hours 30: 6 heures 30
|
||||
7 hours: 7 heures
|
||||
7 hours 30: 7 heure 30
|
||||
7 hours 30: 7 heures 30
|
||||
8 hours: 8 heures
|
||||
8 hours 30: 8 heure 30
|
||||
8 hours 30: 8 heures 30
|
||||
9 hours: 9 heures
|
||||
9 hours 30: 9 heure 30
|
||||
9 hours 30: 9 heures 30
|
||||
10 hours: 10 heures
|
||||
1/2 day: 1/2 jour
|
||||
1 day: 1 jour
|
||||
|
@@ -49,8 +49,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
|
||||
|
||||
$limit = 50;
|
||||
$offset = 0;
|
||||
/** @var \DateInterval $interval the interval before the end of the expiration */
|
||||
$interval = new \DateInterval('P1D');
|
||||
$expiration = (new \DateTimeImmutable('now'))->add(new \DateInterval($input->getOption('subscription-duration')));
|
||||
$users = $this->userRepository->findAllAsArray('fr');
|
||||
$created = 0;
|
||||
@@ -93,7 +91,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
|
||||
} catch (UserAbsenceSyncException $e) {
|
||||
$this->logger->error('could not sync user absence', ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), 'message' => $e->getMessage()]);
|
||||
$output->writeln(sprintf('Could not sync user absence: id: %s and email: %s', $user->getId(), $user->getEmail()));
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// we first try to renew an existing subscription, if any.
|
||||
|
@@ -524,6 +524,16 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
|
||||
return $this->startDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the date of the calendar.
|
||||
*
|
||||
* Useful for showing the date of the calendar event, required by twig in some places.
|
||||
*/
|
||||
public function getDate(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->getStartDate();
|
||||
}
|
||||
|
||||
public function getStatus(): ?string
|
||||
{
|
||||
return $this->status;
|
||||
|
@@ -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()
|
||||
),
|
||||
|
@@ -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()
|
||||
),
|
||||
|
@@ -0,0 +1,50 @@
|
||||
<?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\CalendarBundle\Menu;
|
||||
|
||||
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
|
||||
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
|
||||
use Knp\Menu\MenuItem;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
final readonly class AccompanyingCourseQuickMenuBuilder implements LocalMenuBuilderInterface
|
||||
{
|
||||
public function __construct(private Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getMenuIds(): array
|
||||
{
|
||||
return ['accompanying_course_quick_menu'];
|
||||
}
|
||||
|
||||
public function buildMenu($menuId, MenuItem $menu, array $parameters)
|
||||
{
|
||||
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod $accompanyingCourse */
|
||||
$accompanyingCourse = $parameters['accompanying-course'];
|
||||
|
||||
if ($this->security->isGranted(CalendarVoter::CREATE, $accompanyingCourse)) {
|
||||
$menu
|
||||
->addChild('Create a new calendar in accompanying course', [
|
||||
'route' => 'chill_calendar_calendar_new',
|
||||
'routeParameters' => [
|
||||
'accompanying_period_id' => $accompanyingCourse->getId(),
|
||||
],
|
||||
])
|
||||
->setExtras([
|
||||
'order' => 20,
|
||||
'icon' => 'plus',
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -37,12 +37,12 @@ class RemoteEventConverter
|
||||
* valid when the remote string contains also a timezone, like in
|
||||
* lastModifiedDate.
|
||||
*/
|
||||
final public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\\TH:i:s.u?P';
|
||||
final public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\TH:i:s.u?P';
|
||||
|
||||
/**
|
||||
* Same as above, but sometimes the date is expressed with only 6 milliseconds.
|
||||
*/
|
||||
final public const REMOTE_DATETIMEZONE_FORMAT_ALT = 'Y-m-d\\TH:i:s.uP';
|
||||
final public const REMOTE_DATETIMEZONE_FORMAT_ALT = 'Y-m-d\TH:i:s.uP';
|
||||
|
||||
private const REMOTE_DATE_FORMAT = 'Y-m-d\TH:i:s.u0';
|
||||
|
||||
|
@@ -1 +1,2 @@
|
||||
import './scss/badge.scss';
|
||||
import './scss/calendar-list.scss';
|
||||
|
@@ -0,0 +1,26 @@
|
||||
ul.calendar-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
& > li {
|
||||
display: inline-block;
|
||||
}
|
||||
& > li:nth-child(n+2) {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.calendar-list {
|
||||
|
||||
ul.calendar-list {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > a.calendar-list__global {
|
||||
display: inline-block;;
|
||||
padding: 0.2rem;
|
||||
min-width: 2rem;
|
||||
border: 1px solid var(--bs-chill-blue);
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
@@ -16,6 +16,7 @@
|
||||
:removableIfSet="false"
|
||||
:displayPicked="false"
|
||||
:suggested="this.suggestedUsers"
|
||||
:label="'Utilisateur principal'"
|
||||
@addNewEntity="setMainUser"
|
||||
></pick-entity>
|
||||
</div>
|
||||
|
@@ -55,7 +55,7 @@
|
||||
<div class="item-col">
|
||||
<ul class="list-content">
|
||||
{% if calendar.mainUser is not empty %}
|
||||
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box }}</span>
|
||||
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -132,7 +132,7 @@
|
||||
<li class="cancel">
|
||||
<span class="createdBy">
|
||||
{{ 'Created by'|trans }}
|
||||
<b>{{ calendar.activity.createdBy|chill_entity_render_string }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
|
||||
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
|
||||
</span>
|
||||
</li>
|
||||
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
|
||||
|
@@ -89,7 +89,7 @@ class CalendarVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn
|
||||
switch ($attribute) {
|
||||
case self::SEE:
|
||||
case self::CREATE:
|
||||
if (AccompanyingPeriod::STEP_DRAFT === $subject->getStep()) {
|
||||
if (AccompanyingPeriod::STEP_DRAFT === $subject->getStep() || AccompanyingPeriod::STEP_CLOSED === $subject->getStep()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@@ -26,6 +26,7 @@ The calendar item has been successfully removed.: Le rendez-vous a été supprim
|
||||
From the day: Du
|
||||
to the day: au
|
||||
Transform to activity: Transformer en échange
|
||||
Create a new calendar in accompanying course: Créer un rendez-vous dans le parcours
|
||||
Will send SMS: Un SMS de rappel sera envoyé
|
||||
Will not send SMS: Aucun SMS de rappel ne sera envoyé
|
||||
SMS already sent: Un SMS a été envoyé
|
||||
|
@@ -49,20 +49,17 @@ interface CustomFieldInterface
|
||||
|
||||
/**
|
||||
* Return if the value can be considered as empty.
|
||||
*
|
||||
* @param mixed $value the value passed throug the deserialize function
|
||||
*/
|
||||
public function isEmptyValue($value, CustomField $customField);
|
||||
public function isEmptyValue(mixed $value, CustomField $customField);
|
||||
|
||||
/**
|
||||
* Return a repsentation of the value of the CustomField.
|
||||
*
|
||||
* @param mixed $value the raw value, **not deserialized** (= as stored in the db)
|
||||
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
||||
*
|
||||
* @return string an html representation of the value
|
||||
*/
|
||||
public function render($value, CustomField $customField, $documentType = 'html');
|
||||
public function render(mixed $value, CustomField $customField, $documentType = 'html');
|
||||
|
||||
/**
|
||||
* Transform the value into a format that can be stored in DB.
|
||||
|
@@ -399,8 +399,6 @@ final class CustomFieldsChoiceTest extends KernelTestCase
|
||||
|
||||
/**
|
||||
* @dataProvider emptyDataProvider
|
||||
*
|
||||
* @param mixed $data deserialized data
|
||||
*/
|
||||
public function testIsEmptyValueEmpty(mixed $data)
|
||||
{
|
||||
|
@@ -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,43 +245,21 @@ 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
|
||||
// we store the generated document (associate with the original entity, etc.)
|
||||
// but only if this is not a test
|
||||
if (!$isTest) {
|
||||
$context
|
||||
->storeGenerated(
|
||||
$template,
|
||||
@@ -262,16 +267,35 @@ final class DocGeneratorTemplateController extends AbstractController
|
||||
$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,
|
||||
)
|
||||
);
|
||||
|
||||
|
@@ -69,7 +69,7 @@ class DocGeneratorTemplate
|
||||
*
|
||||
* @Serializer\Groups({"read"})
|
||||
*/
|
||||
private int $id;
|
||||
private ?int $id = null;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="json")
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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;
|
||||
}
|
@@ -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 %}
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td>{{ entity.id }}</td>
|
||||
<td>{{ entity.name|localize_translatable_string}}</td>
|
||||
<td>{{ contextManager.getContextByKey(entity.context).name|trans }}</td>
|
||||
<td>
|
||||
<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', '/')|e('html_attr') }}" />
|
||||
<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" />
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block actions_before %}
|
||||
|
@@ -6,20 +6,22 @@
|
||||
<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">
|
||||
<div class="col">
|
||||
<div>
|
||||
{{ context.description|trans|nl2br }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -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 }}
|
||||
|
||||
|
@@ -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}) }}
|
@@ -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();
|
||||
$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 (null !== $destinationStoredObjectId) {
|
||||
$destinationStoredObject = $this->entityManager->find(StoredObject::class, $destinationStoredObjectId);
|
||||
}
|
||||
|
||||
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)
|
||||
;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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([
|
||||
|
@@ -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,30 +60,83 @@ 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(
|
||||
try {
|
||||
if ($message->isDumpOnly()) {
|
||||
$destinationStoredObject = $this->generator->generateDataDump(
|
||||
$template,
|
||||
$message->getEntityId(),
|
||||
$message->getContextGenerationData(),
|
||||
$destinationStoredObject,
|
||||
false,
|
||||
null,
|
||||
$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(),
|
||||
'destination_stored_object' => $message->getDestinationStoredObjectId(),
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,65 @@
|
||||
<?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\Test;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* @template T of object
|
||||
*/
|
||||
abstract class DocGenNormalizerTestAbstract extends KernelTestCase
|
||||
{
|
||||
public function testNullValueHasSameKeysAsNull(): void
|
||||
{
|
||||
$normalizedObject = $this->getNormalizer()->normalize($this->provideNotNullObject(), 'docgen', [
|
||||
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => $this->provideDocGenExpectClass(),
|
||||
]);
|
||||
$nullNormalizedObject = $this->getNormalizer()->normalize(null, 'docgen', [
|
||||
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => $this->provideDocGenExpectClass(),
|
||||
]);
|
||||
|
||||
self::assertEqualsCanonicalizing(array_keys($normalizedObject), array_keys($nullNormalizedObject));
|
||||
self::assertArrayHasKey('isNull', $nullNormalizedObject, 'each object must have an "isNull" key');
|
||||
self::assertTrue($nullNormalizedObject['isNull'], 'isNull key must be true for null objects');
|
||||
self::assertFalse($normalizedObject['isNull'], 'isNull key must be false for null objects');
|
||||
|
||||
foreach ($normalizedObject as $key => $value) {
|
||||
if (in_array($key, ['isNull', 'type'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
if (array_is_list($value)) {
|
||||
self::assertEquals([], $nullNormalizedObject[$key], "list must be serialized as an empty array, in {$key}");
|
||||
} else {
|
||||
self::assertEqualsCanonicalizing(array_keys($value), array_keys($nullNormalizedObject[$key]), "sub-object must have the same keys, in {$key}");
|
||||
}
|
||||
} elseif (is_string($value)) {
|
||||
self::assertEquals('', $nullNormalizedObject[$key], 'strings must be ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return T
|
||||
*/
|
||||
abstract public function provideNotNullObject(): object;
|
||||
|
||||
/**
|
||||
* @return class-string<T>
|
||||
*/
|
||||
abstract public function provideDocGenExpectClass(): string;
|
||||
|
||||
abstract public function getNormalizer(): NormalizerInterface;
|
||||
}
|
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
@@ -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());
|
||||
}
|
||||
}
|
@@ -0,0 +1,4 @@
|
||||
docgen:
|
||||
data_dump_email:
|
||||
link_valid_until: >-
|
||||
Ce lien est valide jusqu'au {validity, date, full}, {validity, time, medium}
|
@@ -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
|
||||
|
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
<?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
|
||||
{
|
||||
}
|
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace 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' => [],
|
||||
];
|
||||
}
|
||||
}
|
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
@@ -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")
|
||||
@@ -43,14 +48,14 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
||||
/**
|
||||
* @ORM\Column(type="json", name="datas")
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private array $datas = [];
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="text")
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private string $filename = '';
|
||||
|
||||
@@ -61,7 +66,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
||||
*
|
||||
* @ORM\Column(type="integer")
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private ?int $id = null;
|
||||
|
||||
@@ -70,35 +75,35 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
||||
*
|
||||
* @ORM\Column(type="json", name="iv")
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private array $iv = [];
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="json", name="key")
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private array $keyInfos = [];
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="text", name="title")
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private string $title = '';
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="text", name="type", options={"default": ""})
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private string $type = '';
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="uuid", unique=true)
|
||||
*
|
||||
* @Serializer\Groups({"read", "write"})
|
||||
* @Serializer\Groups({"write"})
|
||||
*/
|
||||
private UuidInterface $uuid;
|
||||
|
||||
@@ -117,13 +122,21 @@ 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
|
||||
*/
|
||||
public function __construct(/**
|
||||
* @ORM\Column(type="text", options={"default": "ready"})
|
||||
*
|
||||
* @Serializer\Groups({"read"})
|
||||
*/
|
||||
private string $status = 'ready'
|
||||
) {
|
||||
@@ -144,6 +157,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 +321,52 @@ 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;
|
||||
}
|
||||
|
||||
public function saveHistory(): void
|
||||
{
|
||||
if ('' === $this->getFilename()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->datas['history'][] = [
|
||||
'filename' => $this->getFilename(),
|
||||
'iv' => $this->getIv(),
|
||||
'key_infos' => $this->getKeyInfos(),
|
||||
'type' => $this->getType(),
|
||||
'before' => (new \DateTimeImmutable('now'))->getTimestamp(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -14,47 +14,21 @@ namespace Chill\DocStoreBundle\Form;
|
||||
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
||||
use Chill\DocStoreBundle\Entity\Document;
|
||||
use Chill\DocStoreBundle\Entity\DocumentCategory;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||
use Chill\MainBundle\Form\Type\ChillTextareaType;
|
||||
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
|
||||
use Chill\MainBundle\Templating\TranslatableStringHelper;
|
||||
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class AccompanyingCourseDocumentType extends AbstractType
|
||||
final class AccompanyingCourseDocumentType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* @var AuthorizationHelper
|
||||
*/
|
||||
protected $authorizationHelper;
|
||||
|
||||
/**
|
||||
* @var ObjectManager
|
||||
*/
|
||||
protected $om;
|
||||
|
||||
/**
|
||||
* @var TranslatableStringHelper
|
||||
*/
|
||||
protected $translatableStringHelper;
|
||||
|
||||
/**
|
||||
* the user running this form.
|
||||
*
|
||||
* @var User
|
||||
*/
|
||||
protected $user;
|
||||
|
||||
public function __construct(
|
||||
TranslatableStringHelper $translatableStringHelper
|
||||
private readonly TranslatableStringHelperInterface $translatableStringHelper
|
||||
) {
|
||||
$this->translatableStringHelper = $translatableStringHelper;
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
|
@@ -0,0 +1,37 @@
|
||||
<?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\Form;
|
||||
|
||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class CollectionStoredObjectType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver
|
||||
->setDefault('entry_type', StoredObjectType::class)
|
||||
->setDefault('allow_add', true)
|
||||
->setDefault('allow_delete', true)
|
||||
->setDefault('button_add_label', 'stored_object.Insert a document')
|
||||
->setDefault('button_remove_label', 'stored_object.Remove a document')
|
||||
->setDefault('empty_collection_explain', 'No documents')
|
||||
->setDefault('entry_options', ['has_title' => true])
|
||||
->setDefault('js_caller', 'data-collection-stored-object');
|
||||
}
|
||||
|
||||
public function getParent()
|
||||
{
|
||||
return ChillCollectionType::class;
|
||||
}
|
||||
}
|
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Form\DataMapper;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Symfony\Component\Form\DataMapperInterface;
|
||||
use Symfony\Component\Form\Exception;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
|
||||
class StoredObjectDataMapper implements DataMapperInterface
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||
*/
|
||||
public function mapDataToForms($viewData, $forms)
|
||||
{
|
||||
if (null === $viewData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$viewData instanceof StoredObject) {
|
||||
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||
}
|
||||
|
||||
$forms = iterator_to_array($forms);
|
||||
if (array_key_exists('title', $forms)) {
|
||||
$forms['title']->setData($viewData->getTitle());
|
||||
}
|
||||
$forms['stored_object']->setData($viewData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||
*/
|
||||
public function mapFormsToData($forms, &$viewData)
|
||||
{
|
||||
$forms = iterator_to_array($forms);
|
||||
|
||||
if (!(null === $viewData || $viewData instanceof StoredObject)) {
|
||||
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||
}
|
||||
|
||||
if (null === $forms['stored_object']->getData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StoredObject $viewData */
|
||||
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
|
||||
// we want to keep the previous history
|
||||
$viewData->saveHistory();
|
||||
}
|
||||
|
||||
$viewData->setFilename($forms['stored_object']->getData()['filename']);
|
||||
$viewData->setIv($forms['stored_object']->getData()['iv']);
|
||||
$viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']);
|
||||
$viewData->setType($forms['stored_object']->getData()['type']);
|
||||
|
||||
if (array_key_exists('title', $forms)) {
|
||||
$viewData->setTitle($forms['title']->getData());
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,52 @@
|
||||
<?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\Form\DataTransformer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class StoredObjectDataTransformer implements DataTransformerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(mixed $value): mixed
|
||||
{
|
||||
if (null === $value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($value instanceof StoredObject) {
|
||||
return $this->serializer->serialize($value, 'json', [
|
||||
'groups' => [
|
||||
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
throw new UnexpectedTypeException($value, StoredObject::class);
|
||||
}
|
||||
|
||||
public function reverseTransform(mixed $value): mixed
|
||||
{
|
||||
if ('' === $value || null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
@@ -11,11 +11,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\Form;
|
||||
|
||||
use ChampsLibres\AsyncUploaderBundle\Form\Type\AsyncUploaderType;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -24,16 +23,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
/**
|
||||
* Form type which allow to join a document.
|
||||
*/
|
||||
class StoredObjectType extends AbstractType
|
||||
final class StoredObjectType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
protected $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
public function __construct(
|
||||
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
|
||||
private readonly StoredObjectDataMapper $storedObjectDataMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
@@ -45,30 +40,9 @@ class StoredObjectType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('filename', AsyncUploaderType::class)
|
||||
->add('type', HiddenType::class)
|
||||
->add('keyInfos', HiddenType::class)
|
||||
->add('iv', HiddenType::class);
|
||||
|
||||
$builder
|
||||
->get('keyInfos')
|
||||
->addModelTransformer(new CallbackTransformer(
|
||||
$this->transform(...),
|
||||
$this->reverseTransform(...)
|
||||
));
|
||||
$builder
|
||||
->get('iv')
|
||||
->addModelTransformer(new CallbackTransformer(
|
||||
$this->transform(...),
|
||||
$this->reverseTransform(...)
|
||||
));
|
||||
|
||||
$builder
|
||||
->addModelTransformer(new CallbackTransformer(
|
||||
$this->transformObject(...),
|
||||
$this->reverseTransformObject(...)
|
||||
));
|
||||
$builder->add('stored_object', HiddenType::class);
|
||||
$builder->get('stored_object')->addModelTransformer($this->storedObjectDataTransformer);
|
||||
$builder->setDataMapper($this->storedObjectDataMapper);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
@@ -80,43 +54,4 @@ class StoredObjectType extends AbstractType
|
||||
->setDefault('has_title', false)
|
||||
->setAllowedTypes('has_title', ['bool']);
|
||||
}
|
||||
|
||||
public function reverseTransform($value)
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function reverseTransformObject($object)
|
||||
{
|
||||
if (null === $object) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null === $object->getFilename()) {
|
||||
// remove the original object
|
||||
$this->em->remove($object);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
public function transform($object)
|
||||
{
|
||||
if (null === $object) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \json_encode($object, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function transformObject($object = null)
|
||||
{
|
||||
return $object;
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
{
|
||||
|
@@ -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
|
||||
{
|
||||
}
|
@@ -0,0 +1,86 @@
|
||||
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||
import {createApp} from "vue";
|
||||
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||
const i18n = _createI18n({});
|
||||
|
||||
const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElement): void => {
|
||||
console.log('app started', divElement);
|
||||
const input_stored_object: HTMLInputElement|null = divElement.querySelector("input[data-stored-object]");
|
||||
if (null === input_stored_object) {
|
||||
throw new Error('input to stored object not found');
|
||||
}
|
||||
|
||||
let existingDoc: StoredObject|null = null;
|
||||
if (input_stored_object.value !== "") {
|
||||
existingDoc = JSON.parse(input_stored_object.value);
|
||||
}
|
||||
const app_container = document.createElement("div");
|
||||
divElement.appendChild(app_container);
|
||||
|
||||
const app = createApp({
|
||||
template: '<drop-file-widget :existingDoc="this.$data.existingDoc" :allowRemove="true" @addDocument="this.addDocument" @removeDocument="removeDocument"></drop-file-widget>',
|
||||
data(vm) {
|
||||
return {
|
||||
existingDoc: existingDoc,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
DropFileWidget,
|
||||
},
|
||||
methods: {
|
||||
addDocument: function(object: StoredObjectCreated): void {
|
||||
console.log('object added', object);
|
||||
this.$data.existingDoc = object;
|
||||
input_stored_object.value = JSON.stringify(object);
|
||||
},
|
||||
removeDocument: function(object: StoredObject): void {
|
||||
console.log('catch remove document', object);
|
||||
input_stored_object.value = "";
|
||||
this.$data.existingDoc = null;
|
||||
console.log('collectionEntry', collectionEntry);
|
||||
|
||||
if (null !== collectionEntry) {
|
||||
console.log('will remove collection');
|
||||
collectionEntry.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.use(i18n).mount(app_container);
|
||||
}
|
||||
window.addEventListener('collection-add-entry', ((e: CustomEvent<CollectionEventPayload>) => {
|
||||
const detail = e.detail;
|
||||
const divElement: null|HTMLDivElement = detail.entry.querySelector('div[data-stored-object]');
|
||||
|
||||
if (null === divElement) {
|
||||
throw new Error('div[data-stored-object] not found');
|
||||
}
|
||||
|
||||
startApp(divElement, detail.entry);
|
||||
}) as EventListener);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const upload_inputs: NodeListOf<HTMLDivElement> = document.querySelectorAll('div[data-stored-object]');
|
||||
|
||||
upload_inputs.forEach((input: HTMLDivElement): void => {
|
||||
// test for a parent to check if this is a collection entry
|
||||
let collectionEntry: null|HTMLLIElement = null;
|
||||
let parent = input.parentElement;
|
||||
console.log('parent', parent);
|
||||
if (null !== parent) {
|
||||
let grandParent = parent.parentElement;
|
||||
console.log('grandParent', grandParent);
|
||||
if (null !== grandParent) {
|
||||
if (grandParent.tagName.toLowerCase() === 'li' && grandParent.classList.contains('entry')) {
|
||||
collectionEntry = grandParent as HTMLLIElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
startApp(input, collectionEntry);
|
||||
})
|
||||
});
|
||||
|
||||
export {}
|
@@ -17,18 +17,22 @@ 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'
|
||||
small = datasets.buttonSmall === '1',
|
||||
davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null,
|
||||
davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null
|
||||
;
|
||||
|
||||
return { storedObject, filename, canEdit, small };
|
||||
return { storedObject, filename, canEdit, small, davLink, davLinkExpiration };
|
||||
},
|
||||
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>',
|
||||
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>',
|
||||
methods: {
|
||||
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
||||
this.$data.storedObject.status = newStatus.status;
|
||||
|
@@ -17,6 +17,20 @@ export interface StoredObject {
|
||||
type: string,
|
||||
uuid: string,
|
||||
status: StoredObjectStatus,
|
||||
_links?: {
|
||||
dav_link?: {
|
||||
href: string
|
||||
expiration: number
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredObjectCreated {
|
||||
status: "stored_object_created",
|
||||
filename: string,
|
||||
iv: Uint8Array,
|
||||
keyInfos: object,
|
||||
type: string,
|
||||
}
|
||||
|
||||
export interface StoredObjectStatusChange {
|
||||
@@ -33,3 +47,18 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = {
|
||||
(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Object containing information for performering a POST request to a swift object store
|
||||
*/
|
||||
export interface PostStoreObjectSignature {
|
||||
method: "POST",
|
||||
max_file_size: number,
|
||||
max_file_count: 1,
|
||||
expires: number,
|
||||
submit_delay: 180,
|
||||
redirect: string,
|
||||
prefix: string,
|
||||
url: string,
|
||||
signature: string,
|
||||
}
|
||||
|
||||
|
@@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<div v-if="'ready' === props.storedObject.status" class="btn-group">
|
||||
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
|
||||
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
|
||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'">
|
||||
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
|
||||
</li>
|
||||
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
|
||||
<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 && props.storedObject.status !== 'stored_object_created'">
|
||||
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
|
||||
</li>
|
||||
<li v-if="props.canDownload">
|
||||
@@ -32,13 +35,14 @@ import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
||||
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
||||
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
||||
import {
|
||||
StoredObject,
|
||||
StoredObject, StoredObjectCreated,
|
||||
StoredObjectStatusChange,
|
||||
WopiEditButtonExecutableBeforeLeaveFunction
|
||||
} from "../types";
|
||||
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||
|
||||
interface DocumentActionButtonsGroupConfig {
|
||||
storedObject: StoredObject,
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
small?: boolean,
|
||||
canEdit?: boolean,
|
||||
canDownload?: boolean,
|
||||
@@ -57,6 +61,16 @@ 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<{
|
||||
@@ -68,7 +82,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
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -85,6 +99,7 @@ const checkForReady = function(): void {
|
||||
if (
|
||||
'ready' === props.storedObject.status
|
||||
|| 'failure' === props.storedObject.status
|
||||
|| 'stored_object_created' === props.storedObject.status
|
||||
// stop reloading if the page stays opened for a long time
|
||||
|| tryiesForReady > maxTryiesForReady
|
||||
) {
|
||||
@@ -97,6 +112,11 @@ const checkForReady = function(): void {
|
||||
};
|
||||
|
||||
const onObjectNewStatusCallback = async function(): Promise<void> {
|
||||
|
||||
if (props.storedObject.status === 'stored_object_created') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const new_status = await is_object_ready(props.storedObject);
|
||||
if (props.storedObject.status !== new_status.status) {
|
||||
emit('onStoredObjectStatusChange', new_status);
|
||||
|
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {encryptFile, uploadFile} from "../_components/helper";
|
||||
import {computed, ref, Ref} from "vue";
|
||||
|
||||
interface DropFileConfig {
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
}
|
||||
|
||||
const props = defineProps<DropFileConfig>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
}>();
|
||||
|
||||
const is_dragging: Ref<boolean> = ref(false);
|
||||
const uploading: Ref<boolean> = ref(false);
|
||||
|
||||
const has_existing_doc = computed<boolean>(() => {
|
||||
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||
});
|
||||
|
||||
const onDragOver = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
is_dragging.value = true;
|
||||
}
|
||||
|
||||
const onDragLeave = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
is_dragging.value = false;
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
console.log('on drop', e);
|
||||
e.preventDefault();
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
|
||||
if (null === files || undefined === files) {
|
||||
console.error("no files transferred", e.dataTransfer);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) {
|
||||
console.error("no files given");
|
||||
return;
|
||||
}
|
||||
|
||||
handleFile(files[0])
|
||||
}
|
||||
|
||||
const onZoneClick = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.addEventListener("change", onFileChange);
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
const onFileChange = async (event: Event): Promise<void> => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
console.log('event triggered', input);
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
console.log('file added', input.files[0]);
|
||||
const file = input.files[0];
|
||||
await handleFile(file);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
throw 'No file given';
|
||||
}
|
||||
|
||||
const handleFile = async (file: File): Promise<void> => {
|
||||
uploading.value = true;
|
||||
const type = file.type;
|
||||
const buffer = await file.arrayBuffer();
|
||||
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
|
||||
const filename = await uploadFile(encrypted);
|
||||
|
||||
console.log(iv, jsonWebKey);
|
||||
|
||||
const storedObject: StoredObjectCreated = {
|
||||
filename: filename,
|
||||
iv,
|
||||
keyInfos: jsonWebKey,
|
||||
type: type,
|
||||
status: "stored_object_created",
|
||||
}
|
||||
|
||||
emit('addDocument', storedObject);
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="drop-file">
|
||||
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<p v-if="has_existing_doc">
|
||||
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
|
||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
|
||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
|
||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
|
||||
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
|
||||
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
|
||||
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
|
||||
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
|
||||
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
|
||||
<i class="fa fa-file-code-o" v-else ></i>
|
||||
</p>
|
||||
<!-- todo i18n -->
|
||||
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
|
||||
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
|
||||
</div>
|
||||
<div v-else class="waiting">
|
||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.drop-file {
|
||||
width: 100%;
|
||||
|
||||
& > .area, & > .waiting {
|
||||
width: 100%;
|
||||
height: 8rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& > .area {
|
||||
border: 4px dashed #ccc;
|
||||
|
||||
&.dragging {
|
||||
border: 4px dashed blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
|
||||
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {computed, ref, Ref} from "vue";
|
||||
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
|
||||
interface DropFileConfig {
|
||||
allowRemove: boolean,
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
allowRemove: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
(e: 'removeDocument', stored_object: null): void
|
||||
}>();
|
||||
|
||||
const has_existing_doc = computed<boolean>(() => {
|
||||
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||
});
|
||||
|
||||
const dav_link_expiration = computed<number|undefined>(() => {
|
||||
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (props.existingDoc.status !== 'ready') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return props.existingDoc._links?.dav_link?.expiration;
|
||||
});
|
||||
|
||||
const dav_link_href = computed<string|undefined>(() => {
|
||||
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (props.existingDoc.status !== 'ready') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return props.existingDoc._links?.dav_link?.href;
|
||||
})
|
||||
|
||||
const onAddDocument = (s: StoredObjectCreated): void => {
|
||||
emit('addDocument', s);
|
||||
}
|
||||
|
||||
const onRemoveDocument = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
emit('removeDocument', null);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<drop-file :existingDoc="props.existingDoc" @addDocument="onAddDocument"></drop-file>
|
||||
|
||||
<ul class="record_actions">
|
||||
<li v-if="has_existing_doc">
|
||||
<document-action-buttons-group
|
||||
:stored-object="props.existingDoc"
|
||||
:can-edit="props.existingDoc?.status === 'ready'"
|
||||
:can-download="true"
|
||||
:dav-link="dav_link_href"
|
||||
:dav-link-expiration="dav_link_expiration"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<button v-if="allowRemove" class="btn btn-delete" @click="onRemoveDocument($event)" ></button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
@@ -10,7 +10,7 @@
|
||||
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
||||
import mime from "mime";
|
||||
import {reactive} from "vue";
|
||||
import {StoredObject} from "../../types";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
|
||||
interface ConvertButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
|
@@ -0,0 +1,66 @@
|
||||
<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>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user