Compare commits

..

119 Commits

Author SHA1 Message Date
24eb13f440 Merge remote-tracking branch 'origin/issue159_page_acceuil' into testing-202401 2024-02-19 13:36:23 +01:00
2b14c132d5 Homogenize the styling between vuejs module and twig templates
New CSS styles are applied to news module for better layout. Relevant templates are updated to use the new styles. Also, entry point is added in webpack configuration file to import the CSS file through index.js.
2024-02-19 13:36:11 +01:00
ae6355e1e7 Merge remote-tracking branch 'origin/issue159_page_acceuil' into testing-202401 2024-02-19 13:10:10 +01:00
e96c246ef9 adapt cs to php-csfixer version 3.49 2024-02-19 13:09:54 +01:00
d0af191a00 add target=blank to each anchor 2024-02-19 13:08:11 +01:00
95ee573dc5 rationalize the newsitem widgets 2024-02-19 13:07:45 +01:00
1004e98acd Merge branch 'master' into testing-202401 2024-02-19 11:31:42 +01:00
4ed50979bd Merge remote-tracking branch 'origin/issue159_page_acceuil' into testing-202401 2024-02-19 11:30:42 +01:00
85b91250fb correct newsItemControllerTest 2024-02-07 11:58:56 +01:00
8f2409fc06 remove error causing line from phpunit.xml file 2024-02-07 11:58:36 +01:00
ea47d9ff09 php cs fixer with latest version 2024-02-07 10:12:13 +01:00
81e46f2b52 Remove blockquote styling 2024-02-01 16:22:11 +01:00
f4bbb1950b Change styling date 2024-02-01 16:21:49 +01:00
fd48d45872 Improve styling of actualités 2024-01-31 18:38:09 +01:00
92aa9af052 Fix truncating logic to take into account bulletpoints and text written on separate lines 2024-01-30 18:46:30 +01:00
49aeda86d4 Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into testing-202401 2024-01-15 21:22:21 +01:00
cf1df462dc optional parameter after the required one 2024-01-15 21:18:51 +01:00
dd62581226 Merge branch 'issue159_page_acceuil' into testing-202401 2024-01-15 21:02:50 +01:00
b369d94bc3 Append JSON_THROW_ON_ERROR on json_decode 2024-01-15 20:59:11 +01:00
f5879cf275 Apply new CS rules on the news-on-homepage feature 2024-01-15 20:57:51 +01:00
8cc5859a3b Merge remote-tracking branch 'origin/master' into issue159_page_acceuil 2024-01-15 20:57:18 +01:00
e86954143b Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into testing-202401 2024-01-15 20:47:38 +01:00
a0328b9d68 Apply new CS rules on the webdav feature 2024-01-15 20:38:03 +01:00
813a80d6f9 Dav: add UI to edit document 2024-01-15 20:22:14 +01:00
ab95bb157e Dav: add some documentation on classes 2024-01-15 20:19:03 +01:00
18fd1dbc4a Dav: Introduce access control inside de dav controller 2024-01-15 20:19:03 +01:00
a35f7656cb Dav: refactor WebdavController 2024-01-15 20:19:03 +01:00
ff05f9f48a Dav: implements JWT extraction from the URL, and add the access_token in dav urls 2024-01-15 20:19:02 +01:00
482c494034 Webdav: fully implements the controller and response
The controller is tested from real request scraped from apache mod_dav implementation. The requests were scraped using a wireshark-like tool. Those requests have been adapted to suit to our xml.
2024-01-15 20:19:02 +01:00
81eafde216 Update package.json for improved dependency management
This commit organizes dependencies listed in the package.json file for better manageability. It properly positions the recently added "@types/dompurify", "dompurify", and "marked" packages. This spatial reordering does not affect the functionality but provides clearer visual comprehension.
2024-01-15 20:15:11 +01:00
146f5ac80f Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into testing-202401 2024-01-15 20:13:18 +01:00
5f74682cba Merge branch 'issue159_page_acceuil' into testing-20301 2024-01-15 14:21:59 +01:00
49dbd09167 Change test into an integration test rather than unit test : extend KernelTestCase and get real services from the container. 2023-12-18 15:36:01 +01:00
726f71c8f1 Correct the NewsItemsApiController test : remove commented code and extend properly 2023-12-18 15:34:43 +01:00
f03ae2cabc Fix doctrine annotation for dashboard config item 2023-12-18 14:29:17 +01:00
3a080ebebe Silence deprecation warning in tests, log them 2023-12-18 14:28:57 +01:00
2402050f5f work on tests 2023-12-11 17:32:21 +01:00
a97a22d464 php cs fixer 2023-12-04 16:24:05 +01:00
de9251942c add missing translations 2023-12-04 16:19:37 +01:00
807ffb845a correct date format in admin 2023-12-04 16:15:05 +01:00
e0fc87ef58 add view admin page + some styling 2023-12-04 16:10:24 +01:00
e876b75d41 Add delete functionality in admin for news item 2023-12-04 15:13:12 +01:00
229cef8942 Adjust translation of endDate 2023-12-04 14:13:44 +01:00
6676e06fb5 remove extend of abstractController 2023-12-04 14:13:27 +01:00
6e48f8f7ea do not use an integer in a condition 2023-11-29 21:03:03 +01:00
e2efb267f5 Improve layout of the history page 2023-11-29 20:52:12 +01:00
684f1a3015 Fix ci issues 2023-11-29 16:15:40 +01:00
2af9ff7d00 reload masonry when dom is updated 2023-11-29 16:01:44 +01:00
ae2265df21 fix errors with data structure 2023-11-29 16:01:44 +01:00
6da297d1d2 rename method newsWithDateFilter => currentNews 2023-11-29 16:01:44 +01:00
6787612071 Take into account case when there are no active news 2023-11-29 16:01:43 +01:00
53d18c7748 Give a more obvious url for new/current api endpoint 2023-11-29 16:01:43 +01:00
8bbe094e70 fix error for props on AddressDetailsButton.vue 2023-11-29 16:01:43 +01:00
df16ca9a60 force bootstrap to be restricted to version 5.2 2023-11-29 16:01:42 +01:00
f1df2d5165 Update wrong translation key 2023-11-29 11:12:21 +01:00
4a58d7f300 attempt to write tests 2023-11-21 20:34:28 +01:00
d6b1216021 rename methods for more clarity 2023-11-21 15:10:14 +01:00
dadde29bc2 Fix navigation back to MyCustoms tab 2023-11-21 11:53:16 +01:00
693bf65721 Add single detail page to view entire article from within news item history page 2023-11-21 10:19:00 +01:00
8f3256e46e rename news item history controller 2023-11-21 09:39:59 +01:00
f7de5fe1ed remove custom serializer and adjust annotations of news item entity 2023-11-21 09:28:19 +01:00
6dd463a7b0 let news tile stretch over entire dashboard 2023-11-21 09:20:45 +01:00
ed271bed31 fix truncating of text to avoid cutting into link 2023-11-21 09:20:21 +01:00
502894ecea add limit and offset for apicontroller 2023-11-21 09:19:57 +01:00
c185c35c44 Add history page for all news items with a search filter on the basis of the title or content 2023-11-20 17:13:04 +01:00
e8b8f30e3c add validation on start- and enddate of news item 2023-11-20 15:52:30 +01:00
caa2bc1f3c Add admin translations and order items differently in admin 2023-11-20 15:52:08 +01:00
50a6cb5af6 fix: do no display expired news items 2023-11-20 15:51:07 +01:00
13c33567fd separate vue logic into different components 2023-11-20 15:50:02 +01:00
af3d06e7d3 Format date in dd-mm-yyyy 2023-11-20 12:00:27 +01:00
b74ab2fa0e Fix date typing issue 2023-11-20 11:52:56 +01:00
001fb269b3 Adapt pathes for importing ChillMainAssets in ts 2023-11-20 11:21:58 +01:00
262e76c993 Update bootstrap to version 5.3.0 2023-11-20 11:05:36 +01:00
caf45af4e5 add types dependency 2023-11-20 09:47:34 +01:00
cea801e620 add dependencies 2023-11-20 09:44:54 +01:00
19b53e4a4c php cs fixes 2023-11-13 14:06:47 +01:00
09f823ac08 process review 2023-11-13 14:06:05 +01:00
5be516b14e Merge branch 'issue159_page_acceuil' of gitlab.com:Chill-Projet/chill-bundles into issue159_page_acceuil 2023-11-09 20:47:19 +01:00
eb8dc441b9 add typing 2023-11-09 20:46:43 +01:00
32a103d86a php cs fixes 2023-11-09 20:46:43 +01:00
6d608ab35a fix phpstan issues 2023-11-09 20:46:43 +01:00
334d357189 uncomment sanitizing 2023-11-09 20:46:42 +01:00
8363c5c3cf Add use of DOMPurify to sanitize text from possible injection 2023-11-09 20:46:42 +01:00
cd793d6842 Convert markdown into html + minor style adjustments 2023-11-09 20:46:42 +01:00
3ae8e0c406 update controller not to extend apiController and make some changes in repository + serializer 2023-11-09 20:46:42 +01:00
6c93c8b8fa make frontend news widget 2023-11-09 20:46:42 +01:00
efdc84930b change logic of dashboard item to return user config, reinstate news items api 2023-11-09 20:46:42 +01:00
6cd6cb1000 fix admin form for news item 2023-11-09 20:46:42 +01:00
f4c08ee0d7 create dashboard item entity 2023-11-09 20:46:42 +01:00
b5f7f578da some setup in frontend 2023-11-09 20:46:42 +01:00
b172ebdf76 api point for dashboard items 2023-11-09 20:46:42 +01:00
312a43c093 news item normalizer - to be generalized to dashboard item? 2023-11-09 20:46:42 +01:00
e17b4da2a4 some adjustments to news item 2023-11-09 20:46:42 +01:00
003ca30c74 adjust news item entity and recreate migration 2023-11-09 20:46:42 +01:00
88447bbbf8 restructure json data 2023-11-09 20:46:42 +01:00
1c49eb492a create API for news item + testing if fetch works : to be generalized to accomodate other types of dashboard items 2023-11-09 20:46:42 +01:00
7bdb5bfce6 create news item entity and the admin for it 2023-11-09 20:46:42 +01:00
87615d179e create news item entity + migration 2023-11-09 20:46:42 +01:00
ed2d41c225 add typing 2023-11-09 20:22:31 +01:00
d828a6b9e0 php cs fixes 2023-11-09 19:25:41 +01:00
d6641f70c9 fix phpstan issues 2023-11-09 19:25:18 +01:00
7b4969e89d uncomment sanitizing 2023-11-09 19:23:04 +01:00
fc22bf1194 Add use of DOMPurify to sanitize text from possible injection 2023-11-09 19:21:52 +01:00
997a6ea419 Convert markdown into html + minor style adjustments 2023-11-09 19:07:35 +01:00
a55cd3b7e9 update controller not to extend apiController and make some changes in repository + serializer 2023-11-09 18:33:07 +01:00
6b966285a6 make frontend news widget 2023-11-09 13:49:32 +01:00
5a400fd162 change logic of dashboard item to return user config, reinstate news items api 2023-11-08 15:40:58 +01:00
01a5c291e0 fix admin form for news item 2023-11-08 14:00:53 +01:00
4646cd1cf0 create dashboard item entity 2023-11-08 12:59:01 +01:00
2997dff237 some setup in frontend 2023-11-08 12:23:10 +01:00
2624e44e2f api point for dashboard items 2023-11-08 12:18:11 +01:00
9ec1376d29 news item normalizer - to be generalized to dashboard item? 2023-11-08 12:16:57 +01:00
9591f1e49c some adjustments to news item 2023-11-08 12:16:33 +01:00
ddb90c2e41 adjust news item entity and recreate migration 2023-11-08 11:40:43 +01:00
e97571059c restructure json data 2023-11-07 15:16:40 +01:00
3a6d5fc22a create API for news item + testing if fetch works : to be generalized to accomodate other types of dashboard items 2023-11-01 16:26:19 +01:00
a542d319f7 create news item entity and the admin for it 2023-11-01 16:25:14 +01:00
4286a51bf4 create news item entity + migration 2023-10-26 11:31:39 +02:00
6893c833e4 WIP: first tests for building dav endpoints 2023-09-12 11:24:50 +02:00
267 changed files with 1825 additions and 5416 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
## v2.20.1 - 2024-06-05
### Fixed
* Do not allow StoredObjectCreated for edit and convert buttons

View File

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

View File

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

View File

@@ -6,107 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## 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
## v2.16.0 - 2024-02-08
### Feature
* ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span
* ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids
* ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this
* ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating
* ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address
* ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason
* ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work
* Modernize the event bundle, with some new fields and multiple improvements
### Fixed
* ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method
* ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form
### UX
* ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.
## v2.15.2 - 2024-01-11
### Fixed
* Fix the id_seq used when creating a new accompanying period participation during fusion of two person files

View File

@@ -76,7 +76,7 @@
"phpunit/phpunit": ">= 7.5",
"psalm/plugin-phpunit": "^0.18.4",
"psalm/plugin-symfony": "^4.0.2",
"rector/rector": "^1.1.0",
"rector/rector": "^0.17.7",
"symfony/debug-bundle": "^5.1",
"symfony/dotenv": "^4.4",
"symfony/maker-bundle": "^1.20",

View File

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

View File

@@ -8,16 +8,6 @@ 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
=========

View File

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

View File

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

View File

@@ -31,5 +31,4 @@ includes:
- phpstan-baseline-level-3.neon
- phpstan-baseline-level-4.neon
- phpstan-baseline-level-5.neon
- phpstan-baseline-2024-05.neon

View File

@@ -45,6 +45,9 @@ 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,

View File

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

View File

@@ -15,7 +15,6 @@ 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;
@@ -27,8 +26,7 @@ class CreatorScopeFilter implements FilterInterface
private const PREFIX = 'acp_act_filter_creator_scope';
public function __construct(
private readonly TranslatableStringHelper $translatableStringHelper,
private readonly ScopeRepositoryInterface $scopeRepository,
private readonly TranslatableStringHelper $translatableStringHelper
) {
}
@@ -78,7 +76,6 @@ class CreatorScopeFilter implements FilterInterface
$builder
->add('scopes', EntityType::class, [
'class' => Scope::class,
'choices' => $this->scopeRepository->findAllActive(),
'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize(
$s->getName()
),

View File

@@ -16,7 +16,6 @@ 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;
@@ -28,8 +27,7 @@ class UsersJobFilter implements FilterInterface
private const PREFIX = 'act_filter_user_job';
public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly UserJobRepositoryInterface $userJobRepository
private readonly TranslatableStringHelperInterface $translatableStringHelper
) {
}
@@ -71,7 +69,6 @@ class UsersJobFilter implements FilterInterface
$builder
->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()),
'multiple' => true,
'expanded' => true,

View File

@@ -15,10 +15,11 @@ 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\CollectionStoredObjectType;
use Chill\DocStoreBundle\Form\StoredObjectType;
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;
@@ -94,7 +95,7 @@ class ActivityType extends AbstractType
]);
}
/** @var AccompanyingPeriod|null $accompanyingPeriod */
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod|null $accompanyingPeriod */
$accompanyingPeriod = null;
if ($options['accompanyingPeriod'] instanceof AccompanyingPeriod) {
@@ -275,9 +276,16 @@ class ActivityType extends AbstractType
}
if ($activityType->isVisible('documents')) {
$builder->add('documents', CollectionStoredObjectType::class, [
$builder->add('documents', ChillCollectionType::class, [
'entry_type' => StoredObjectType::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],
]);
}

View File

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

View File

@@ -92,9 +92,7 @@
{% endif %}
{%- if edit_form.documents is defined -%}
{{ form_label(edit_form.documents) }}
{{ form_errors(edit_form.documents) }}
{{ form_widget(edit_form.documents) }}
{{ form_row(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 %}
@@ -129,4 +127,4 @@
{% block css %}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% endblock %}

View File

@@ -11,7 +11,6 @@ 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;
@@ -113,7 +112,7 @@ class ListActivitiesByAccompanyingPeriodContext implements
}
/**
* @return list<Activity>
* @return list
*/
private function filterActivitiesByUser(array $activities, User $user): array
{
@@ -121,12 +120,6 @@ 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);
@@ -136,7 +129,7 @@ class ListActivitiesByAccompanyingPeriodContext implements
}
/**
* @return list<AccompanyingPeriod\AccompanyingPeriodWork>
* @return list
*/
private function filterWorksByUser(array $works, User $user): array
{
@@ -223,15 +216,6 @@ class ListActivitiesByAccompanyingPeriodContext implements
foreach ($activities as $row) {
$activity = $row[0];
$user = match (null === $row['userId']) {
false => $this->userRepository->find($row['userId']),
true => null,
};
$activity['user'] = $this->normalizer->normalize($user, 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => User::class,
]);
$activity['date'] = $this->normalizer->normalize($activity['date'], 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => \DateTime::class,
]);

View File

@@ -91,29 +91,6 @@ 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
*/
@@ -324,10 +301,7 @@ class ActivityACLAwareRepositoryTest extends KernelTestCase
->getQuery()
->getResult()
) {
$job = new UserJob();
$job->setLabel(['fr' => 'test']);
$this->entityManager->persist($job);
$this->entityManager->flush();
throw new \RuntimeException('no jobs found');
}
if (null === $user = $this->entityManager

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ 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;
@@ -28,8 +27,7 @@ class ByUserJobFilter implements FilterInterface
private const PREFIX = 'aside_act_filter_user_job';
public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly UserJobRepositoryInterface $userJobRepository
private readonly TranslatableStringHelperInterface $translatableStringHelper
) {
}
@@ -71,7 +69,6 @@ class ByUserJobFilter implements FilterInterface
$builder
->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()),
'multiple' => true,
'expanded' => true,

View File

@@ -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 heures 30
2 hours 30: 2 heure 30
3 hours: 3 heures
3 hours 30: 3 heures 30
3 hours 30: 3 heure 30
4 hours: 4 heures
4 hours 30: 4 heures 30
4 hours 30: 4 heure 30
5 hours: 5 heures
5 hours 30: 5 heures 30
5 hours 30: 5 heure 30
6 hours: 6 heures
6 hours 30: 6 heures 30
6 hours 30: 6 heure 30
7 hours: 7 heures
7 hours 30: 7 heures 30
7 hours 30: 7 heure 30
8 hours: 8 heures
8 hours 30: 8 heures 30
8 hours 30: 8 heure 30
9 hours: 9 heures
9 hours 30: 9 heures 30
9 hours 30: 9 heure 30
10 hours: 10 heures
1/2 day: 1/2 jour
1 day: 1 jour

View File

@@ -49,6 +49,8 @@ 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;
@@ -91,6 +93,7 @@ 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.

View File

@@ -15,7 +15,6 @@ 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;
@@ -27,8 +26,7 @@ final readonly class JobFilter implements FilterInterface
private const PREFIX = 'cal_filter_job';
public function __construct(
private TranslatableStringHelper $translatableStringHelper,
private UserJobRepositoryInterface $userJobRepository
private TranslatableStringHelper $translatableStringHelper
) {
}
@@ -76,7 +74,6 @@ final readonly class JobFilter implements FilterInterface
$builder
->add('job', EntityType::class, [
'class' => UserJob::class,
'choices' => $this->userJobRepository->findAllActive(),
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize(
$j->getLabel()
),

View File

@@ -15,7 +15,6 @@ 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;
@@ -29,8 +28,7 @@ class ScopeFilter implements FilterInterface
public function __construct(
protected TranslatorInterface $translator,
private readonly TranslatableStringHelper $translatableStringHelper,
private readonly ScopeRepositoryInterface $scopeRepository
private readonly TranslatableStringHelper $translatableStringHelper
) {
}
@@ -78,7 +76,6 @@ class ScopeFilter implements FilterInterface
$builder
->add('scope', EntityType::class, [
'class' => Scope::class,
'choices' => $this->scopeRepository->findAllActive(),
'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize(
$s->getName()
),

View File

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

View File

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

View File

@@ -49,17 +49,20 @@ interface CustomFieldInterface
/**
* Return if the value can be considered as empty.
*
* @param mixed $value the value passed throug the deserialize function
*/
public function isEmptyValue(mixed $value, CustomField $customField);
public function isEmptyValue($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(mixed $value, CustomField $customField, $documentType = 'html');
public function render($value, CustomField $customField, $documentType = 'html');
/**
* Transform the value into a format that can be stored in DB.

View File

@@ -399,6 +399,8 @@ final class CustomFieldsChoiceTest extends KernelTestCase
/**
* @dataProvider emptyDataProvider
*
* @param mixed $data deserialized data
*/
public function testIsEmptyValueEmpty(mixed $data)
{

View File

@@ -16,42 +16,29 @@ 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\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
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 MessageBusInterface $messageBus,
private readonly PaginatorFactory $paginatorFactory,
private readonly EntityManagerInterface $entityManager,
private readonly ClockInterface $clock,
private readonly Security $security,
) {
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)
{
}
/**
@@ -176,7 +163,9 @@ final class DocGeneratorTemplateController extends AbstractController
throw new NotFoundHttpException(sprintf('Entity with classname %s and id %s is not found', $context->getEntityClass(), $entityId));
}
$contextGenerationData = [];
$contextGenerationData = [
'test_file' => null,
];
if (
$context instanceof DocGeneratorContextWithPublicFormInterface
@@ -186,39 +175,25 @@ final class DocGeneratorTemplateController extends AbstractController
$builder = $this->createFormBuilder(
array_merge(
$context->getFormData($template, $entity),
$isTest ? ['creator' => null, 'dump_only' => false, 'send_result_to' => ''] : []
$isTest ? ['test_file' => null, 'show_data' => false] : []
)
);
$context->buildPublicForm($builder, $template, $entity);
} else {
$builder = $this->createFormBuilder(
['creator' => null, 'show_data' => false, 'send_result_to' => '']
['test_file' => null, 'show_data' => false]
);
}
if ($isTest) {
$builder->add('dump_only', CheckboxType::class, [
'label' => 'docgen.Show data instead of generating',
$builder->add('test_file', FileType::class, [
'label' => 'Template file',
'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(),
],
$builder->add('show_data', CheckboxType::class, [
'label' => 'Show data instead of generating',
'required' => false,
]);
}
@@ -229,10 +204,8 @@ 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);
@@ -245,57 +218,60 @@ 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 (associate with the original entity, etc.)
// but only if this is not a test
if (!$isTest) {
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
}
// we store the generated document
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
$this->entityManager->flush();
if ($isTest) {
$creator = $contextGenerationData['creator'];
$sendResultTo = ($form ?? null)?->get('send_result_to')?->getData() ?? null;
$dumpOnly = ($form ?? null)?->get('dump_only')?->getData() ?? false;
} else {
$creator = $this->security->getUser();
if (!$creator instanceof User) {
throw new AccessDeniedHttpException('only authenticated user can request a generation');
}
$sendResultTo = null;
$dumpOnly = false;
}
$this->messageBus->dispatch(
new RequestGenerationMessage(
$creator,
$this->getUser(),
$template,
$entityId,
$storedObject,
$contextGenerationDataSanitized,
$isTest,
$sendResultTo,
$dumpOnly,
)
);

View File

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

View File

@@ -14,9 +14,10 @@ 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 DocGeneratorTemplateRepositoryInterface
final class DocGeneratorTemplateRepository implements ObjectRepository
{
private readonly EntityRepository $repository;

View File

@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocGeneratorBundle\Repository;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Doctrine\Persistence\ObjectRepository;
/**
* @extends ObjectRepository<DocGeneratorTemplate>
*/
interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository
{
public function countByEntity(string $entity): int;
}

View File

@@ -1,62 +1,36 @@
{% 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 %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="flex-basis:100%;">
<h2>{{ entity.name|localize_translatable_string }}</h2>
</div>
</div>
<div class="item-row">
<p><span class="badge bg-chill-green-dark">{{ contextManager.getContextByKey(entity.context).name|trans }}</span></p>
</div>
<div class="item-row">
<div class="item-col"></div>
<ul class="record_actions item-col flex-shrink-1">
<li>
<form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}">
<input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', app.request.uri)|e('html_attr') }}" />
<input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" />
<input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" />
<input type="text" name="entityId" placeholder="{{ 'docgen.entity_id_placeholder'|trans }}" required />
<button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button>
</form>
</li>
<li>
{{ entity.file|chill_document_button_group('Template file', true) }}
</li>
<li>
<a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
</ul>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% for entity in entities %}
<tr>
<td>{{ entity.id }}</td>
<td>{{ entity.name|localize_translatable_string}}</td>
<td>{{ contextManager.getContextByKey(entity.context).name|trans }}</td>
<td>
<form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}">
<input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', '/')|e('html_attr') }}" />
<input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" />
<input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" />
<input type="text" name="entityId" />
<button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button>
</form>
</td>
<td>
<a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</td>
</tr>
{% endfor %}
{% endblock %}
{% block actions_before %}

View File

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

View File

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

View File

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

View File

@@ -17,88 +17,54 @@ 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\Persistence\ManagerRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Yaml\Yaml;
use Symfony\Component\HttpFoundation\File\File;
class Generator implements GeneratorInterface
{
private const LOG_PREFIX = '[docgen generator] ';
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,
);
public function __construct(private readonly ContextManagerInterface $contextManager, private readonly DriverInterface $driver, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, private readonly StoredObjectManagerInterface $storedObjectManager)
{
}
/**
* @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,
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()) {
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null,
?User $creator = null
): ?string {
if ($destinationStoredObject instanceof StoredObject && 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' => $destinationStoredObject->getId(),
'destination_stored_object' => null === $destinationStoredObject ? null : $destinationStoredObject->getId(),
]);
$context = $this->contextManager->getContextByDocGeneratorTemplate($template);
$entity = $this
->objectManagerRegistry
->getManagerForClass($context->getEntityClass())
->entityManager
->find($context->getEntityClass(), $entityId)
;
@@ -116,47 +82,17 @@ class Generator implements GeneratorInterface
$data = $context->getData($template, $entity, $contextGenerationDataNormalized);
$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();
$destinationStoredObjectId = $destinationStoredObject instanceof StoredObject ? $destinationStoredObject->getId() : null;
$this->entityManager->clear();
gc_collect_cycles();
if (null !== $destinationStoredObjectId) {
$destinationStoredObject = $this->entityManager->find(StoredObject::class, $destinationStoredObjectId);
}
// 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 {
if ($isTest && ($testFile instanceof File)) {
$templateDecrypted = file_get_contents($testFile->getPathname());
} else {
$templateDecrypted = $this->storedObjectManager->read($template->getFile());
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
throw new GeneratorException([$e->getMessage()], $e);
}
try {
@@ -169,10 +105,19 @@ 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())
@@ -180,19 +125,15 @@ class Generator implements GeneratorInterface
->setStatus(StoredObject::STATUS_READY)
;
try {
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
throw new GeneratorException([$e->getMessage()], $e);
}
$this->entityManager->flush();
$this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => $destinationStoredObject->getId(),
]);
return $destinationStoredObject;
return null;
}
}

View File

@@ -13,48 +13,29 @@ 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
{
/**
* Generate a document and store the document on disk.
* @template T of File|null
* @template B of bool
*
* 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.
* @param B $isTest
* @param (B is true ? T : null) $testFile
*
* 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.
* @psalm-return (B is true ? string : null)
*
* 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
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
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;
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null,
?User $creator = null
): ?string;
}

View File

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

View File

@@ -11,11 +11,10 @@ declare(strict_types=1);
namespace Chill\DocGeneratorBundle\Service\Messenger;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorException;
use Chill\DocGeneratorBundle\tests\Service\Messenger\OnGenerationFailsTest;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -25,22 +24,12 @@ 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 DocGeneratorTemplateRepositoryInterface $docGeneratorTemplateRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private MailerInterface $mailer,
private StoredObjectRepositoryInterface $storedObjectRepository,
private TranslatorInterface $translator,
private UserRepositoryInterface $userRepository
) {
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 static function getSubscribedEvents()
@@ -56,12 +45,13 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
return;
}
$message = $event->getEnvelope()->getMessage();
if (!$message instanceof RequestGenerationMessage) {
if (!$event->getEnvelope()->getMessage() 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(),
@@ -89,8 +79,16 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
private function warnCreator(RequestGenerationMessage $message, WorkerMessageFailedEvent $event): void
{
if (null === $message->getSendResultToEmail() || '' === $message->getSendResultToEmail()) {
$this->logger->info(self::LOG_PREFIX.'No email associated with this request generation');
$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()]);
return;
}
@@ -98,7 +96,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 = $throwable->getPrevious();
}
if ($throwable instanceof GeneratorException) {
@@ -113,14 +111,8 @@ 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($message->getSendResultToEmail())
->to($creator->getEmail())
->subject($this->translator->trans('docgen.failure_email.The generation of a document failed'))
->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig')
->context([

View File

@@ -11,21 +11,15 @@ 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.
@@ -36,17 +30,8 @@ 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,
private readonly MailerInterface $mailer,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
private readonly TranslatorInterface $translator,
) {
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 __invoke(RequestGenerationMessage $message)
@@ -60,59 +45,25 @@ 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();
try {
if ($message->isDumpOnly()) {
$destinationStoredObject = $this->generator->generateDataDump(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
$creator
);
$this->sendDataDump($destinationStoredObject, $message);
} else {
$destinationStoredObject = $this->generator->generateDocFromTemplate(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
$creator
);
}
} catch (StoredObjectManagerException|GeneratorException $e) {
$this->entityManager->flush();
$this->logger->error(self::LOG_PREFIX.'Request generation failed', [
'template_id' => $message->getTemplateId(),
'destination_stored_object' => $message->getDestinationStoredObjectId(),
'trial' => $destinationStoredObject->getGenerationTrialsCounter(),
'error' => $e->getTraceAsString(),
]);
throw $e;
}
$this->entityManager->flush();
$this->generator->generateDocFromTemplate(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject,
false,
null,
$creator
);
$this->logger->info(self::LOG_PREFIX.'Request generation finished', [
'template_id' => $message->getTemplateId(),
@@ -120,23 +71,4 @@ class RequestGenerationHandler implements MessageHandlerInterface
'duration_int' => (new \DateTimeImmutable('now'))->getTimestamp() - $message->getCreatedAt()->getTimestamp(),
]);
}
private function sendDataDump(StoredObject $destinationStoredObject, RequestGenerationMessage $message): void
{
$url = $this->tempUrlGenerator->generate('GET', $destinationStoredObject->getFilename(), 3600);
$parts = [];
parse_str(parse_url((string) $url->url)['query'], $parts);
$validity = \DateTimeImmutable::createFromFormat('U', $parts['temp_url_expires']);
$email = (new TemplatedEmail())
->to($message->getSendResultToEmail())
->textTemplate('@ChillDocGenerator/Email/send_data_dump_to_admin.txt.twig')
->context([
'link' => $url->url,
'validity' => $validity,
])
->subject($this->translator->trans('docgen.data_dump_email.subject'));
$this->mailer->send($email);
}
}

View File

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

View File

@@ -20,9 +20,7 @@ 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;
@@ -68,11 +66,7 @@ class GeneratorTest extends TestCase
$entityManager->find('DummyClass', Argument::type('int'))
->willReturn($entity);
$entityManager->clear()->shouldBeCalled();
$entityManager->flush()->shouldNotBeCalled();
$managerRegistry = $this->prophesize(ManagerRegistry::class);
$managerRegistry->getManagerForClass('DummyClass')->willReturn($entityManager->reveal());
$managerRegistry->getManagerForClass(StoredObject::class)->willReturn($entityManager->reveal());
$entityManager->flush()->shouldBeCalled();
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($templateStoredObject)->willReturn('template');
@@ -81,7 +75,7 @@ class GeneratorTest extends TestCase
$generator = new Generator(
$contextManagerInterface->reveal(),
$driver->reveal(),
$managerRegistry->reveal(),
$entityManager->reveal(),
new NullLogger(),
$storedObjectManager->reveal()
);
@@ -90,8 +84,7 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject,
new User()
$destinationStoredObject
);
}
@@ -102,7 +95,7 @@ class GeneratorTest extends TestCase
$generator = new Generator(
$this->prophesize(ContextManagerInterface::class)->reveal(),
$this->prophesize(DriverInterface::class)->reveal(),
$this->prophesize(ManagerRegistry::class)->reveal(),
$this->prophesize(EntityManagerInterface::class)->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
@@ -115,8 +108,7 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject,
new User()
$destinationStoredObject
);
}
@@ -144,14 +136,10 @@ 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(),
$managerRegistry->reveal(),
$entityManager->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
@@ -160,8 +148,7 @@ class GeneratorTest extends TestCase
$template,
1,
[],
$destinationStoredObject,
new User()
$destinationStoredObject
);
}
}

View File

@@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocGeneratorBundle\tests\Service\Messenger;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Service\Messenger\OnAfterMessageHandledClearStoredObjectCache;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
/**
* @internal
*
* @coversNothing
*/
class OnAfterMessageHandledClearStoredObjectCacheTest extends TestCase
{
use ProphecyTrait;
public function testThatNotGenerationMessageDoesNotCallAClearCache(): void
{
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->clearCache()->shouldNotBeCalled();
$eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal());
$eventSubscriber->afterHandling($this->buildEventSuccess(new \stdClass()));
$eventSubscriber->afterFails($this->buildEventFailed(new \stdClass()));
}
public function testThatConcernedEventCallAClearCache(): void
{
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->clearCache()->shouldBeCalledTimes(2);
$eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal());
$eventSubscriber->afterHandling($this->buildEventSuccess($this->buildRequestGenerationMessage()));
$eventSubscriber->afterFails($this->buildEventFailed($this->buildRequestGenerationMessage()));
}
private function buildRequestGenerationMessage(
): RequestGenerationMessage {
$creator = new User();
$creator->setEmail('fake@example.com');
$class = new \ReflectionClass($creator);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($creator, 1);
$template ??= new DocGeneratorTemplate();
$class = new \ReflectionClass($template);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($template, 2);
$destinationStoredObject = new StoredObject();
$class = new \ReflectionClass($destinationStoredObject);
$property = $class->getProperty('id');
$property->setAccessible(true);
$property->setValue($destinationStoredObject, 3);
return new RequestGenerationMessage(
$creator,
$template,
1,
$destinationStoredObject,
[],
);
}
private function buildEventSubscriber(StoredObjectManagerInterface $storedObjectManager): OnAfterMessageHandledClearStoredObjectCache
{
return new OnAfterMessageHandledClearStoredObjectCache($storedObjectManager, new NullLogger());
}
private function buildEventFailed(object $message): WorkerMessageFailedEvent
{
$envelope = new Envelope($message);
return new WorkerMessageFailedEvent($envelope, 'testing', new \RuntimeException());
}
private function buildEventSuccess(object $message): WorkerMessageHandledEvent
{
$envelope = new Envelope($message);
return new WorkerMessageHandledEvent($envelope, 'test_receiver');
}
}

View File

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

View File

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

View File

@@ -14,31 +14,13 @@ 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:
@@ -46,4 +28,5 @@ crud:
add_new: Créer
Show data instead of generating: Montrer les données au lieu de générer le document
Template file: Fichier modèle

View File

@@ -25,11 +25,6 @@ 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")
@@ -48,14 +43,14 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
/**
* @ORM\Column(type="json", name="datas")
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private array $datas = [];
/**
* @ORM\Column(type="text")
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private string $filename = '';
@@ -66,7 +61,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*
* @ORM\Column(type="integer")
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private ?int $id = null;
@@ -75,35 +70,35 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*
* @ORM\Column(type="json", name="iv")
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private array $iv = [];
/**
* @ORM\Column(type="json", name="key")
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private array $keyInfos = [];
/**
* @ORM\Column(type="text", name="title")
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private string $title = '';
/**
* @ORM\Column(type="text", name="type", options={"default": ""})
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private string $type = '';
/**
* @ORM\Column(type="uuid", unique=true)
*
* @Serializer\Groups({"write"})
* @Serializer\Groups({"read", "write"})
*/
private UuidInterface $uuid;
@@ -122,21 +117,13 @@ 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'
) {
@@ -157,11 +144,6 @@ 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);
}
@@ -321,52 +303,4 @@ 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(),
];
}
}

View File

@@ -14,21 +14,47 @@ 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\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Templating\TranslatableStringHelper;
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;
final class AccompanyingCourseDocumentType extends AbstractType
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(
private readonly TranslatableStringHelperInterface $translatableStringHelper
TranslatableStringHelper $translatableStringHelper
) {
$this->translatableStringHelper = $translatableStringHelper;
}
public function buildForm(FormBuilderInterface $builder, array $options)

View File

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

View File

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

View File

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

View File

@@ -11,10 +11,11 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Form;
use ChampsLibres\AsyncUploaderBundle\Form\Type\AsyncUploaderType;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
use Doctrine\ORM\EntityManagerInterface;
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;
@@ -23,12 +24,16 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type which allow to join a document.
*/
final class StoredObjectType extends AbstractType
class StoredObjectType extends AbstractType
{
public function __construct(
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
private readonly StoredObjectDataMapper $storedObjectDataMapper,
) {
/**
* @var EntityManagerInterface
*/
protected $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function buildForm(FormBuilderInterface $builder, array $options)
@@ -40,9 +45,30 @@ final class StoredObjectType extends AbstractType
]);
}
$builder->add('stored_object', HiddenType::class);
$builder->get('stored_object')->addModelTransformer($this->storedObjectDataTransformer);
$builder->setDataMapper($this->storedObjectDataMapper);
$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(...)
));
}
public function configureOptions(OptionsResolver $resolver)
@@ -54,4 +80,43 @@ final 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;
}
}

View File

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

View File

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

View File

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

View File

@@ -17,20 +17,6 @@ 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 {
@@ -47,18 +33,3 @@ 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,
}

View File

@@ -1,16 +1,16 @@
<template>
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
<div v-if="'ready' === 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) && props.storedObject.status !== 'stored_object_created'">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf && props.storedObject.status !== 'stored_object_created'">
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
<li v-if="props.canDownload">
@@ -35,14 +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, StoredObjectCreated,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
StoredObject,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject|StoredObjectCreated,
storedObject: StoredObject,
small?: boolean,
canEdit?: boolean,
canDownload?: boolean,
@@ -99,7 +99,6 @@ 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
) {
@@ -112,11 +111,6 @@ 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);

View File

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

View File

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

View File

@@ -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, StoredObjectCreated} from "../../types";
import {StoredObject} from "../../types";
interface ConvertButtonConfig {
storedObject: StoredObject,

View File

@@ -13,10 +13,10 @@
import {reactive, ref, nextTick, onMounted} from "vue";
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
import mime from "mime";
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject} from "../../types";
interface DownloadButtonConfig {
storedObject: StoredObject|StoredObjectCreated,
storedObject: StoredObject,
classes: { [k: string]: boolean },
filename?: string,
}

View File

@@ -8,7 +8,7 @@
<script lang="ts" setup>
import WopiEditButton from "./WopiEditButton.vue";
import {build_wopi_editor_link} from "./helpers";
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
interface WopiEditButtonConfig {
storedObject: StoredObject,

View File

@@ -1,60 +0,0 @@
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import {PostStoreObjectSignature} from "../../types";
const algo = 'AES-CBC';
const URL_POST = '/asyncupload/temp_url/generate/post';
const keyDefinition = {
name: algo,
length: 256
};
const createFilename = (): string => {
var text = "";
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < 7; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
};
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
const params = new URLSearchParams();
params.append('expires_delay', "180");
params.append('submit_delay', "180");
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
const suffix = createFilename();
const filename = asyncData.prefix + suffix;
const formData = new FormData();
formData.append("redirect", asyncData.redirect);
formData.append("max_file_size", asyncData.max_file_size.toString());
formData.append("max_file_count", asyncData.max_file_count.toString());
formData.append("expires", asyncData.expires.toString());
formData.append("signature", asyncData.signature);
formData.append(filename, new Blob([uploadFile]), suffix);
const response = await window.fetch(asyncData.url, {
method: "POST",
body: formData,
})
if (!response.ok) {
console.error("Error while sending file to store", response);
throw new Error(response.statusText);
}
return Promise.resolve(filename);
}
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
console.log('encrypt', originalFile);
const iv = crypto.getRandomValues(new Uint8Array(16));
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
const encrypted = await window.crypto.subtle.encrypt({ name: algo, iv: iv}, key, originalFile);
return Promise.resolve([encrypted, iv, exportedKey]);
};

View File

@@ -1,7 +1,23 @@
{% block stored_object_widget %}
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
<div
data-stored-object="data-stored-object">
{{ form_widget(form.stored_object, { 'attr': { 'data-stored-object': 1 } }) }}
data-stored-object="data-stored-object"
data-label-preparing="{{ ('Preparing'|trans ~ '...')|escape('html_attr') }}"
data-label-quiet-button="{{ 'Download existing file'|trans|escape('html_attr') }}"
data-label-ready="{{ 'Ready to show'|trans|escape('html_attr') }}"
data-dict-file-too-big="{{ 'File too big'|trans|escape('html_attr') }}"
data-dict-default-message="{{ "Drop your file or click here"|trans|escape('html_attr') }}"
data-dict-remove-file="{{ 'Remove file in order to upload a new one'|trans|escape('html_attr') }}"
data-dict-max-files-exceeded="{{ 'Max files exceeded. Remove previous files'|trans|escape('html_attr') }}"
data-dict-cancel-upload="{{ 'Cancel upload'|trans|escape('html_attr') }}"
data-dict-cancel-upload-confirm="{{ 'Are you sure you want to cancel this upload ?'|trans|escape('html_attr') }}"
data-dict-upload-canceled="{{ 'Upload canceled'|trans|escape('html_attr') }}"
data-dict-remove="{{ 'Remove existing file'|trans|escape('html_attr') }}"
data-allow-remove="{% if required %}false{% else %}true{% endif %}"
data-temp-url-generator="{{ path('async_upload.generate_url', { 'method': 'GET' })|escape('html_attr') }}">
{{ form_widget(form.filename) }}
{{ form_widget(form.keyInfos, { 'attr': { 'data-stored-object-key': 1 } }) }}
{{ form_widget(form.iv, { 'attr': { 'data-stored-object-iv': 1 } }) }}
{{ form_widget(form.type, { 'attr': { 'data-async-file-type': 1 } }) }}
</div>
{% endblock %}

View File

@@ -1,62 +1,54 @@
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} {% set
activeRouteKey = '' %} {% block title %}
{{ "Documents" }}
{% endblock %} {% block js %}
{{ parent() }}
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
{{ encore_entry_script_tags("mod_document_action_buttons_group") }}
{% endblock %} {% block css %}
{{ parent() }}
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
{{ encore_entry_link_tags("mod_entity_workflow_pick") }}
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
{% endblock %} {% block content %}
<div class="document-list">
<h1>{{ "Documents" }}</h1>
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %}
{{ filter | chill_render_filter_order_helper }}
{% set activeRouteKey = '' %}
{% if documents|length > 5 %}
<div
data-docgen-template-picker="data-docgen-template-picker"
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
data-entity-id="{{ accompanyingCourse.id }}"
></div>
{% endif %} {% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
{% else %}
<div class="flex-table chill-task-list">
{% for document in documents %}
{{ document | chill_generic_doc_render }}
{% endfor %}
</div>
{% endif %}
{{ chill_pagination(pagination) }}
<div
data-docgen-template-picker="data-docgen-template-picker"
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
data-entity-id="{{ accompanyingCourse.id }}"
></div>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE',
accompanyingCourse) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a
href="{{
path('accompanying_course_document_new', {
course: accompanyingCourse.id
})
}}"
class="btn btn-create"
>
{{ "Create" | trans }}
</a>
</li>
</ul>
{% endif %}
</div>
{% block title %}
{{ 'Documents' }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}
<div class="document-list">
<h1>{{ 'Documents' }}</h1>
{{ filter|chill_render_filter_order_helper }}
{% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
{% else %}
<div class="flex-table chill-task-list">
{% for document in documents %}
{{ document|chill_generic_doc_render }}
{% endfor %}
</div>
{% endif %}
{{ chill_pagination(pagination) }}
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod" data-entity-id="{{ accompanyingCourse.id }}"></div>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a href="{{ path('accompanying_course_document_new', {'course': accompanyingCourse.id}) }}" class="btn btn-create">
{{ 'Create'|trans }}
</a>
</li>
</ul>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,70 +1,74 @@
{# * Copyright (C) 2018, Champs Libres Cooperative SCRLFS,
<http://www.champs-libres.coop> * * This program is free software: you can
redistribute it and/or modify * it under the terms of the GNU Affero General
Public License as * published by the Free Software Foundation, either version 3
of the * License, or (at your option) any later version. * * This program is
distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY;
without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the * GNU Affero General Public License for more
details. * * You should have received a copy of the GNU Affero General Public
License * along with this program. If not, see <http://www.gnu.org/licenses/>.
#} {% extends "@ChillPerson/Person/layout.html.twig" %} {% set activeRouteKey =
'' %} {% import "@ChillDocStore/Macro/macro.html.twig" as m %} {% block title %}
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
{% endblock %} {% block js %}
{{ parent() }}
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
{{ encore_entry_script_tags("mod_document_action_buttons_group") }}
{% endblock %} {% block css %}
{{ parent() }}
{{ encore_entry_link_tags("mod_docgen_picktemplate") }}
{{ encore_entry_link_tags("mod_entity_workflow_pick") }}
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
{% endblock %} {% block content %}
{#
* Copyright (C) 2018, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillPerson/Person/layout.html.twig" %}
{% set activeRouteKey = '' %}
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
{% block title %}
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}
<div class="col-md-10 col-xxl">
<h1>
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
</h1>
<h1>{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}</h1>
{{ filter | chill_render_filter_order_helper }}
{{ filter|chill_render_filter_order_helper }}
{% if documents|length > 5 %}
<div
data-docgen-template-picker="data-docgen-template-picker"
data-entity-class="Chill\PersonBundle\Entity\Person"
data-entity-id="{{ person.id }}"
></div>
{% endif %} {% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
{% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
{% else %}
<div class="flex-table chill-task-list">
{% for document in documents %}
{{ document | chill_generic_doc_render }}
{% endfor %}
</div>
<div class="flex-table chill-task-list">
{% for document in documents %}
{{ document|chill_generic_doc_render }}
{% endfor %}
</div>
{% endif %}
{{ chill_pagination(pagination) }}
{{ chill_pagination(pagination) }}
<div
data-docgen-template-picker="data-docgen-template-picker"
data-entity-class="Chill\PersonBundle\Entity\Person"
data-entity-id="{{ person.id }}"
></div>
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\Person" data-entity-id="{{ person.id }}"></div>
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a href="{{ path('person_document_new', {'person': person.id}) }}" class="btn btn-create">
{{ 'Create new document' | trans }}
</a>
</li>
</ul>
{% endif %}
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a
href="{{ path('person_document_new', { person: person.id }) }}"
class="btn btn-create"
>
{{ "Create new document" | trans }}
</a>
</li>
</ul>
{% endif %}
</div>
{% endblock %}

View File

@@ -29,7 +29,7 @@ class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator
TokenExtractorInterface $tokenExtractor,
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
TokenStorageInterface $preAuthenticationTokenStorage,
?TranslatorInterface $translator = null,
TranslatorInterface $translator = null,
) {
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
}

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Class StoredObjectNormalizer.
*
* Normalizes a StoredObject entity to an array of data.
*/
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
public function __construct(
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
private readonly UrlGeneratorInterface $urlGenerator
) {
}
public function normalize($object, ?string $format = null, array $context = [])
{
/** @var StoredObject $object */
$datas = [
'datas' => $object->getDatas(),
'filename' => $object->getFilename(),
'id' => $object->getId(),
'iv' => $object->getIv(),
'keyInfos' => $object->getKeyInfos(),
'title' => $object->getTitle(),
'type' => $object->getType(),
'uuid' => $object->getUuid(),
'status' => $object->getStatus(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
];
// deprecated property
$datas['creationDate'] = $datas['createdAt'];
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
if ($canDavSee || $canDavEdit) {
$accessToken = $this->JWTDavTokenProvider->createToken(
$object,
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
);
$datas['_links'] = [
'dav_link' => [
'href' => $this->urlGenerator->generate(
'chill_docstore_dav_document_get',
[
'uuid' => $object->getUuid(),
'access_token' => $accessToken,
],
UrlGeneratorInterface::ABSOLUTE_URL,
),
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
],
];
}
return $datas;
}
public function supportsNormalization($data, ?string $format = null)
{
return $data instanceof StoredObject && 'json' === $format;
}
}

View File

@@ -160,12 +160,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
)
: $clearContent;
$headers = [];
if (null !== $document->getDeleteAt()) {
$headers['X-Delete-At'] = $document->getDeleteAt()->getTimestamp();
}
try {
$response = $this
->client
@@ -180,7 +174,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->url,
[
'body' => $encryptedContent,
'headers' => $headers,
]
);
} catch (TransportExceptionInterface $exception) {
@@ -192,11 +185,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
}
}
public function clearCache(): void
{
$this->inMemory = [];
}
private function extractLastModifiedFromResponse(ResponseInterface $response): \DateTimeImmutable
{
$lastModifiedString = (($response->getHeaders()['last-modified'] ?? [])[0] ?? '');

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
interface StoredObjectManagerInterface
{
@@ -26,8 +25,6 @@ interface StoredObjectManagerInterface
* @param StoredObject $document the document
*
* @return string the retrieved content in clear
*
* @throws StoredObjectManagerException if unable to read or decrypt the content
*/
public function read(StoredObject $document): string;
@@ -36,12 +33,8 @@ interface StoredObjectManagerInterface
*
* @param StoredObject $document the document
* @param $clearContent The content to store in clear
*
* @throws StoredObjectManagerException
*/
public function write(StoredObject $document, string $clearContent): void;
public function etag(StoredObject $document): string;
public function clearCache(): void;
}

View File

@@ -407,8 +407,4 @@ class MockedStoredObjectManager implements StoredObjectManagerInterface
{
return 'ab56b4d92b40713acc5af89985d4b786';
}
public function clearCache(): void
{
}
}

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectTest extends KernelTestCase
{
public function testSaveHistory(): void
{
$storedObject = new StoredObject();
$storedObject
->setFilename('test_0')
->setIv([2, 4, 6, 8])
->setKeyInfos(['key' => ['data0' => 'data0']])
->setType('text/html');
$storedObject->saveHistory();
$storedObject
->setFilename('test_1')
->setIv([8, 10, 12])
->setKeyInfos(['key' => ['data1' => 'data1']])
->setType('text/text');
$storedObject->saveHistory();
self::assertEquals('test_0', $storedObject->getDatas()['history'][0]['filename']);
self::assertEquals([2, 4, 6, 8], $storedObject->getDatas()['history'][0]['iv']);
self::assertEquals(['key' => ['data0' => 'data0']], $storedObject->getDatas()['history'][0]['key_infos']);
self::assertEquals('text/html', $storedObject->getDatas()['history'][0]['type']);
self::assertEquals('test_1', $storedObject->getDatas()['history'][1]['filename']);
self::assertEquals([8, 10, 12], $storedObject->getDatas()['history'][1]['iv']);
self::assertEquals(['key' => ['data1' => 'data1']], $storedObject->getDatas()['history'][1]['key_infos']);
self::assertEquals('text/text', $storedObject->getDatas()['history'][1]['type']);
}
}

View File

@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Form;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
use Chill\DocStoreBundle\Form\StoredObjectType;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectTypeTest extends TypeTestCase
{
use ProphecyTrait;
public function testChangeTitleValue(): void
{
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
{"datas":[],"filename":"","id":null,"iv":[],"keyInfos":[],"title":"","type":"","uuid":"3c6a28fe-f913-40b9-a201-5eccc4f2d312","status":"ready","createdAt":null,"createdBy":null,"creationDate":null,"_links":{"dav_link":{"href":"http:\/\/url\/fake","expiration":"1716889578"}}}
JSON];
$model = new StoredObject();
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
$form->submit($formData);
$this->assertTrue($form->isSynchronized());
$this->assertEquals($newTitle, $model->getTitle());
}
public function testReplaceByAnotherObject(): void
{
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","status":"object_store_created"}
JSON];
$model = new StoredObject();
$originalObjectId = spl_object_hash($model);
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
$form->submit($formData);
$this->assertTrue($form->isSynchronized());
$model = $form->getData();
$this->assertEquals($originalObjectId, spl_object_hash($model));
$this->assertEquals('abcdef', $model->getFilename());
$this->assertEquals([10, 15, 20, 30], $model->getIv());
$this->assertEquals('text/html', $model->getType());
$this->assertEquals($newTitle, $model->getTitle());
}
protected function getExtensions()
{
$jwtTokenProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
$jwtTokenProvider->createToken(Argument::type(StoredObject::class), Argument::type(StoredObjectRoleEnum::class))
->willReturn('token');
$jwtTokenProvider->getTokenExpiration('token')->willReturn(new \DateTimeImmutable());
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL)
->willReturn('http://url/fake');
$serializer = new Serializer(
[
new StoredObjectNormalizer(
$jwtTokenProvider->reveal(),
$urlGenerator->reveal(),
),
],
[
new JsonEncoder(),
]
);
$dataTransformer = new StoredObjectDataTransformer($serializer);
$dataMapper = new StoredObjectDataMapper();
$type = new StoredObjectType(
$dataTransformer,
$dataMapper,
);
return [
new PreloadedExtension([$type], []),
];
}
}

View File

@@ -32,7 +32,7 @@ class StoredObjectVoterTest extends TestCase
/**
* @dataProvider provideDataVote
*/
public function testVote(TokenInterface $token, ?object $subject, string $attribute, mixed $expected): void
public function testVote(TokenInterface $token, null|object $subject, string $attribute, mixed $expected): void
{
$voter = new StoredObjectVoter();
@@ -98,7 +98,7 @@ class StoredObjectVoterTest extends TestCase
];
}
private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface
private function buildToken(StoredObjectRoleEnum $storedObjectRoleEnum = null, StoredObject $storedObject = null): TokenInterface
{
$token = $this->prophesize(TokenInterface::class);

View File

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

View File

@@ -3,6 +3,6 @@ module.exports = function(encore)
encore.addAliases({
ChillDocStoreAssets: __dirname + '/Resources/public'
});
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts');
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.js');
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
};

View File

@@ -1,18 +1,13 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\DocStoreBundle\Form\StoredObjectType:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
tags:
- { name: form.type }
Chill\DocStoreBundle\Form\StoredObjectType:
tags:
- { name: form.type }
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
tags:
- { name: form.type, alias: chill_docstorebundle_form_document }
Chill\DocStoreBundle\Form\DataMapper\:
resource: '../../Form/DataMapper'
Chill\DocStoreBundle\Form\DataTransformer\:
resource: '../../Form/DataTransformer'
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
class: Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType
arguments:
- "@chill.main.helper.translatable_string"
tags:
- { name: form.type, alias: chill_docstorebundle_form_document }

View File

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

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