mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-25 16:14:59 +00:00
Compare commits
30 Commits
295-cancel
...
v3.1.0
Author | SHA1 | Date | |
---|---|---|---|
1c0d334b91
|
|||
bc34d84d63
|
|||
f0f651edea
|
|||
5dfbdad13d
|
|||
b3e2d4ff9f | |||
01c2848a83
|
|||
d0ee381627
|
|||
8b1b255050
|
|||
5d0b531820 | |||
5be3cae288 | |||
4587f66402 | |||
2bef3c3878
|
|||
cea44d1788
|
|||
84069e03dc
|
|||
ad5e780936
|
|||
19accc4d00
|
|||
6cb085f5f7
|
|||
97239ada84
|
|||
643156f822 | |||
ff0b205591 | |||
2d67843901 | |||
2b09e1459c | |||
029524ba2c | |||
fa91e9494d | |||
4e72d6fea1
|
|||
5666b8b647
|
|||
|
0573f56782 | ||
|
3bee18b0fa | ||
|
843698a1d8 | ||
|
499640e48b |
@@ -1,8 +0,0 @@
|
||||
kind: Feature
|
||||
body: |-
|
||||
Electronic signature
|
||||
|
||||
Implementation of the electronic signature for documents within chill.
|
||||
time: 2024-06-14T15:32:36.875891692+02:00
|
||||
custom:
|
||||
Issue: ""
|
@@ -1,7 +0,0 @@
|
||||
kind: Feature
|
||||
body: The behavoir of the voters for stored objects is adjusted so as to limit edit
|
||||
and delete possibilities to users related to the activity, social action or workflow
|
||||
entity.
|
||||
time: 2024-06-14T15:35:37.582159301+02:00
|
||||
custom:
|
||||
Issue: "286"
|
@@ -1,5 +0,0 @@
|
||||
kind: Feature
|
||||
body: Metadata form added for person signatures
|
||||
time: 2024-07-18T15:12:33.8134266+02:00
|
||||
custom:
|
||||
Issue: "288"
|
@@ -1,11 +1,30 @@
|
||||
## v2.23.0 - 2024-07-23
|
||||
## v2.23.0 - 2024-07-23 & 2024-07-19
|
||||
### Feature
|
||||
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
|
||||
* Add job bundle (module emploi)
|
||||
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
|
||||
* Add job bundle (module emploi)
|
||||
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
|
||||
|
||||
|
||||
* Upgrade CKEditor and refactor configuration with use of typescript
|
||||
|
||||
|
||||
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
|
||||
* [admin] filter users by active / inactive in the admin user's list
|
||||
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
|
||||
|
||||
|
||||
* Handle duplicate reference id in the import of reference addresses
|
||||
* Do not update the "createdAt" column when importing postal code which does not change
|
||||
* Display filename on file upload within the UI interface
|
||||
### Fixed
|
||||
* Fix resolving of centers for an household, which will fix in turn the access control
|
||||
* Resolved type hinting error in activity list export
|
||||
* Fix resolving of centers for an household, which will fix in turn the access control
|
||||
* Resolved type hinting error in activity list export
|
||||
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
|
||||
|
||||
### Traduction française des principaux changements
|
||||
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
|
||||
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
|
||||
actifs sont affichés;
|
||||
- Nouveau bouton pour indiquer toutes les notifications comme lues;
|
||||
- Améliorations sur l'import des adresses et des codes postaux;
|
||||
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
|
||||
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
|
||||
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
|
||||
|
3
.changes/v2.24.0.md
Normal file
3
.changes/v2.24.0.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v2.24.0 - 2024-09-11
|
||||
### Feature
|
||||
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
|
3
.changes/v3.1.0.md
Normal file
3
.changes/v3.1.0.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v3.1.0 - 2024-08-30
|
||||
### Feature
|
||||
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.
|
@@ -122,7 +122,7 @@ unit_tests:
|
||||
- php tests/console chill:db:sync-views --env=test
|
||||
- php -d memory_limit=2G tests/console cache:clear --env=test
|
||||
- php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test
|
||||
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration
|
||||
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive
|
||||
artifacts:
|
||||
expire_in: 1 day
|
||||
paths:
|
||||
|
221
CHANGELOG.md
221
CHANGELOG.md
@@ -6,33 +6,62 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
|
||||
## v3.1.0 - 2024-08-30
|
||||
### Feature
|
||||
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.
|
||||
|
||||
## v3.0.0 - 2024-08-26
|
||||
### Fixed
|
||||
* Fix delete action for accompanying periods in draft state
|
||||
* Fix connection to azure when making an calendar event in chill
|
||||
* CollectionType js fixes for remove button and adding multiple entries
|
||||
* Fix delete action for accompanying periods in draft state
|
||||
* Fix connection to azure when making an calendar event in chill
|
||||
* CollectionType js fixes for remove button and adding multiple entries
|
||||
|
||||
## v2.23.0 - 2024-07-23
|
||||
## v2.24.0 - 2024-09-11
|
||||
### Feature
|
||||
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
|
||||
* Add job bundle (module emploi)
|
||||
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
|
||||
|
||||
* Upgrade CKEditor and refactor configuration with use of typescript
|
||||
|
||||
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
|
||||
|
||||
## v2.23.0 - 2024-07-19 & 2024-07-23
|
||||
### Feature
|
||||
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
|
||||
* [admin] filter users by active / inactive in the admin user's list
|
||||
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
|
||||
|
||||
|
||||
* Handle duplicate reference id in the import of reference addresses
|
||||
* Do not update the "createdAt" column when importing postal code which does not change
|
||||
* Display filename on file upload within the UI interface
|
||||
### Fixed
|
||||
* Fix resolving of centers for an household, which will fix in turn the access control
|
||||
* Resolved type hinting error in activity list export
|
||||
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
|
||||
|
||||
### Traduction française des principaux changements
|
||||
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
|
||||
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
|
||||
actifs sont affichés;
|
||||
- Nouveau bouton pour indiquer toutes les notifications comme lues;
|
||||
- Améliorations sur l'import des adresses et des codes postaux;
|
||||
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
|
||||
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
|
||||
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
|
||||
|
||||
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
|
||||
* Add job bundle (module emploi)
|
||||
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
|
||||
|
||||
* Upgrade CKEditor and refactor configuration with use of typescript
|
||||
|
||||
### Fixed
|
||||
* Fix resolving of centers for an household, which will fix in turn the access control
|
||||
* Resolved type hinting error in activity list export
|
||||
|
||||
## v2.22.2 - 2024-07-03
|
||||
### Fixed
|
||||
* Remove scope required for event participation stats
|
||||
* Remove scope required for event participation stats
|
||||
|
||||
## v2.22.1 - 2024-07-01
|
||||
### Fixed
|
||||
* Remove debug word
|
||||
* Remove debug word
|
||||
### DX
|
||||
* Add a command for reading official address DB from Luxembourg and update chill addresses
|
||||
* Add a command for reading official address DB from Luxembourg and update chill addresses
|
||||
|
||||
## v2.22.0 - 2024-06-25
|
||||
### Feature
|
||||
@@ -75,7 +104,7 @@ 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
|
||||
* Do not allow StoredObjectCreated for edit and convert buttons
|
||||
|
||||
## v2.20.0 - 2024-06-05
|
||||
### Fixed
|
||||
@@ -122,96 +151,96 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
## v2.18.2 - 2024-04-12
|
||||
### Fixed
|
||||
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
|
||||
* ([#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)
|
||||
* 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
|
||||
* ([#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
|
||||
* ([#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
|
||||
* ([#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
|
||||
* ([#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'
|
||||
* ([#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
|
||||
* ([#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
|
||||
* 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
|
||||
* 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
|
||||
* ([#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
|
||||
* ([#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.
|
||||
* ([#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
|
||||
* Fix the id_seq used when creating a new accompanying period participation during fusion of two person files
|
||||
### DX
|
||||
* Set placeholder to False for expanded EntityType form fields where required is set to False.
|
||||
* Set placeholder to False for expanded EntityType form fields where required is set to False.
|
||||
|
||||
## v2.15.1 - 2023-12-20
|
||||
### Fixed
|
||||
* Fix the household export query to exclude accompanying periods that are in draft state.
|
||||
* Fix the household export query to exclude accompanying periods that are in draft state.
|
||||
### DX
|
||||
* ([#167](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/167)) Fixed readthedocs compilation by updating readthedocs config file and requirements for Sphinx
|
||||
* ([#167](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/167)) Fixed readthedocs compilation by updating readthedocs config file and requirements for Sphinx
|
||||
|
||||
## v2.15.0 - 2023-12-11
|
||||
### Feature
|
||||
* ([#191](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/191)) Add export "number of household associate with an exchange"
|
||||
* ([#235](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/235)) Export: add dates on the filter "filter course by activity type"
|
||||
* ([#191](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/191)) Add export "number of household associate with an exchange"
|
||||
* ([#235](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/235)) Export: add dates on the filter "filter course by activity type"
|
||||
### Fixed
|
||||
* ([#214](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/214)) Fix error when posting an empty comment on an accompanying period.
|
||||
* ([#233](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/233)) Fix "filter evaluation by evaluation type" (and add select2 to the list of evaluation types to pick)
|
||||
* ([#214](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/214)) Fix error when posting an empty comment on an accompanying period.
|
||||
* ([#233](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/233)) Fix "filter evaluation by evaluation type" (and add select2 to the list of evaluation types to pick)
|
||||
* ([#234](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/234)) Fix "filter aside activity by date"
|
||||
|
||||
* ([#228](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/228)) Fix export of activity for people created before the introduction of the createdAt column on person (during v1)
|
||||
* ([#246](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/246)) Do not show activities, evaluations and social work when associated to a confidential accompanying period, except for the users which are allowed to see them
|
||||
|
||||
* ([#228](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/228)) Fix export of activity for people created before the introduction of the createdAt column on person (during v1)
|
||||
* ([#246](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/246)) Do not show activities, evaluations and social work when associated to a confidential accompanying period, except for the users which are allowed to see them
|
||||
|
||||
## v2.14.1 - 2023-11-29
|
||||
### Fixed
|
||||
* Export: fix list person with custom fields
|
||||
* ([#100](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/100)) Add a paginator to budget elements (resource and charge types) in the admin
|
||||
* Fix error in ListEvaluation when "handling agents" are alone
|
||||
* Export: fix list person with custom fields
|
||||
* ([#100](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/100)) Add a paginator to budget elements (resource and charge types) in the admin
|
||||
* Fix error in ListEvaluation when "handling agents" are alone
|
||||
|
||||
## v2.14.0 - 2023-11-24
|
||||
### Feature
|
||||
* ([#161](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/161)) Export: in filter "Filter accompanying period work (social action) by type, goal and result", order the items alphabetically or with the defined order
|
||||
* ([#161](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/161)) Export: in filter "Filter accompanying period work (social action) by type, goal and result", order the items alphabetically or with the defined order
|
||||
### Fixed
|
||||
* ([#141](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/141)) Export: on filter "action by type goals, and results", restore the fields when editing a saved export
|
||||
* ([#219](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/219)) Export: fix the list of accompanying period work, when the "calc date" is null
|
||||
* ([#222](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/222)) Fix rendering of custom fields
|
||||
* Fix various errors in custom fields administration
|
||||
* ([#141](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/141)) Export: on filter "action by type goals, and results", restore the fields when editing a saved export
|
||||
* ([#219](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/219)) Export: fix the list of accompanying period work, when the "calc date" is null
|
||||
* ([#222](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/222)) Fix rendering of custom fields
|
||||
* Fix various errors in custom fields administration
|
||||
|
||||
## v2.13.0 - 2023-11-21
|
||||
### Feature
|
||||
@@ -225,7 +254,7 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
## v2.12.1 - 2023-11-16
|
||||
### Fixed
|
||||
* ([#208](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/208)) Export: fix loading of form for "filter action by type, goal and result"
|
||||
* ([#208](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/208)) Export: fix loading of form for "filter action by type, goal and result"
|
||||
|
||||
## v2.12.0 - 2023-11-15
|
||||
### Feature
|
||||
@@ -256,36 +285,36 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
## v2.11.0 - 2023-11-07
|
||||
### Feature
|
||||
* ([#194](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/194)) Export: add a filter "filter activity by creator job"
|
||||
* ([#194](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/194)) Export: add a filter "filter activity by creator job"
|
||||
### Fixed
|
||||
* ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix "group accompanying period by geographical unit": take into account the accompanying periods when the period is not located within an unit
|
||||
* Fix "group activity by creator job" aggregator
|
||||
* ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix "group accompanying period by geographical unit": take into account the accompanying periods when the period is not located within an unit
|
||||
* Fix "group activity by creator job" aggregator
|
||||
|
||||
## v2.10.6 - 2023-11-07
|
||||
### Fixed
|
||||
* ([#182](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/182)) Fix merging of double person files. Adjustement relationship sql statement
|
||||
* ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix aggregator by geographical unit on person: avoid inconsistencies
|
||||
* ([#182](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/182)) Fix merging of double person files. Adjustement relationship sql statement
|
||||
* ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix aggregator by geographical unit on person: avoid inconsistencies
|
||||
|
||||
## v2.10.5 - 2023-11-05
|
||||
### Fixed
|
||||
* ([#183](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/183)) Fix "problem during download" on some filters, which used a wrong data type
|
||||
* ([#184](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/184)) Fix filter "activity by date"
|
||||
* ([#183](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/183)) Fix "problem during download" on some filters, which used a wrong data type
|
||||
* ([#184](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/184)) Fix filter "activity by date"
|
||||
|
||||
## v2.10.4 - 2023-10-26
|
||||
### Fixed
|
||||
* Fix null value constraint errors when merging relationships in doubles
|
||||
* Fix null value constraint errors when merging relationships in doubles
|
||||
|
||||
## v2.10.3 - 2023-10-26
|
||||
### Fixed
|
||||
* ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Replace old method of getting translator with injection of translatorInterface
|
||||
* ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Replace old method of getting translator with injection of translatorInterface
|
||||
|
||||
## v2.10.2 - 2023-10-26
|
||||
### Fixed
|
||||
* ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Use injection of translator instead of ->get().
|
||||
* ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Use injection of translator instead of ->get().
|
||||
|
||||
## v2.10.1 - 2023-10-24
|
||||
### Fixed
|
||||
* Fix export controller when generating an export without any data in session
|
||||
* Fix export controller when generating an export without any data in session
|
||||
|
||||
## v2.10.0 - 2023-10-24
|
||||
### Feature
|
||||
@@ -310,11 +339,11 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
## v2.9.2 - 2023-10-17
|
||||
### Fixed
|
||||
* Fix possible null values in string's entities
|
||||
* Fix possible null values in string's entities
|
||||
|
||||
## v2.9.1 - 2023-10-17
|
||||
### Fixed
|
||||
* Fix the handling of activity form when editing or creating an activity in an accompanying period with multiple centers
|
||||
* Fix the handling of activity form when editing or creating an activity in an accompanying period with multiple centers
|
||||
|
||||
## v2.9.0 - 2023-10-17
|
||||
### Feature
|
||||
@@ -362,57 +391,57 @@ But if you do not need this any more, you must ensure that the configuration key
|
||||
|
||||
## v2.7.0 - 2023-09-27
|
||||
### Feature
|
||||
* ([#155](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/155)) The regulation list load accompanying periods by exact postal code (address associated with postal code), and not by the content of the postal code (postal code with same code's string)
|
||||
* ([#155](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/155)) The regulation list load accompanying periods by exact postal code (address associated with postal code), and not by the content of the postal code (postal code with same code's string)
|
||||
### Fixed
|
||||
* ([#142](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/142)) Fix the label of filter ActivityTypeFilter to a more obvious one
|
||||
* ([#140](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/140)) [export] Fix association of filter "filter location by type" which did not appears on "list of activities"
|
||||
* ([#142](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/142)) Fix the label of filter ActivityTypeFilter to a more obvious one
|
||||
* ([#140](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/140)) [export] Fix association of filter "filter location by type" which did not appears on "list of activities"
|
||||
|
||||
## v2.6.3 - 2023-09-19
|
||||
### Fixed
|
||||
* Remove id property from document
|
||||
mappedsuperclass
|
||||
* Remove id property from document
|
||||
mappedsuperclass
|
||||
|
||||
## v2.6.2 - 2023-09-18
|
||||
### Fixed
|
||||
* Fix doctrine mapping of AbstractTaskPlaceEvent and SingleTaskPlaceEvent: id property moved.
|
||||
* Fix doctrine mapping of AbstractTaskPlaceEvent and SingleTaskPlaceEvent: id property moved.
|
||||
|
||||
## v2.6.1 - 2023-09-14
|
||||
### Fixed
|
||||
* Filter out active centers in exports, which uses a different PickCenterType.
|
||||
* Filter out active centers in exports, which uses a different PickCenterType.
|
||||
|
||||
## v2.6.0 - 2023-09-14
|
||||
### Feature
|
||||
* ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Add locations in Aside Activity. By default, suggest user location, otherwise a select with all locations.
|
||||
* ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Adapt Aside Activity exports: display location, filter by location, group by location
|
||||
* Use the CRUD controller for center entity + add the isActive property to be able to mask instances of Center that are no longer in use.
|
||||
* ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Add locations in Aside Activity. By default, suggest user location, otherwise a select with all locations.
|
||||
* ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Adapt Aside Activity exports: display location, filter by location, group by location
|
||||
* Use the CRUD controller for center entity + add the isActive property to be able to mask instances of Center that are no longer in use.
|
||||
### Fixed
|
||||
* ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) reinstate the fusion of duplicate persons
|
||||
* Missing translation in Work Actions exports
|
||||
* Reimplement the mission type filter on tasks, only for instances that have a config parameter indicating true for this.
|
||||
* ([#135](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/135)) Corrects a typing error in 2 filters, which caused an
|
||||
* ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) reinstate the fusion of duplicate persons
|
||||
* Missing translation in Work Actions exports
|
||||
* Reimplement the mission type filter on tasks, only for instances that have a config parameter indicating true for this.
|
||||
* ([#135](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/135)) Corrects a typing error in 2 filters, which caused an
|
||||
error when trying to reedit a saved export
|
||||
|
||||
|
||||
* ([#136](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/136)) [household] when moving a person to a sharing position to a not-sharing position on the same household on the same date, remove the previous household membership on the same household. This fix duplicate member.
|
||||
|
||||
* ([#136](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/136)) [household] when moving a person to a sharing position to a not-sharing position on the same household on the same date, remove the previous household membership on the same household. This fix duplicate member.
|
||||
* Add missing translation for comment field placeholder in repositionning household editor.
|
||||
|
||||
* Do not send an email to creator twice when adding a comment to a notification
|
||||
* ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) Fix gestion doublon functionality to work with chill bundles v2
|
||||
|
||||
* Do not send an email to creator twice when adding a comment to a notification
|
||||
* ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) Fix gestion doublon functionality to work with chill bundles v2
|
||||
### UX
|
||||
* Uniformize badge-person in household banner (background, size)
|
||||
|
||||
|
||||
|
||||
## v2.5.3 - 2023-07-20
|
||||
### Fixed
|
||||
* ([#132](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/132)) Rendez-vous documents created would appear in all documents lists of all persons with an accompanying period. Or statements are now added to the where clause to filter out documents that come from unrelated accompanying period/ or person rendez-vous.
|
||||
* ([#132](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/132)) Rendez-vous documents created would appear in all documents lists of all persons with an accompanying period. Or statements are now added to the where clause to filter out documents that come from unrelated accompanying period/ or person rendez-vous.
|
||||
|
||||
## v2.5.2 - 2023-07-15
|
||||
### Fixed
|
||||
* [Collate Address] when updating address point, do not use the point's address reference if the similarity is below the requirement for associating the address reference and the address (it uses the postcode's center instead)
|
||||
* [Collate Address] when updating address point, do not use the point's address reference if the similarity is below the requirement for associating the address reference and the address (it uses the postcode's center instead)
|
||||
|
||||
## v2.5.1 - 2023-07-14
|
||||
### Fixed
|
||||
* [collate addresses] block collating addresses to another address reference where the address reference is already the best match
|
||||
* [collate addresses] block collating addresses to another address reference where the address reference is already the best match
|
||||
|
||||
## v2.5.0 - 2023-07-14
|
||||
### Feature
|
||||
|
@@ -31,7 +31,6 @@
|
||||
"phpoffice/phpspreadsheet": "^1.16",
|
||||
"ramsey/uuid-doctrine": "^1.7",
|
||||
"sensio/framework-extra-bundle": "^5.5",
|
||||
"smalot/pdfparser": "^2.10",
|
||||
"spomky-labs/base64url": "^2.0",
|
||||
"symfony/asset": "^5.4",
|
||||
"symfony/browser-kit": "^5.4",
|
||||
@@ -43,6 +42,7 @@
|
||||
"symfony/dom-crawler": "^5.4",
|
||||
"symfony/error-handler": "^5.4",
|
||||
"symfony/event-dispatcher": "^5.4",
|
||||
"symfony/event-dispatcher-contracts": "^2.4",
|
||||
"symfony/expression-language": "^5.4",
|
||||
"symfony/filesystem": "^5.4",
|
||||
"symfony/finder": "^5.4",
|
||||
|
@@ -39,9 +39,12 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
|
||||
class MyCronJob implements CronJobInterface
|
||||
{
|
||||
function __construct(private ClockInterface $clock) {}
|
||||
|
||||
public function canRun(?CronJobExecution $cronJobExecution): bool
|
||||
{
|
||||
// the parameter $cronJobExecution contains data about the last execution of the cronjob
|
||||
@@ -56,7 +59,7 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
|
||||
|
||||
// this cron job should be executed if the last execution is greater than one day, but only during the night
|
||||
|
||||
$now = new DateTimeImmutable('now');
|
||||
$now = $clock->now();
|
||||
|
||||
return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D'))
|
||||
&& in_array($now->format('H'), self::ACCEPTED_HOURS, true)
|
||||
@@ -69,10 +72,15 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
|
||||
return 'arbitrary-and-unique-key';
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
public function run(array $lastExecutionData): void
|
||||
{
|
||||
// here, we execute the command
|
||||
}
|
||||
|
||||
// we return execution data, which will be served for next execution
|
||||
// this data should be easily serializable in a json column: it should contains
|
||||
// only int, string, etc. Avoid storing object
|
||||
return ['last-execution-id' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
How are cron job scheduled ?
|
||||
|
@@ -1,125 +0,0 @@
|
||||
|
||||
Enable CODE for development
|
||||
===========================
|
||||
|
||||
For editing a document, there must be a way to communicate between the collabora server and the symfony server, in
|
||||
both direction. The domain name should also be the same for collabora server and for the browser which access to the
|
||||
online editor.
|
||||
|
||||
Using ngrok (or other http tunnel)
|
||||
----------------------------------
|
||||
|
||||
One can configure a tunnel server to expose your local install to the web, and access to your local server using the
|
||||
tunnel url.
|
||||
|
||||
Start ngrok
|
||||
^^^^^^^^^^^
|
||||
|
||||
This can be achieve using `ngrok <https://ngrok.com/>`_.
|
||||
|
||||
.. note::
|
||||
|
||||
The configuration of ngrok is outside of the scope of this document. Refers to the ngrok's documentation.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# ensuring that your server is running through http and port 8000
|
||||
ngrok http 8000
|
||||
# then open the link given by the ngrok utility and you should reach your app
|
||||
|
||||
At this step, ensure that you can reach your local app using the ngrok url.
|
||||
|
||||
Configure Collabora
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The collabora server must be executed online and configure to access to your ngrok installation. Ensure that the aliasgroup
|
||||
exists for your ngrok application (`See the CODE documentation: <https://sdk.collaboraonline.com/docs/installation/Configuration.html#multihost-configuration>`_).
|
||||
|
||||
Configure your app
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Set the :code:`EDITOR_SERVER` variable to point to your collabora server, this should be done in your :code:`.env.local` file.
|
||||
|
||||
At this point, everything must be fine. In case of errors, watch the log from your collabora server, use the `profiler <https://symfony.com/doc/current/profiler.html>`_
|
||||
to debug the requests.
|
||||
|
||||
.. note::
|
||||
|
||||
In case of error while validating proof (you'll see those message in the collabora's logs), you can temporarily disable
|
||||
the proof validation adding this code snippet in `config/services.yaml`:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
when@dev:
|
||||
# add only in dev environment, to avoid security problems
|
||||
services:
|
||||
ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface:
|
||||
# this class will always validate proof
|
||||
alias: Chill\WopiBundle\Service\Wopi\NullProofValidator
|
||||
|
||||
With a local CODE image
|
||||
-----------------------
|
||||
|
||||
.. warning::
|
||||
|
||||
This configuration is not sure, and must be refined. The documentation does not seems to be entirely valid.
|
||||
|
||||
Use a local domain name and https for your app
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Use the proxy feature from embedded symfony server to run your app. `See the dedicated doc <https://symfony.com/doc/current/setup/symfony_server.html#local-domain-names>`
|
||||
|
||||
Configure also the `https certificate <https://symfony.com/doc/current/setup/symfony_server.html#enabling-tls>`_
|
||||
|
||||
In this example, your local domain name will be :code:`my-domain` and the url will be :code:`https://my-domain.wip`.
|
||||
|
||||
Ensure that the proxy is running.
|
||||
|
||||
Create a certificate database for collabora
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Collabora must validate your certificate generated by symfony console. For that, you need `to create a NSS database <https://sdk.collaboraonline.com/docs/installation/Configuration.html#validating-digital-signatures>`
|
||||
and configure collabora to use it.
|
||||
|
||||
At first, export the certificate for symfony development. Use the graphical interface from your browser to get the
|
||||
certificate as a PEM file.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# create your database in a custom directory
|
||||
mkdir /path/to/your/directory
|
||||
certutil -N -d /path/to/your/directory
|
||||
cat /path/to/your/ca.crt | certutil -d . -A symfony -t -t C,P,C,u,w -a
|
||||
|
||||
Launch CODE properly configured
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
collabora:
|
||||
image: collabora/code:latest
|
||||
environment:
|
||||
- SLEEPFORDEBUGGER=0
|
||||
- DONT_GEN_SSL_CERT="True"
|
||||
# add path to the database
|
||||
- extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:logging.level=7 -o:certificates.database_path=/etc/custom-certificates/nss-database
|
||||
- username=admin
|
||||
- password=admin
|
||||
- dictionaries=en_US
|
||||
- aliasgroup1=https://my-domain.wip
|
||||
ports:
|
||||
- "127.0.0.1:9980:9980"
|
||||
volumes:
|
||||
- "/path/to/your/directory/nss-database:/etc/custom-certificates/nss-database"
|
||||
extra_hosts:
|
||||
- "my-domain.wip:host-gateway"
|
||||
|
||||
Configure your app
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Into your :code:`.env.local` file:
|
||||
|
||||
.. code-block:: env
|
||||
|
||||
EDITOR_SERVER=http://${COLLABORA_HOST}:${COLLABORA_PORT}
|
||||
|
||||
At this step, you should be able to edit a document through collabora.
|
@@ -53,7 +53,7 @@
|
||||
"marked": "^12.0.2",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"mime": "^4.0.0",
|
||||
"pdfjs-dist": "^4.3.136",
|
||||
"swagger-ui": "^4.15.5",
|
||||
"vis-network": "^9.1.0",
|
||||
"vue": "^3.2.37",
|
||||
"vue-i18n": "^9.1.6",
|
||||
|
@@ -0,0 +1,99 @@
|
||||
<?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\Export\Aggregator\PersonAggregators;
|
||||
|
||||
use Chill\ActivityBundle\Export\Declarations;
|
||||
use Chill\MainBundle\Export\AggregatorInterface;
|
||||
use Chill\PersonBundle\Entity\Household\Household;
|
||||
use Chill\PersonBundle\Entity\Household\HouseholdMember;
|
||||
use Chill\PersonBundle\Repository\Household\HouseholdRepository;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final readonly class HouseholdAggregator implements AggregatorInterface
|
||||
{
|
||||
public function __construct(private HouseholdRepository $householdRepository) {}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder)
|
||||
{
|
||||
// nothing to add here
|
||||
}
|
||||
|
||||
public function getFormDefaultData(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getLabels($key, array $values, mixed $data)
|
||||
{
|
||||
return function (int|string|null $value): string|int {
|
||||
if ('_header' === $value) {
|
||||
return 'export.aggregator.person.by_household.household';
|
||||
}
|
||||
|
||||
if ('' === $value || null === $value || null === $household = $this->householdRepository->find($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $household->getId();
|
||||
};
|
||||
}
|
||||
|
||||
public function getQueryKeys($data)
|
||||
{
|
||||
return ['activity_household_agg'];
|
||||
}
|
||||
|
||||
public function getTitle()
|
||||
{
|
||||
return 'export.aggregator.person.by_household.title';
|
||||
}
|
||||
|
||||
public function addRole(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function alterQuery(QueryBuilder $qb, $data)
|
||||
{
|
||||
$qb->join(
|
||||
HouseholdMember::class,
|
||||
'activity_household_agg_household_member',
|
||||
Join::WITH,
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('activity_household_agg_household_member.person', 'activity.person'),
|
||||
$qb->expr()->lte('activity_household_agg_household_member.startDate', 'activity.date'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gte('activity_household_agg_household_member.endDate', 'activity.date'),
|
||||
$qb->expr()->isNull('activity_household_agg_household_member.endDate')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
$qb->join(
|
||||
Household::class,
|
||||
'activity_household_agg_household',
|
||||
Join::WITH,
|
||||
$qb->expr()->eq('activity_household_agg_household_member.household', 'activity_household_agg_household')
|
||||
);
|
||||
|
||||
$qb
|
||||
->addSelect('activity_household_agg_household.id AS activity_household_agg')
|
||||
->addGroupBy('activity_household_agg');
|
||||
}
|
||||
|
||||
public function applyOn()
|
||||
{
|
||||
return Declarations::ACTIVITY_PERSON;
|
||||
}
|
||||
}
|
@@ -19,6 +19,7 @@ use Chill\MainBundle\Export\FormatterInterface;
|
||||
use Chill\MainBundle\Export\GroupedExportInterface;
|
||||
use Chill\MainBundle\Export\ListInterface;
|
||||
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
|
||||
use Chill\PersonBundle\Entity\Household\HouseholdMember;
|
||||
use Chill\PersonBundle\Export\Declarations as PersonDeclarations;
|
||||
use Doctrine\DBAL\Exception\InvalidArgumentException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -44,6 +45,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
|
||||
'person_firstname',
|
||||
'person_lastname',
|
||||
'person_id',
|
||||
'household_id',
|
||||
];
|
||||
private readonly bool $filterStatsByCenters;
|
||||
|
||||
@@ -189,19 +191,26 @@ class ListActivity implements ListInterface, GroupedExportInterface
|
||||
{
|
||||
$centers = array_map(static fn ($el) => $el['center'], $acl);
|
||||
|
||||
// throw an error if any fields are present
|
||||
// throw an error if no fields are present
|
||||
if (!\array_key_exists('fields', $data)) {
|
||||
throw new InvalidArgumentException('Any fields have been checked.');
|
||||
throw new InvalidArgumentException('No fields have been checked.');
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->createQueryBuilder();
|
||||
|
||||
$qb
|
||||
->from('ChillActivityBundle:Activity', 'activity')
|
||||
->join('activity.person', 'actperson');
|
||||
->join('activity.person', 'person')
|
||||
->join(
|
||||
HouseholdMember::class,
|
||||
'householdmember',
|
||||
Query\Expr\Join::WITH,
|
||||
'person = householdmember.person AND householdmember.startDate <= activity.date AND (householdmember.endDate IS NULL OR householdmember.endDate > activity.date)'
|
||||
)
|
||||
->join('householdmember.household', 'household');
|
||||
|
||||
if ($this->filterStatsByCenters) {
|
||||
$qb->join('actperson.centerHistory', 'centerHistory');
|
||||
$qb->join('person.centerHistory', 'centerHistory');
|
||||
$qb->where(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->lte('centerHistory.startDate', 'activity.date'),
|
||||
@@ -224,17 +233,22 @@ class ListActivity implements ListInterface, GroupedExportInterface
|
||||
break;
|
||||
|
||||
case 'person_firstname':
|
||||
$qb->addSelect('actperson.firstName AS person_firstname');
|
||||
$qb->addSelect('person.firstName AS person_firstname');
|
||||
|
||||
break;
|
||||
|
||||
case 'person_lastname':
|
||||
$qb->addSelect('actperson.lastName AS person_lastname');
|
||||
$qb->addSelect('person.lastName AS person_lastname');
|
||||
|
||||
break;
|
||||
|
||||
case 'person_id':
|
||||
$qb->addSelect('actperson.id AS person_id');
|
||||
$qb->addSelect('person.id AS person_id');
|
||||
|
||||
break;
|
||||
|
||||
case 'household_id':
|
||||
$qb->addSelect('household.id AS household_id');
|
||||
|
||||
break;
|
||||
|
||||
@@ -284,7 +298,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
|
||||
return ActivityStatsVoter::LISTS;
|
||||
}
|
||||
|
||||
public function supportsModifiers()
|
||||
public function supportsModifiers(): array
|
||||
{
|
||||
return [
|
||||
Declarations::ACTIVITY,
|
||||
|
@@ -73,7 +73,7 @@ final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInt
|
||||
|
||||
$qb->andWhere(
|
||||
$qb->expr()->exists(
|
||||
'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod"
|
||||
'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = acp"
|
||||
)
|
||||
);
|
||||
|
||||
|
@@ -39,7 +39,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
return null;
|
||||
}
|
||||
|
||||
public function alterQuery(QueryBuilder $qb, $data)
|
||||
public function alterQuery(QueryBuilder $qb, $data): void
|
||||
{
|
||||
// create a subquery for activity
|
||||
$sqb = $qb->getEntityManager()->createQueryBuilder();
|
||||
@@ -121,7 +121,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
];
|
||||
}
|
||||
|
||||
public function describeAction($data, $format = 'string')
|
||||
public function describeAction($data, $format = 'string'): array
|
||||
{
|
||||
return [
|
||||
[] === $data['reasons'] ?
|
||||
@@ -141,7 +141,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle()
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'export.filter.activity.person_between_dates.title';
|
||||
}
|
||||
|
@@ -12,8 +12,6 @@ declare(strict_types=1);
|
||||
namespace Chill\ActivityBundle\Repository;
|
||||
|
||||
use Chill\ActivityBundle\Entity\Activity;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
|
||||
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
@@ -25,7 +23,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
* @method Activity[] findAll()
|
||||
* @method Activity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class ActivityRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
|
||||
class ActivityRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
@@ -99,16 +97,4 @@ class ActivityRepository extends ServiceEntityRepository implements AssociatedEn
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Activity
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a');
|
||||
$query = $qb
|
||||
->leftJoin('a.documents', 'ad')
|
||||
->where('ad.id = :storedObjectId')
|
||||
->setParameter('storedObjectId', $storedObject->getId())
|
||||
->getQuery();
|
||||
|
||||
return $query->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
@@ -1,54 +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\Security\Authorization;
|
||||
|
||||
use Chill\ActivityBundle\Entity\Activity;
|
||||
use Chill\ActivityBundle\Repository\ActivityRepository;
|
||||
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
|
||||
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityRepository $repository,
|
||||
Security $security,
|
||||
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
|
||||
) {
|
||||
parent::__construct($security, $workflowDocumentService);
|
||||
}
|
||||
|
||||
protected function getRepository(): AssociatedEntityToStoredObjectInterface
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
||||
protected function getClass(): string
|
||||
{
|
||||
return Activity::class;
|
||||
}
|
||||
|
||||
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
|
||||
{
|
||||
return match ($attribute) {
|
||||
StoredObjectRoleEnum::EDIT => ActivityVoter::UPDATE,
|
||||
StoredObjectRoleEnum::SEE => ActivityVoter::SEE_DETAILS,
|
||||
};
|
||||
}
|
||||
|
||||
protected function canBeAssociatedWithWorkflow(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
@@ -243,3 +243,7 @@ services:
|
||||
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator:
|
||||
tags:
|
||||
- { name: chill.export_aggregator, alias: activity_person_agg }
|
||||
|
||||
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\HouseholdAggregator:
|
||||
tags:
|
||||
- { name: chill.export_aggregator, alias: activity_household_agg }
|
||||
|
@@ -428,6 +428,9 @@ export:
|
||||
by_person:
|
||||
title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré)
|
||||
person: Usager
|
||||
by_household:
|
||||
title: Grouper les échanges par ménage
|
||||
household: Identifiant ménage
|
||||
acp:
|
||||
by_activity_type:
|
||||
title: Grouper les parcours par type d'échange
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<label class="form-label">{{ $t('created_availabilities') }}</label>
|
||||
<label class="form-label">{{ $t("created_availabilities") }}</label>
|
||||
<vue-multiselect
|
||||
v-model="pickedLocation"
|
||||
:options="locations"
|
||||
@@ -14,10 +14,15 @@
|
||||
></vue-multiselect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="display-options row justify-content-between" style="margin-top: 1rem;">
|
||||
<div
|
||||
class="display-options row justify-content-between"
|
||||
style="margin-top: 1rem"
|
||||
>
|
||||
<div class="col-sm-9 col-xs-12">
|
||||
<div class="input-group mb-3">
|
||||
<label class="input-group-text" for="slotDuration">Durée des créneaux</label>
|
||||
<label class="input-group-text" for="slotDuration"
|
||||
>Durée des créneaux</label
|
||||
>
|
||||
<select v-model="slotDuration" id="slotDuration" class="form-select">
|
||||
<option value="00:05:00">5 minutes</option>
|
||||
<option value="00:10:00">10 minutes</option>
|
||||
@@ -58,13 +63,20 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 col-xs-12">
|
||||
<div class="col-xs-12 col-sm-3">
|
||||
<div class="float-end">
|
||||
<div class="form-check input-group">
|
||||
<span class="input-group-text">
|
||||
<input id="showHideWE" class="mt-0" type="checkbox" v-model="showWeekends">
|
||||
<input
|
||||
id="showHideWE"
|
||||
class="mt-0"
|
||||
type="checkbox"
|
||||
v-model="showWeekends"
|
||||
/>
|
||||
</span>
|
||||
<label for="showHideWE" class="form-check-label input-group-text">Week-ends</label>
|
||||
<label for="showHideWE" class="form-check-label input-group-text"
|
||||
>Week-ends</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,39 +84,86 @@
|
||||
<FullCalendar :options="calendarOptions" ref="calendarRef">
|
||||
<template v-slot:eventContent="arg: EventApi">
|
||||
<span :class="eventClasses(arg.event)">
|
||||
<b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b>
|
||||
<b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b>
|
||||
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b>
|
||||
<b v-else >no 'is'</b>
|
||||
<a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete"
|
||||
@click.prevent="onClickDelete(arg.event)">
|
||||
</a>
|
||||
</span>
|
||||
<b v-if="arg.event.extendedProps.is === 'remote'">{{
|
||||
arg.event.title
|
||||
}}</b>
|
||||
<b v-else-if="arg.event.extendedProps.is === 'range'"
|
||||
>{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b
|
||||
>
|
||||
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
|
||||
arg.event.title
|
||||
}}</b>
|
||||
<b v-else>no 'is'</b>
|
||||
<a
|
||||
v-if="arg.event.extendedProps.is === 'range'"
|
||||
class="fa fa-fw fa-times delete"
|
||||
@click.prevent="onClickDelete(arg.event)"
|
||||
>
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
</FullCalendar>
|
||||
|
||||
<div id="copy-widget">
|
||||
<div class="container">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-sm-4 col-xs-12">
|
||||
<h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6>
|
||||
</div>
|
||||
<div class="col-sm-3 col-xs-12">
|
||||
<input class="form-control" type="date" v-model="copyFrom" />
|
||||
</div>
|
||||
<div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;">
|
||||
<i class="fa fa-angle-double-right"></i>
|
||||
</div>
|
||||
<div class="col-sm-3 col-xs-12" >
|
||||
<input class="form-control" type="date" v-model="copyTo" />
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button class="btn btn-action" @click="copyDay">
|
||||
{{ $t('copy_range') }}
|
||||
</button>
|
||||
<div class="container mt-2 mb-2">
|
||||
|
||||
<div class="row justify-content-between align-items-center mb-4">
|
||||
<div class="col-xs-12 col-sm-3 col-md-2">
|
||||
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-9 col-md-2">
|
||||
<select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
|
||||
<option value="day">{{ $t("from_day_to_day") }}</option>
|
||||
<option value="week">{{ $t("from_week_to_week") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="dayOrWeek === 'day'">
|
||||
<div class="col-xs-12 col-sm-3 col-md-3">
|
||||
<input class="form-control" type="date" v-model="copyFrom" />
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
|
||||
<i class="fa fa-angle-double-right"></i>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-3 col-md-3">
|
||||
<input class="form-control" type="date" v-model="copyTo" />
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-5 col-md-1">
|
||||
<button class="btn btn-action float-end" @click="copyDay">
|
||||
{{ $t("copy_range") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="col-xs-12 col-sm-3 col-md-3">
|
||||
<select
|
||||
v-model="copyFromWeek"
|
||||
id="copyFromWeek"
|
||||
class="form-select"
|
||||
>
|
||||
<option v-for="w in lastWeeks" :value="w.value" :key="w.value">
|
||||
{{ w.text }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
|
||||
<i class="fa fa-angle-double-right"></i>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-3 col-md-3">
|
||||
<select v-model="copyToWeek" id="copyToWeek" class="form-select">
|
||||
<option v-for="w in nextWeeks" :value="w.value" :key="w.value">
|
||||
{{ w.text }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-5 col-md-1">
|
||||
<button class="btn btn-action float-end" @click="copyWeek">
|
||||
{{ $t("copy_range") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- not directly seen, but include in a modal -->
|
||||
@@ -112,42 +171,95 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import type {
|
||||
CalendarOptions,
|
||||
DatesSetArg,
|
||||
EventInput
|
||||
} from '@fullcalendar/core';
|
||||
import {reactive, computed, ref} from "vue";
|
||||
import {useStore} from "vuex";
|
||||
import {key} from './store';
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import frLocale from '@fullcalendar/core/locales/fr';
|
||||
import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction";
|
||||
EventInput,
|
||||
} from "@fullcalendar/core";
|
||||
import { reactive, computed, ref, onMounted } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { key } from "./store";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import frLocale from "@fullcalendar/core/locales/fr";
|
||||
import interactionPlugin, {
|
||||
DropArg,
|
||||
EventResizeDoneArg,
|
||||
} from "@fullcalendar/interaction";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core";
|
||||
import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
|
||||
import {
|
||||
EventApi,
|
||||
DateSelectArg,
|
||||
EventDropArg,
|
||||
EventClickArg,
|
||||
} from "@fullcalendar/core";
|
||||
import {
|
||||
dateToISO,
|
||||
ISOToDate,
|
||||
} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
|
||||
import VueMultiselect from "vue-multiselect";
|
||||
import {Location} from "../../../../../ChillMainBundle/Resources/public/types";
|
||||
import { Location } from "../../../../../ChillMainBundle/Resources/public/types";
|
||||
import EditLocation from "./Components/EditLocation.vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const store = useStore(key);
|
||||
|
||||
const {t} = useI18n();
|
||||
const { t } = useI18n();
|
||||
|
||||
const showWeekends = ref(false);
|
||||
const slotDuration = ref('00:05:00');
|
||||
const slotMinTime = ref('09:00:00');
|
||||
const slotMaxTime = ref('18:00:00');
|
||||
const slotDuration = ref("00:15:00");
|
||||
const slotMinTime = ref("09:00:00");
|
||||
const slotMaxTime = ref("18:00:00");
|
||||
const copyFrom = ref<string | null>(null);
|
||||
const copyTo = ref<string | null>(null);
|
||||
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null)
|
||||
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null);
|
||||
const dayOrWeek = ref("day");
|
||||
const copyFromWeek = ref<string | null>(null);
|
||||
const copyToWeek = ref<string | null>(null);
|
||||
|
||||
interface Weeks {
|
||||
value: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const getMonday = (week: number): Date => {
|
||||
const lastMonday = new Date();
|
||||
lastMonday.setDate(
|
||||
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7
|
||||
);
|
||||
return lastMonday;
|
||||
};
|
||||
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
const lastWeeks = computed((): Weeks[] =>
|
||||
Array.from(Array(30).keys()).map((w) => {
|
||||
const lastMonday = getMonday(15-w);
|
||||
return {
|
||||
value: dateToISO(lastMonday),
|
||||
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const nextWeeks = computed((): Weeks[] =>
|
||||
Array.from(Array(52).keys()).map((w) => {
|
||||
const nextMonday = getMonday(w + 1);
|
||||
return {
|
||||
value: dateToISO(nextMonday),
|
||||
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const baseOptions = ref<CalendarOptions>({
|
||||
locale: frLocale,
|
||||
plugins: [interactionPlugin, timeGridPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
initialView: "timeGridWeek",
|
||||
initialDate: new Date(),
|
||||
scrollTimeReset: false,
|
||||
selectable: true,
|
||||
@@ -164,9 +276,9 @@ const baseOptions = ref<CalendarOptions>({
|
||||
selectMirror: false,
|
||||
editable: true,
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridWeek,timeGridDay'
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "timeGridWeek,timeGridDay",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,20 +292,23 @@ const locations = computed<Location[]>(() => {
|
||||
|
||||
const pickedLocation = computed<Location | null>({
|
||||
get(): Location | null {
|
||||
return store.state.locations.locationPicked || store.state.locations.currentLocation;
|
||||
return (
|
||||
store.state.locations.locationPicked ||
|
||||
store.state.locations.currentLocation
|
||||
);
|
||||
},
|
||||
set(newLocation: Location | null): void {
|
||||
store.commit('locations/setLocationPicked', newLocation, {root: true});
|
||||
}
|
||||
})
|
||||
store.commit("locations/setLocationPicked", newLocation, { root: true });
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* return the show classes for the event
|
||||
* @param arg
|
||||
*/
|
||||
const eventClasses = function(arg: EventApi): object {
|
||||
return {'calendarRangeItems': true};
|
||||
}
|
||||
const eventClasses = function (arg: EventApi): object {
|
||||
return { calendarRangeItems: true };
|
||||
};
|
||||
|
||||
/*
|
||||
// currently, all events are stored into calendarRanges, due to reactivity bug
|
||||
@@ -230,51 +345,60 @@ const calendarOptions = computed((): CalendarOptions => {
|
||||
* launched when the calendar range date change
|
||||
*/
|
||||
function onDatesSet(event: DatesSetArg): void {
|
||||
store.dispatch('fullCalendar/setCurrentDatesView', {start: event.start, end: event.end});
|
||||
store.dispatch("fullCalendar/setCurrentDatesView", {
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
});
|
||||
}
|
||||
|
||||
function onDateSelect(event: DateSelectArg): void {
|
||||
|
||||
if (null === pickedLocation.value) {
|
||||
window.alert("Indiquez une localisation avant de créer une période de disponibilité.");
|
||||
window.alert(
|
||||
"Indiquez une localisation avant de créer une période de disponibilité."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('calendarRanges/createRange', {start: event.start, end: event.end, location: pickedLocation.value});
|
||||
store.dispatch("calendarRanges/createRange", {
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: pickedLocation.value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When a calendar range is deleted
|
||||
*/
|
||||
function onClickDelete(event: EventApi): void {
|
||||
console.log('onClickDelete', event);
|
||||
|
||||
if (event.extendedProps.is !== 'range') {
|
||||
if (event.extendedProps.is !== "range") {
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId);
|
||||
store.dispatch(
|
||||
"calendarRanges/deleteRange",
|
||||
event.extendedProps.calendarRangeId
|
||||
);
|
||||
}
|
||||
|
||||
function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) {
|
||||
if (payload.event.extendedProps.is !== 'range') {
|
||||
if (payload.event.extendedProps.is !== "range") {
|
||||
return;
|
||||
}
|
||||
const changedEvent = payload.event;
|
||||
|
||||
store.dispatch('calendarRanges/patchRangeTime', {
|
||||
store.dispatch("calendarRanges/patchRangeTime", {
|
||||
calendarRangeId: payload.event.extendedProps.calendarRangeId,
|
||||
start: payload.event.start,
|
||||
end: payload.event.end,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function onEventClick(payload: EventClickArg): void {
|
||||
// @ts-ignore TS does not recognize the target. But it does exists.
|
||||
if (payload.jsEvent.target.classList.contains('delete')) {
|
||||
if (payload.jsEvent.target.classList.contains("delete")) {
|
||||
return;
|
||||
}
|
||||
if (payload.event.extendedProps.is !== 'range') {
|
||||
if (payload.event.extendedProps.is !== "range") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -285,10 +409,26 @@ function copyDay() {
|
||||
if (null === copyFrom.value || null === copyTo.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)})
|
||||
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
|
||||
from: ISOToDate(copyFrom.value),
|
||||
to: ISOToDate(copyTo.value),
|
||||
});
|
||||
}
|
||||
|
||||
function copyWeek() {
|
||||
if (null === copyFromWeek.value || null === copyToWeek.value) {
|
||||
return;
|
||||
}
|
||||
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
|
||||
fromMonday: ISOToDate(copyFromWeek.value),
|
||||
toMonday: ISOToDate(copyToWeek.value),
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
copyFromWeek.value = dateToISO(getMonday(0));
|
||||
copyToWeek.value = dateToISO(getMonday(1));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -299,4 +439,9 @@ function copyDay() {
|
||||
z-index: 9999999999;
|
||||
padding: 0.25rem 0 0.25rem;
|
||||
}
|
||||
div.copy-chevron {
|
||||
text-align: center;
|
||||
font-size: x-large;
|
||||
width: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
@@ -5,11 +5,9 @@ const appMessages = {
|
||||
show_my_calendar: "Afficher mon calendrier",
|
||||
show_weekends: "Afficher les week-ends",
|
||||
copy_range: "Copier",
|
||||
copy_range_from_to: "Copier les plages d'un jour à l'autre",
|
||||
copy_range_to_next_day: "Copier les plages du jour au jour suivant",
|
||||
copy_range_from_day: "Copier les plages du ",
|
||||
to_the_next_day: " au jour suivant",
|
||||
copy_range_to_next_week: "Copier les plages de la semaine à la semaine suivante",
|
||||
copy_range_from_to: "Copier les plages",
|
||||
from_day_to_day: "d'un jour à l'autre",
|
||||
from_week_to_week: "d'une semaine à l'autre",
|
||||
copy_range_how_to: "Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.",
|
||||
new_range_to_save: "Nouvelles plages à enregistrer",
|
||||
update_range_to_save: "Plages à modifier",
|
||||
|
@@ -52,6 +52,23 @@ export default <Module<CalendarRangesState, State>>{
|
||||
}
|
||||
}
|
||||
|
||||
return founds;
|
||||
},
|
||||
getRangesOnWeek: (state: CalendarRangesState) => (mondayDate: Date): EventInputCalendarRange[] => {
|
||||
const founds = [];
|
||||
for (let d of Array.from(Array(7).keys())) {
|
||||
const dateOfWeek = new Date(mondayDate);
|
||||
dateOfWeek.setDate(mondayDate.getDate() + d);
|
||||
const dateStr = <string>dateToISO(dateOfWeek);
|
||||
for (let range of state.ranges) {
|
||||
if (isEventInputCalendarRange(range)
|
||||
&& range.start.startsWith(dateStr)
|
||||
) {
|
||||
founds.push(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return founds;
|
||||
},
|
||||
},
|
||||
@@ -238,7 +255,7 @@ export default <Module<CalendarRangesState, State>>{
|
||||
|
||||
for (let r of rangesToCopy) {
|
||||
let start = new Date(<Date>ISOToDatetime(r.start));
|
||||
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate())
|
||||
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
|
||||
let end = new Date(<Date>ISOToDatetime(r.end));
|
||||
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
|
||||
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
|
||||
@@ -246,6 +263,23 @@ export default <Module<CalendarRangesState, State>>{
|
||||
promises.push(ctx.dispatch('createRange', {start, end, location}));
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(_ => Promise.resolve(null));
|
||||
},
|
||||
copyFromWeekToAnotherWeek(ctx, {fromMonday, toMonday}: {fromMonday: Date, toMonday: Date}): Promise<null> {
|
||||
|
||||
const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnWeek'](fromMonday);
|
||||
const promises = [];
|
||||
const diffTime = toMonday.getTime() - fromMonday.getTime();
|
||||
for (let r of rangesToCopy) {
|
||||
let start = new Date(<Date>ISOToDatetime(r.start));
|
||||
let end = new Date(<Date>ISOToDatetime(r.end));
|
||||
start.setTime(start.getTime() + diffTime);
|
||||
end.setTime(end.getTime() + diffTime);
|
||||
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
|
||||
|
||||
promises.push(ctx.dispatch('createRange', {start, end, location}));
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(_ => Promise.resolve(null));
|
||||
}
|
||||
}
|
||||
|
@@ -54,15 +54,12 @@ class LoadDocGeneratorTemplate extends AbstractFixture
|
||||
];
|
||||
|
||||
foreach ($templates as $template) {
|
||||
$newStoredObj = (new StoredObject());
|
||||
|
||||
$newStoredObj
|
||||
$newStoredObj = (new StoredObject())
|
||||
->setFilename($template['file']['filename'])
|
||||
->setKeyInfos(json_decode($template['file']['key'], true))
|
||||
->setIv(json_decode($template['file']['iv'], true))
|
||||
->setCreatedAt(new \DateTime('today'))
|
||||
->registerVersion(
|
||||
json_decode($template['file']['key'], true),
|
||||
json_decode($template['file']['iv'], true),
|
||||
$template['file']['type'],
|
||||
);
|
||||
->setType($template['file']['type']);
|
||||
|
||||
$manager->persist($newStoredObj);
|
||||
|
||||
|
@@ -134,11 +134,13 @@ class Generator implements GeneratorInterface
|
||||
$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, 'application/yaml');
|
||||
$this->storedObjectManager->write($destinationStoredObject, $content);
|
||||
} catch (StoredObjectManagerException $e) {
|
||||
$destinationStoredObject->addGenerationErrors($e->getMessage());
|
||||
|
||||
@@ -172,11 +174,13 @@ class Generator implements GeneratorInterface
|
||||
|
||||
/* @var StoredObject $destinationStoredObject */
|
||||
$destinationStoredObject
|
||||
->setType($template->getFile()->getType())
|
||||
->setFilename(sprintf('%s_odt', uniqid('doc_', true)))
|
||||
->setStatus(StoredObject::STATUS_READY)
|
||||
;
|
||||
|
||||
try {
|
||||
$this->storedObjectManager->write($destinationStoredObject, $generatedResource, $template->getFile()->getType());
|
||||
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
|
||||
} catch (StoredObjectManagerException $e) {
|
||||
$destinationStoredObject->addGenerationErrors($e->getMessage());
|
||||
|
||||
|
@@ -19,7 +19,6 @@ use Chill\DocGeneratorBundle\Service\Generator\Generator;
|
||||
use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException;
|
||||
use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -40,11 +39,11 @@ class GeneratorTest extends TestCase
|
||||
|
||||
public function testSuccessfulGeneration(): void
|
||||
{
|
||||
$templateStoredObject = new StoredObject();
|
||||
$templateStoredObject->registerVersion(type: 'application/test');
|
||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
|
||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
|
||||
->setType('application/test'));
|
||||
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
|
||||
$reflection = new \ReflectionClass($destinationStoredObject);
|
||||
$reflection->getProperty('id')->setAccessible(true);
|
||||
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
|
||||
$entity = new class () {};
|
||||
$data = [];
|
||||
@@ -77,14 +76,7 @@ class GeneratorTest extends TestCase
|
||||
|
||||
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
|
||||
$storedObjectManager->read($templateStoredObject)->willReturn('template');
|
||||
$storedObjectManager->write($destinationStoredObject, 'generated', 'application/test')
|
||||
->will(function ($args): StoredObjectVersion {
|
||||
/** @var StoredObject $storedObject */
|
||||
$storedObject = $args[0];
|
||||
|
||||
return $storedObject->registerVersion(type: $args[2]);
|
||||
})
|
||||
->shouldBeCalled();
|
||||
$storedObjectManager->write($destinationStoredObject, 'generated')->shouldBeCalled();
|
||||
|
||||
$generator = new Generator(
|
||||
$contextManagerInterface->reveal(),
|
||||
@@ -115,9 +107,8 @@ class GeneratorTest extends TestCase
|
||||
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
|
||||
);
|
||||
|
||||
$templateStoredObject = new StoredObject();
|
||||
$templateStoredObject->registerVersion(type: 'application/test');
|
||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
|
||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
|
||||
->setType('application/test'));
|
||||
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY);
|
||||
|
||||
$generator->generateDocFromTemplate(
|
||||
@@ -133,11 +124,11 @@ class GeneratorTest extends TestCase
|
||||
{
|
||||
$this->expectException(RelatedEntityNotFoundException::class);
|
||||
|
||||
$templateStoredObject = new StoredObject();
|
||||
$templateStoredObject->registerVersion(type: 'application/test');
|
||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
|
||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
|
||||
->setType('application/test'));
|
||||
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
|
||||
$reflection = new \ReflectionClass($destinationStoredObject);
|
||||
$reflection->getProperty('id')->setAccessible(true);
|
||||
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
|
||||
|
||||
$context = $this->prophesize(DocGeneratorContextInterface::class);
|
||||
|
@@ -58,7 +58,6 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
||||
?int $expire_delay = null,
|
||||
?int $submit_delay = null,
|
||||
int $max_file_count = 1,
|
||||
?string $object_name = null,
|
||||
): SignedUrlPost {
|
||||
$delay = $expire_delay ?? $this->max_expire_delay;
|
||||
$submit_delay ??= $this->max_submit_delay;
|
||||
@@ -85,14 +84,11 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
||||
|
||||
$expires = $this->clock->now()->add(new \DateInterval('PT'.(string) $delay.'S'));
|
||||
|
||||
if (null === $object_name) {
|
||||
$object_name = $this->generateObjectName();
|
||||
}
|
||||
$object_name = $this->generateObjectName();
|
||||
|
||||
$g = new SignedUrlPost(
|
||||
$url = $this->generateUrl($object_name),
|
||||
$expires,
|
||||
$object_name,
|
||||
$this->max_post_file_size,
|
||||
$max_file_count,
|
||||
$submit_delay,
|
||||
@@ -131,7 +127,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
||||
];
|
||||
$url = $url.'?'.\http_build_query($args);
|
||||
|
||||
$signature = new SignedUrl(strtoupper($method), $url, $expires, $object_name);
|
||||
$signature = new SignedUrl(strtoupper($method), $url, $expires);
|
||||
|
||||
$this->event_dispatcher->dispatch(
|
||||
new TempUrlGenerateEvent($signature)
|
||||
@@ -182,19 +178,21 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
||||
return \hash_hmac('sha512', $body, $this->key, false);
|
||||
}
|
||||
|
||||
private function generateSignature(string $method, $url, \DateTimeImmutable $expires)
|
||||
private function generateSignature($method, $url, \DateTimeImmutable $expires)
|
||||
{
|
||||
if ('POST' === $method) {
|
||||
return $this->generateSignaturePost($url, $expires);
|
||||
}
|
||||
|
||||
$path = \parse_url((string) $url, PHP_URL_PATH);
|
||||
|
||||
$body = sprintf(
|
||||
"%s\n%s\n%s",
|
||||
strtoupper($method),
|
||||
$method,
|
||||
$expires->format('U'),
|
||||
$path
|
||||
);
|
||||
)
|
||||
;
|
||||
|
||||
$this->logger->debug(
|
||||
'generate signature GET',
|
||||
|
@@ -21,8 +21,6 @@ readonly class SignedUrl
|
||||
#[Serializer\Groups(['read'])]
|
||||
public string $url,
|
||||
public \DateTimeImmutable $expires,
|
||||
#[Serializer\Groups(['read'])]
|
||||
public string $object_name,
|
||||
) {}
|
||||
|
||||
#[Serializer\Groups(['read'])]
|
||||
|
@@ -18,7 +18,6 @@ readonly class SignedUrlPost extends SignedUrl
|
||||
public function __construct(
|
||||
string $url,
|
||||
\DateTimeImmutable $expires,
|
||||
string $object_name,
|
||||
#[Serializer\Groups(['read'])]
|
||||
public int $max_file_size,
|
||||
#[Serializer\Groups(['read'])]
|
||||
@@ -32,6 +31,6 @@ readonly class SignedUrlPost extends SignedUrl
|
||||
#[Serializer\Groups(['read'])]
|
||||
public string $signature,
|
||||
) {
|
||||
parent::__construct('POST', $url, $expires, $object_name);
|
||||
parent::__construct('POST', $url, $expires);
|
||||
}
|
||||
}
|
||||
|
@@ -17,7 +17,6 @@ interface TempUrlGeneratorInterface
|
||||
?int $expire_delay = null,
|
||||
?int $submit_delay = null,
|
||||
int $max_file_count = 1,
|
||||
?string $object_name = null,
|
||||
): SignedUrlPost;
|
||||
|
||||
public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl;
|
||||
|
@@ -11,11 +11,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -32,84 +30,62 @@ final readonly class AsyncUploadController
|
||||
private TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
private SerializerInterface $serializer,
|
||||
private Security $security,
|
||||
private LoggerInterface $chillLogger,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
#[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/post', name: 'chill_docstore_asyncupload_getsignedurlpost')]
|
||||
public function getSignedUrlPost(Request $request, StoredObject $storedObject): JsonResponse
|
||||
#[Route(path: '/asyncupload/temp_url/generate/{method}', name: 'async_upload.generate_url')]
|
||||
public function getSignedUrl(string $method, Request $request): JsonResponse
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException('not able to edit the given stored object');
|
||||
}
|
||||
try {
|
||||
switch (strtolower($method)) {
|
||||
case 'post':
|
||||
$p = $this->tempUrlGenerator
|
||||
->generatePost(
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null,
|
||||
$request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null
|
||||
)
|
||||
;
|
||||
break;
|
||||
case 'get':
|
||||
case 'head':
|
||||
$object_name = $request->query->get('object_name', null);
|
||||
|
||||
// we create a dummy version, to generate a filename
|
||||
$version = $storedObject->registerVersion();
|
||||
|
||||
$p = $this->tempUrlGenerator
|
||||
->generatePost(
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null,
|
||||
$request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null,
|
||||
object_name: $version->getFilename()
|
||||
);
|
||||
|
||||
$this->chillLogger->notice('[Privacy Event] a request to upload a document has been generated', [
|
||||
'doc_uuid' => $storedObject->getUuid(),
|
||||
]);
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
Response::HTTP_OK,
|
||||
[],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/{method}', name: 'chill_docstore_asyncupload_getsignedurlget', requirements: ['method' => 'get|head'])]
|
||||
public function getSignedUrlGet(Request $request, StoredObject $storedObject, string $method): JsonResponse
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException('not able to read the given stored object');
|
||||
}
|
||||
|
||||
// we really want to be sure that there are no other method than get or head:
|
||||
if (!in_array($method, ['get', 'head'], true)) {
|
||||
throw new AccessDeniedHttpException('Only methods get and head are allowed');
|
||||
}
|
||||
|
||||
if ($request->query->has('version')) {
|
||||
$filename = $request->query->get('version');
|
||||
|
||||
$storedObjectVersion = $storedObject->getVersions()->findFirst(fn (int $index, StoredObjectVersion $version): bool => $version->getFilename() === $filename);
|
||||
|
||||
if (null === $storedObjectVersion) {
|
||||
// we are here in the case where the version is not stored into the database
|
||||
// as the version is prefixed by the stored object prefix, we just have to check that this prefix
|
||||
// is the same. It means that the user had previously the permission to "SEE_AND_EDIT" this stored
|
||||
// object with same prefix that we checked before
|
||||
if (!str_starts_with($filename, $storedObject->getPrefix())) {
|
||||
throw new AccessDeniedHttpException('not able to match the version with the same filename');
|
||||
}
|
||||
if (null === $object_name) {
|
||||
return (new JsonResponse((object) [
|
||||
'message' => 'the object_name is null',
|
||||
]))
|
||||
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
$p = $this->tempUrlGenerator->generate(
|
||||
$method,
|
||||
$object_name,
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return (new JsonResponse((object) ['message' => 'the method '
|
||||
."{$method} is not valid"]))
|
||||
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
} else {
|
||||
$filename = $storedObject->getCurrentVersion()->getFilename();
|
||||
} catch (TempUrlGeneratorException $e) {
|
||||
$this->logger->warning('The client requested a temp url'
|
||||
.' which sparkle an error.', [
|
||||
'message' => $e->getMessage(),
|
||||
'expire_delay' => $request->query->getInt('expire_delay', 0),
|
||||
'file_count' => $request->query->getInt('file_count', 1),
|
||||
'method' => $method,
|
||||
]);
|
||||
|
||||
$p = new \stdClass();
|
||||
$p->message = $e->getMessage();
|
||||
$p->status = JsonResponse::HTTP_BAD_REQUEST;
|
||||
|
||||
return new JsonResponse($p, JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$p = $this->tempUrlGenerator->generate(
|
||||
$method,
|
||||
$filename,
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
|
||||
);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$userId = match ($user instanceof User) {
|
||||
true => $user->getId(),
|
||||
false => $user->getUserIdentifier(),
|
||||
};
|
||||
|
||||
$this->chillLogger->notice('[Privacy Event] a request to see a document has been granted', [
|
||||
'doc_uuid' => $storedObject->getUuid()->toString(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
if (!$this->security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, $p)) {
|
||||
throw new AccessDeniedHttpException('not allowed to generate this signature');
|
||||
}
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
|
@@ -26,8 +26,6 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
|
||||
/**
|
||||
* Class DocumentPersonController.
|
||||
@@ -42,8 +40,6 @@ class DocumentPersonController extends AbstractController
|
||||
protected TranslatorInterface $translator,
|
||||
protected EventDispatcherInterface $eventDispatcher,
|
||||
protected AuthorizationHelper $authorizationHelper,
|
||||
protected PDFSignatureZoneParser $PDFSignatureZoneParser,
|
||||
protected StoredObjectManagerInterface $storedObjectManagerInterface,
|
||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
|
||||
) {}
|
||||
|
||||
@@ -201,36 +197,4 @@ class DocumentPersonController extends AbstractController
|
||||
['document' => $document, 'person' => $person]
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/signature', name: 'person_document_signature', methods: 'GET')]
|
||||
public function signature(Person $person, PersonDocument $document): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person);
|
||||
$this->denyAccessUnlessGranted('CHILL_PERSON_DOCUMENT_SEE', $document);
|
||||
|
||||
$event = new PrivacyEvent($person, [
|
||||
'element_class' => PersonDocument::class,
|
||||
'element_id' => $document->getId(),
|
||||
'action' => 'show',
|
||||
]);
|
||||
$this->eventDispatcher->dispatch($event, PrivacyEvent::PERSON_PRIVACY_EVENT);
|
||||
|
||||
$storedObject = $document->getObject();
|
||||
$content = $this->storedObjectManagerInterface->read($storedObject);
|
||||
$zones = $this->PDFSignatureZoneParser->findSignatureZones($content);
|
||||
|
||||
$signature = [];
|
||||
$signature['id'] = 1;
|
||||
$signature['storedObject'] = [ // TEMP
|
||||
'filename' => $storedObject->getFilename(),
|
||||
'iv' => $storedObject->getIv(),
|
||||
'keyInfos' => $storedObject->getKeyInfos(),
|
||||
];
|
||||
$signature['zones'] = $zones;
|
||||
|
||||
return $this->render(
|
||||
'@ChillDocStore/PersonDocument/signature.html.twig',
|
||||
['document' => $document, 'person' => $person, 'signature' => $signature]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessage;
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFPage;
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class SignatureRequestController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageBusInterface $messageBus,
|
||||
private readonly StoredObjectManagerInterface $storedObjectManager,
|
||||
private readonly EntityWorkflowManager $entityWorkflowManager,
|
||||
) {}
|
||||
|
||||
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
|
||||
public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse
|
||||
{
|
||||
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
|
||||
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
|
||||
$content = $this->storedObjectManager->read($storedObject);
|
||||
|
||||
$data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); // TODO parse payload: json_decode ou, mieux, dataTransfertObject
|
||||
$zone = new PDFSignatureZone(
|
||||
$data['zone']['index'],
|
||||
$data['zone']['x'],
|
||||
$data['zone']['y'],
|
||||
$data['zone']['height'],
|
||||
$data['zone']['width'],
|
||||
new PDFPage($data['zone']['PDFPage']['index'], $data['zone']['PDFPage']['width'], $data['zone']['PDFPage']['height'])
|
||||
);
|
||||
|
||||
$this->messageBus->dispatch(new RequestPdfSignMessage(
|
||||
$signature->getId(),
|
||||
$zone,
|
||||
$data['zone']['index'],
|
||||
'test signature', // reason (string)
|
||||
'Mme Caroline Diallo', // signerText (string)
|
||||
$content
|
||||
));
|
||||
|
||||
return new JsonResponse(null, JsonResponse::HTTP_OK, []);
|
||||
}
|
||||
|
||||
#[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')]
|
||||
public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse
|
||||
{
|
||||
return new JsonResponse($signature->getState(), JsonResponse::HTTP_OK, []);
|
||||
}
|
||||
}
|
@@ -11,46 +11,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\CRUD\Controller\ApiController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class StoredObjectApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new stored object.
|
||||
*
|
||||
* @return JsonResponse the response containing the serialized object in JSON format
|
||||
*
|
||||
* @throws AccessDeniedHttpException if the user does not have the necessary role to create a stored object
|
||||
*/
|
||||
#[Route('/api/1.0/doc-store/stored-object/create', methods: ['POST'])]
|
||||
public function createStoredObject(): JsonResponse
|
||||
{
|
||||
if (!($this->security->isGranted('ROLE_ADMIN') || $this->security->isGranted('ROLE_USER'))) {
|
||||
throw new AccessDeniedHttpException('Must be user or admin to create a stored object');
|
||||
}
|
||||
|
||||
$object = new StoredObject();
|
||||
|
||||
$this->entityManager->persist($object);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($object, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
json: true
|
||||
);
|
||||
}
|
||||
}
|
||||
class StoredObjectApiController extends ApiController {}
|
||||
|
@@ -16,7 +16,6 @@ use Chill\DocStoreBundle\Dav\Response\DavResponse;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
@@ -43,7 +42,6 @@ final readonly class WebdavController
|
||||
private \Twig\Environment $engine,
|
||||
private StoredObjectManagerInterface $storedObjectManager,
|
||||
private Security $security,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {
|
||||
$this->requestAnalyzer = new PropfindRequestAnalyzer();
|
||||
}
|
||||
@@ -203,8 +201,6 @@ final readonly class WebdavController
|
||||
|
||||
$this->storedObjectManager->write($storedObject, $request->getContent());
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new DavResponse('', Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
|
@@ -11,13 +11,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\DependencyInjection;
|
||||
|
||||
use Chill\DocStoreBundle\Controller\StoredObjectApiController;
|
||||
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
|
||||
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
|
||||
use Symfony\Component\DependencyInjection\Loader;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
||||
|
||||
/**
|
||||
@@ -34,8 +35,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
|
||||
|
||||
$container->setParameter('chill_doc_store', $config);
|
||||
|
||||
$container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter');
|
||||
|
||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
||||
$loader->load('services.yaml');
|
||||
$loader->load('services/controller.yaml');
|
||||
@@ -43,7 +42,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
|
||||
$loader->load('services/fixtures.yaml');
|
||||
$loader->load('services/form.yaml');
|
||||
$loader->load('services/templating.yaml');
|
||||
$loader->load('services/security.yaml');
|
||||
}
|
||||
|
||||
public function prepend(ContainerBuilder $container)
|
||||
@@ -51,6 +49,29 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
|
||||
$this->prependRoute($container);
|
||||
$this->prependAuthorization($container);
|
||||
$this->prependTwig($container);
|
||||
$this->prependApis($container);
|
||||
}
|
||||
|
||||
protected function prependApis(ContainerBuilder $container)
|
||||
{
|
||||
$container->prependExtensionConfig('chill_main', [
|
||||
'apis' => [
|
||||
[
|
||||
'class' => \Chill\DocStoreBundle\Entity\StoredObject::class,
|
||||
'controller' => StoredObjectApiController::class,
|
||||
'name' => 'stored_object',
|
||||
'base_path' => '/api/1.0/docstore/stored-object',
|
||||
'base_role' => 'ROLE_USER',
|
||||
'actions' => [
|
||||
'_entity' => [
|
||||
'methods' => [
|
||||
Request::METHOD_POST => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function prependAuthorization(ContainerBuilder $container)
|
||||
|
@@ -16,14 +16,10 @@ use ChampsLibres\WopiLib\Contract\Entity\Document;
|
||||
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
use Random\RandomException;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Represent a document stored in an object store.
|
||||
@@ -32,16 +28,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Each version is stored within a @see{StoredObjectVersion}, associated with this current's object. The creation
|
||||
* of each new version should be done using the method @see{self::registerVersion}.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table('stored_object', schema: 'chill_doc')]
|
||||
#[ORM\Table('chill_doc.stored_object')]
|
||||
#[AsyncFileExists(message: 'The file is not stored properly')]
|
||||
class StoredObject implements Document, TrackCreationInterface
|
||||
{
|
||||
use TrackCreationTrait;
|
||||
final public const STATUS_EMPTY = 'empty';
|
||||
final public const STATUS_READY = 'ready';
|
||||
final public const STATUS_PENDING = 'pending';
|
||||
final public const STATUS_FAILURE = 'failure';
|
||||
@@ -50,11 +43,9 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
|
||||
private array $datas = [];
|
||||
|
||||
/**
|
||||
* the prefix of each version.
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
private string $prefix = '';
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
|
||||
private string $filename = '';
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Id]
|
||||
@@ -62,10 +53,25 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
|
||||
/**
|
||||
* @var int[]
|
||||
*/
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
|
||||
private array $iv = [];
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
|
||||
private array $keyInfos = [];
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
|
||||
private string $title = '';
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
|
||||
private string $type = '';
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: 'uuid', unique: true)]
|
||||
private UuidInterface $uuid;
|
||||
@@ -88,22 +94,14 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
private string $generationErrors = '';
|
||||
|
||||
/**
|
||||
* @var Collection<int, StoredObjectVersion>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
|
||||
private Collection $versions;
|
||||
|
||||
/**
|
||||
* @param StoredObject::STATUS_* $status
|
||||
*/
|
||||
public function __construct(
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
||||
private string $status = 'empty',
|
||||
private string $status = 'ready',
|
||||
) {
|
||||
$this->uuid = Uuid::uuid4();
|
||||
$this->versions = new ArrayCollection();
|
||||
$this->prefix = self::generatePrefix();
|
||||
}
|
||||
|
||||
public function addGenerationTrial(): self
|
||||
@@ -127,34 +125,14 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return \DateTime::createFromImmutable($this->createdAt);
|
||||
}
|
||||
|
||||
#[AsyncFileExists(message: 'The file is not stored properly')]
|
||||
#[Assert\NotNull(message: 'The store object version must be present')]
|
||||
public function getCurrentVersion(): ?StoredObjectVersion
|
||||
{
|
||||
$maxVersion = null;
|
||||
|
||||
foreach ($this->versions as $v) {
|
||||
if ($v->getVersion() > ($maxVersion?->getVersion() ?? -1)) {
|
||||
$maxVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxVersion;
|
||||
}
|
||||
|
||||
public function getDatas(): array
|
||||
{
|
||||
return $this->datas;
|
||||
}
|
||||
|
||||
public function getPrefix(): string
|
||||
{
|
||||
return $this->prefix;
|
||||
}
|
||||
|
||||
public function getFilename(): string
|
||||
{
|
||||
return $this->getCurrentVersion()?->getFilename() ?? '';
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
public function getGenerationTrialsCounter(): int
|
||||
@@ -167,17 +145,14 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getIv(): array
|
||||
{
|
||||
return $this->getCurrentVersion()?->getIv() ?? [];
|
||||
return $this->iv;
|
||||
}
|
||||
|
||||
public function getKeyInfos(): array
|
||||
{
|
||||
return $this->getCurrentVersion()?->getKeyInfos() ?? [];
|
||||
return $this->keyInfos;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,14 +171,14 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
public function getTitle()
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
public function getType()
|
||||
{
|
||||
return $this->getCurrentVersion()?->getType() ?? '';
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getUuid(): UuidInterface
|
||||
@@ -234,6 +209,27 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setFilename(?string $filename): self
|
||||
{
|
||||
$this->filename = (string) $filename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setIv(?array $iv): self
|
||||
{
|
||||
$this->iv = (array) $iv;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setKeyInfos(?array $keyInfos): self
|
||||
{
|
||||
$this->keyInfos = (array) $keyInfos;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StoredObject::STATUS_* $status
|
||||
*/
|
||||
@@ -251,21 +247,18 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setType(?string $type): self
|
||||
{
|
||||
$this->type = (string) $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTemplate(): ?DocGeneratorTemplate
|
||||
{
|
||||
return $this->template;
|
||||
}
|
||||
|
||||
public function getVersions(): Collection
|
||||
{
|
||||
return $this->versions;
|
||||
}
|
||||
|
||||
public function hasCurrentVersion(): bool
|
||||
{
|
||||
return null !== $this->getCurrentVersion();
|
||||
}
|
||||
|
||||
public function hasTemplate(): bool
|
||||
{
|
||||
return null !== $this->template;
|
||||
@@ -321,65 +314,18 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function registerVersion(
|
||||
array $iv = [],
|
||||
array $keyInfos = [],
|
||||
string $type = '',
|
||||
?string $filename = null,
|
||||
): StoredObjectVersion {
|
||||
$version = new StoredObjectVersion(
|
||||
$this,
|
||||
null === $this->getCurrentVersion() ? 0 : $this->getCurrentVersion()->getVersion() + 1,
|
||||
$iv,
|
||||
$keyInfos,
|
||||
$type,
|
||||
$filename
|
||||
);
|
||||
|
||||
$this->versions->add($version);
|
||||
|
||||
if ('empty' === $this->status) {
|
||||
$this->status = self::STATUS_READY;
|
||||
public function saveHistory(): void
|
||||
{
|
||||
if ('' === $this->getFilename()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
public function removeVersion(StoredObjectVersion $storedObjectVersion): void
|
||||
{
|
||||
if (!$this->versions->contains($storedObjectVersion)) {
|
||||
throw new \UnexpectedValueException('This stored object does not contains this version');
|
||||
}
|
||||
$this->versions->removeElement($storedObjectVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public function saveHistory(): void {}
|
||||
|
||||
public static function generatePrefix(): string
|
||||
{
|
||||
try {
|
||||
return base_convert(bin2hex(random_bytes(32)), 16, 36);
|
||||
} catch (RandomException) {
|
||||
return uniqid(more_entropy: true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a stored object can be deleted.
|
||||
*
|
||||
* Currently, return true if the deletedAt date is below the current date, and the object
|
||||
* does not contains any version (which must be removed first).
|
||||
*
|
||||
* @param \DateTimeImmutable $now the current date and time
|
||||
* @param StoredObject $storedObject the stored object to check
|
||||
*
|
||||
* @return bool returns true if the stored object can be deleted, false otherwise
|
||||
*/
|
||||
public static function canBeDeleted(\DateTimeImmutable $now, StoredObject $storedObject): bool
|
||||
{
|
||||
return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty();
|
||||
$this->datas['history'][] = [
|
||||
'filename' => $this->getFilename(),
|
||||
'iv' => $this->getIv(),
|
||||
'key_infos' => $this->getKeyInfos(),
|
||||
'type' => $this->getType(),
|
||||
'before' => (new \DateTimeImmutable('now'))->getTimestamp(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -1,67 +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\Entity;
|
||||
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Represents a snapshot of a stored object at a specific point in time.
|
||||
*
|
||||
* This entity tracks versions of stored objects, reasons for the snapshot,
|
||||
* and the user who initiated the action.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'stored_object_point_in_time', schema: 'chill_doc')]
|
||||
class StoredObjectPointInTime implements TrackCreationInterface
|
||||
{
|
||||
use TrackCreationTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')]
|
||||
#[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)]
|
||||
private StoredObjectVersion $objectVersion,
|
||||
#[ORM\Column(name: 'reason', type: 'text', nullable: false, enumType: StoredObjectPointInTimeReasonEnum::class)]
|
||||
private StoredObjectPointInTimeReasonEnum $reason,
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private ?User $byUser = null,
|
||||
) {
|
||||
$this->objectVersion->addPointInTime($this);
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getByUser(): ?User
|
||||
{
|
||||
return $this->byUser;
|
||||
}
|
||||
|
||||
public function getObjectVersion(): StoredObjectVersion
|
||||
{
|
||||
return $this->objectVersion;
|
||||
}
|
||||
|
||||
public function getReason(): StoredObjectPointInTimeReasonEnum
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
}
|
@@ -1,18 +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\Entity;
|
||||
|
||||
enum StoredObjectPointInTimeReasonEnum: string
|
||||
{
|
||||
case KEEP_BEFORE_CONVERSION = 'keep-before-conversion';
|
||||
case KEEP_BY_USER = 'keep-by-user';
|
||||
}
|
@@ -1,173 +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\Entity;
|
||||
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\Selectable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Random\RandomException;
|
||||
|
||||
/**
|
||||
* Store each version of StoredObject's.
|
||||
*
|
||||
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table('chill_doc.stored_object_version')]
|
||||
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
|
||||
class StoredObjectVersion implements TrackCreationInterface
|
||||
{
|
||||
use TrackCreationTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
|
||||
/**
|
||||
* filename of the version in the stored object.
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
private string $filename = '';
|
||||
|
||||
/**
|
||||
* @var Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection&Selectable $pointInTimes;
|
||||
|
||||
public function __construct(
|
||||
/**
|
||||
* The stored object associated with this version.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: StoredObject::class, inversedBy: 'versions')]
|
||||
#[ORM\JoinColumn(name: 'stored_object_id', nullable: false)]
|
||||
private StoredObject $storedObject,
|
||||
|
||||
/**
|
||||
* The incremental version.
|
||||
*/
|
||||
#[ORM\Column(name: 'version', type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])]
|
||||
private int $version = 0,
|
||||
|
||||
/**
|
||||
* vector for encryption.
|
||||
*
|
||||
* @var int[]
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
|
||||
private array $iv = [],
|
||||
|
||||
/**
|
||||
* Key infos for document encryption.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
|
||||
private array $keyInfos = [],
|
||||
|
||||
/**
|
||||
* type of the document.
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
|
||||
private string $type = '',
|
||||
?string $filename = null,
|
||||
) {
|
||||
$this->filename = $filename ?? self::generateFilename($this);
|
||||
$this->pointInTimes = new ArrayCollection();
|
||||
}
|
||||
|
||||
public static function generateFilename(StoredObjectVersion $storedObjectVersion): string
|
||||
{
|
||||
try {
|
||||
$suffix = base_convert(bin2hex(random_bytes(8)), 16, 36);
|
||||
} catch (RandomException) {
|
||||
$suffix = uniqid(more_entropy: true);
|
||||
}
|
||||
|
||||
return $storedObjectVersion->getStoredObject()->getPrefix().'/'.$suffix;
|
||||
}
|
||||
|
||||
public function getFilename(): string
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getIv(): array
|
||||
{
|
||||
return $this->iv;
|
||||
}
|
||||
|
||||
public function getKeyInfos(): array
|
||||
{
|
||||
return $this->keyInfos;
|
||||
}
|
||||
|
||||
public function getStoredObject(): StoredObject
|
||||
{
|
||||
return $this->storedObject;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
|
||||
*/
|
||||
public function getPointInTimes(): Selectable&Collection
|
||||
{
|
||||
return $this->pointInTimes;
|
||||
}
|
||||
|
||||
public function hasPointInTimes(): bool
|
||||
{
|
||||
return $this->pointInTimes->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*
|
||||
* @internal use @see{StoredObjectPointInTime} constructor instead
|
||||
*/
|
||||
public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
|
||||
{
|
||||
if (!$this->pointInTimes->contains($storedObjectPointInTime)) {
|
||||
$this->pointInTimes->add($storedObjectPointInTime);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removePointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
|
||||
{
|
||||
if ($this->pointInTimes->contains($storedObjectPointInTime)) {
|
||||
$this->pointInTimes->removeElement($storedObjectPointInTime);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
@@ -55,8 +55,16 @@ class StoredObjectDataMapper implements DataMapperInterface
|
||||
return;
|
||||
}
|
||||
|
||||
/* @var StoredObject $viewData */
|
||||
$viewData = $forms['stored_object']->getData();
|
||||
/** @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());
|
||||
|
@@ -12,6 +12,7 @@ declare(strict_types=1);
|
||||
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;
|
||||
@@ -29,7 +30,11 @@ class StoredObjectDataTransformer implements DataTransformerInterface
|
||||
}
|
||||
|
||||
if ($value instanceof StoredObject) {
|
||||
return $this->serializer->serialize($value, 'json');
|
||||
return $this->serializer->serialize($value, 'json', [
|
||||
'groups' => [
|
||||
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
throw new UnexpectedTypeException($value, StoredObject::class);
|
||||
@@ -41,6 +46,6 @@ class StoredObjectDataTransformer implements DataTransformerInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->serializer->deserialize($value, StoredObject::class, 'json');
|
||||
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
@@ -12,14 +12,13 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Repository;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
|
||||
class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
|
||||
class AccompanyingCourseDocumentRepository implements ObjectRepository
|
||||
{
|
||||
private readonly EntityRepository $repository;
|
||||
|
||||
@@ -46,16 +45,6 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
|
||||
return $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('d');
|
||||
$query = $qb->where('d.object = :storedObject')
|
||||
->setParameter('storedObject', $storedObject)
|
||||
->getQuery();
|
||||
|
||||
return $query->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function find($id): ?AccompanyingCourseDocument
|
||||
{
|
||||
return $this->repository->find($id);
|
||||
@@ -66,7 +55,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
|
||||
return $this->repository->findAll();
|
||||
}
|
||||
|
||||
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
|
||||
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
|
||||
{
|
||||
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
|
||||
}
|
||||
@@ -76,7 +65,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
|
||||
return $this->findOneBy($criteria);
|
||||
}
|
||||
|
||||
public function getClassName(): string
|
||||
public function getClassName()
|
||||
{
|
||||
return AccompanyingCourseDocument::class;
|
||||
}
|
||||
|
@@ -1,19 +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;
|
||||
|
||||
interface AssociatedEntityToStoredObjectInterface
|
||||
{
|
||||
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object;
|
||||
}
|
@@ -12,7 +12,6 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Repository;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\PersonDocument;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
@@ -20,7 +19,7 @@ use Doctrine\Persistence\ObjectRepository;
|
||||
/**
|
||||
* @template ObjectRepository<PersonDocument::class>
|
||||
*/
|
||||
readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
|
||||
readonly class PersonDocumentRepository implements ObjectRepository
|
||||
{
|
||||
private EntityRepository $repository;
|
||||
|
||||
@@ -54,14 +53,4 @@ readonly class PersonDocumentRepository implements ObjectRepository, AssociatedE
|
||||
{
|
||||
return PersonDocument::class;
|
||||
}
|
||||
|
||||
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('d');
|
||||
$query = $qb->where('d.object = :storedObject')
|
||||
->setParameter('storedObject', $storedObject)
|
||||
->getQuery();
|
||||
|
||||
return $query->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
@@ -1,27 +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\StoredObjectPointInTime;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @template-extends ServiceEntityRepository<StoredObjectPointInTime>
|
||||
*/
|
||||
class StoredObjectPointInTimeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, StoredObjectPointInTime::class);
|
||||
}
|
||||
}
|
@@ -14,7 +14,6 @@ namespace Chill\DocStoreBundle\Repository;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query;
|
||||
|
||||
final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface
|
||||
{
|
||||
@@ -54,21 +53,6 @@ final readonly class StoredObjectRepository implements StoredObjectRepositoryInt
|
||||
return $this->repository->findOneBy($criteria);
|
||||
}
|
||||
|
||||
public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('stored_object');
|
||||
$qb
|
||||
->where('stored_object.deleteAt <= :expiredAt')
|
||||
->setParameter('expiredAt', $expiredAtDate);
|
||||
|
||||
return $qb->getQuery()->toIterable(hydrationMode: Query::HYDRATE_OBJECT);
|
||||
}
|
||||
|
||||
public function findOneByUUID(string $uuid): ?StoredObject
|
||||
{
|
||||
return $this->repository->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function getClassName(): string
|
||||
{
|
||||
return StoredObject::class;
|
||||
|
@@ -17,12 +17,4 @@ use Doctrine\Persistence\ObjectRepository;
|
||||
/**
|
||||
* @extends ObjectRepository<StoredObject>
|
||||
*/
|
||||
interface StoredObjectRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
/**
|
||||
* @return iterable<StoredObject>
|
||||
*/
|
||||
public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable;
|
||||
|
||||
public function findOneByUUID(string $uuid): ?StoredObject;
|
||||
}
|
||||
interface StoredObjectRepositoryInterface extends ObjectRepository {}
|
||||
|
@@ -1,94 +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\StoredObjectVersion;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
|
||||
/**
|
||||
* @implements ObjectRepository<StoredObjectVersion>
|
||||
*/
|
||||
class StoredObjectVersionRepository implements ObjectRepository
|
||||
{
|
||||
private readonly EntityRepository $repository;
|
||||
|
||||
private readonly Connection $connection;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->repository = $entityManager->getRepository(StoredObjectVersion::class);
|
||||
$this->connection = $entityManager->getConnection();
|
||||
}
|
||||
|
||||
public function find($id): ?StoredObjectVersion
|
||||
{
|
||||
return $this->repository->find($id);
|
||||
}
|
||||
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->repository->findAll();
|
||||
}
|
||||
|
||||
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
|
||||
{
|
||||
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
|
||||
}
|
||||
|
||||
public function findOneBy(array $criteria): ?StoredObjectVersion
|
||||
{
|
||||
return $this->repository->findOneBy($criteria);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDs of versions older than a given date and that are not the last version.
|
||||
*
|
||||
* Those version are good candidates for a deletion.
|
||||
*
|
||||
* @param \DateTimeImmutable $beforeDate the date to compare versions against
|
||||
*
|
||||
* @return iterable returns an iterable with the IDs of the versions
|
||||
*/
|
||||
public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable
|
||||
{
|
||||
$results = $this->connection->executeQuery(
|
||||
self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION,
|
||||
[$beforeDate],
|
||||
[Types::DATETIME_IMMUTABLE]
|
||||
);
|
||||
|
||||
foreach ($results->iterateAssociative() as $row) {
|
||||
yield $row['sov_id'];
|
||||
}
|
||||
}
|
||||
|
||||
private const QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION = <<<'SQL'
|
||||
SELECT
|
||||
sov.id AS sov_id
|
||||
FROM chill_doc.stored_object_version sov
|
||||
WHERE
|
||||
sov.createdat < ?::timestamp
|
||||
AND
|
||||
sov.version < (SELECT MAX(sub_sov.version) FROM chill_doc.stored_object_version sub_sov WHERE sub_sov.stored_object_id = sov.stored_object_id)
|
||||
AND
|
||||
NOT EXISTS (SELECT 1 FROM chill_doc.stored_object_point_in_time sub_poi WHERE sub_poi.stored_object_version_id = sov.id)
|
||||
SQL;
|
||||
|
||||
public function getClassName(): string
|
||||
{
|
||||
return StoredObjectVersion::class;
|
||||
}
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||
import {createApp} from "vue";
|
||||
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||
const i18n = _createI18n({});
|
||||
|
||||
@@ -30,17 +30,15 @@ const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElemen
|
||||
DropFileWidget,
|
||||
},
|
||||
methods: {
|
||||
addDocument: function({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
|
||||
console.log('object added', stored_object);
|
||||
console.log('version added', stored_object_version);
|
||||
this.$data.existingDoc = stored_object;
|
||||
this.$data.existingDoc.currentVersion = stored_object_version;
|
||||
input_stored_object.value = JSON.stringify(this.$data.existingDoc);
|
||||
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 = undefined;
|
||||
this.$data.existingDoc = null;
|
||||
console.log('collectionEntry', collectionEntry);
|
||||
|
||||
if (null !== collectionEntry) {
|
||||
|
@@ -1,53 +1,38 @@
|
||||
import {DateTime, User} from "../../../ChillMainBundle/Resources/public/types";
|
||||
import {DateTime} from "../../../ChillMainBundle/Resources/public/types";
|
||||
|
||||
export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending";
|
||||
export type StoredObjectStatus = "ready"|"failure"|"pending";
|
||||
|
||||
export interface StoredObject {
|
||||
id: number,
|
||||
title: string|null,
|
||||
uuid: string,
|
||||
prefix: string,
|
||||
status: StoredObjectStatus,
|
||||
currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted,
|
||||
totalVersions: number,
|
||||
datas: object,
|
||||
/** @deprecated */
|
||||
creationDate: DateTime,
|
||||
createdAt: DateTime|null,
|
||||
createdBy: User|null,
|
||||
_permissions: {
|
||||
canEdit: boolean,
|
||||
canSee: boolean,
|
||||
},
|
||||
id: number,
|
||||
|
||||
/**
|
||||
* filename of the object in the object storage
|
||||
*/
|
||||
filename: string,
|
||||
creationDate: DateTime,
|
||||
datas: object,
|
||||
iv: number[],
|
||||
keyInfos: object,
|
||||
title: string,
|
||||
type: string,
|
||||
uuid: string,
|
||||
status: StoredObjectStatus,
|
||||
_links?: {
|
||||
dav_link?: {
|
||||
href: string
|
||||
expiration: number
|
||||
},
|
||||
},
|
||||
dav_link?: {
|
||||
href: string
|
||||
expiration: number
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredObjectVersion {
|
||||
/**
|
||||
* filename of the object in the object storage
|
||||
*/
|
||||
export interface StoredObjectCreated {
|
||||
status: "stored_object_created",
|
||||
filename: string,
|
||||
iv: number[],
|
||||
keyInfos: JsonWebKey,
|
||||
iv: Uint8Array,
|
||||
keyInfos: object,
|
||||
type: string,
|
||||
}
|
||||
|
||||
export interface StoredObjectVersionCreated extends StoredObjectVersion {
|
||||
persisted: false,
|
||||
}
|
||||
|
||||
export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated {
|
||||
version: number,
|
||||
id: number,
|
||||
createdAt: DateTime|null,
|
||||
createdBy: User|null,
|
||||
}
|
||||
|
||||
export interface StoredObjectStatusChange {
|
||||
id: number,
|
||||
filename: string,
|
||||
@@ -66,35 +51,14 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = {
|
||||
* 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,
|
||||
method: "POST",
|
||||
max_file_size: number,
|
||||
max_file_count: 1,
|
||||
expires: number,
|
||||
submit_delay: 180,
|
||||
redirect: string,
|
||||
prefix: string,
|
||||
url: string,
|
||||
signature: string,
|
||||
}
|
||||
|
||||
export interface PDFPage {
|
||||
index: number,
|
||||
width: number,
|
||||
height: number,
|
||||
}
|
||||
export interface SignatureZone {
|
||||
index: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
PDFPage: PDFPage,
|
||||
}
|
||||
|
||||
export interface Signature {
|
||||
id: number,
|
||||
storedObject: StoredObject,
|
||||
zones: SignatureZone[],
|
||||
}
|
||||
|
||||
export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error';
|
||||
|
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div v-if="isButtonGroupDisplayable" class="btn-group">
|
||||
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
|
||||
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li v-if="isEditableOnline">
|
||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'">
|
||||
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
|
||||
</li>
|
||||
<li v-if="isEditableOnDesktop">
|
||||
<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="isConvertibleToPdf">
|
||||
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf && props.storedObject.status !== 'stored_object_created'">
|
||||
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
|
||||
</li>
|
||||
<li v-if="isDownloadable">
|
||||
<download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
|
||||
<li v-if="props.canDownload">
|
||||
<download-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -29,20 +29,20 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
import {computed, onMounted} from "vue";
|
||||
import {onMounted} from "vue";
|
||||
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
|
||||
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,
|
||||
StoredObjectStatusChange, StoredObjectVersion,
|
||||
StoredObject, StoredObjectCreated,
|
||||
StoredObjectStatusChange,
|
||||
WopiEditButtonExecutableBeforeLeaveFunction
|
||||
} from "../types";
|
||||
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||
|
||||
interface DocumentActionButtonsGroupConfig {
|
||||
storedObject: StoredObject,
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
small?: boolean,
|
||||
canEdit?: boolean,
|
||||
canDownload?: boolean,
|
||||
@@ -95,44 +95,11 @@ let tryiesForReady = 0;
|
||||
*/
|
||||
const maxTryiesForReady = 120;
|
||||
|
||||
const isButtonGroupDisplayable = computed<boolean>(() => {
|
||||
return isDownloadable.value || isEditableOnline.value || isEditableOnDesktop.value || isConvertibleToPdf.value;
|
||||
});
|
||||
|
||||
const isDownloadable = computed<boolean>(() => {
|
||||
return props.storedObject.status === 'ready'
|
||||
// happens when the stored object version is just added, but not persisted
|
||||
|| (props.storedObject.currentVersion !== null && props.storedObject.status === 'empty')
|
||||
});
|
||||
|
||||
const isEditableOnline = computed<boolean>(() => {
|
||||
return props.storedObject.status === 'ready'
|
||||
&& props.storedObject._permissions.canEdit
|
||||
&& props.canEdit
|
||||
&& props.storedObject.currentVersion !== null
|
||||
&& is_extension_editable(props.storedObject.currentVersion.type)
|
||||
&& props.storedObject.currentVersion.persisted !== false;
|
||||
});
|
||||
|
||||
const isEditableOnDesktop = computed<boolean>(() => {
|
||||
return isEditableOnline.value;
|
||||
});
|
||||
|
||||
const isConvertibleToPdf = computed<boolean>(() => {
|
||||
return props.storedObject.status === 'ready'
|
||||
&& props.storedObject._permissions.canSee
|
||||
&& props.canConvertPdf
|
||||
&& props.storedObject.currentVersion !== null
|
||||
&& is_extension_viewable(props.storedObject.currentVersion.type)
|
||||
&& props.storedObject.currentVersion.type !== 'application/pdf'
|
||||
&& props.storedObject.currentVersion.persisted !== false;
|
||||
})
|
||||
|
||||
const checkForReady = function(): void {
|
||||
if (
|
||||
'ready' === props.storedObject.status
|
||||
|| 'empty' === 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
|
||||
) {
|
||||
|
@@ -1,450 +0,0 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<modal v-if="modalOpen" @close="modalOpen = false">
|
||||
<template v-slot:header>
|
||||
<h2>{{ $t("signature_confirmation") }}</h2>
|
||||
</template>
|
||||
<template v-slot:body>
|
||||
<div class="signature-modal-body text-center" v-if="loading">
|
||||
<p>{{ $t("electronic_signature_in_progress") }}</p>
|
||||
<div class="loading">
|
||||
<i
|
||||
class="fa fa-circle-o-notch fa-spin fa-3x"
|
||||
:title="$t('loading')"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signature-modal-body text-center" v-else>
|
||||
<p>{{ $t("you_are_going_to_sign") }}</p>
|
||||
<p>{{ $t("are_you_sure") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<button class="btn btn-action" @click.prevent="confirmSign">
|
||||
{{ $t("yes") }}
|
||||
</button>
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
<div class="col-12">
|
||||
<div
|
||||
class="row justify-content-center mb-2"
|
||||
v-if="signature.zones.length > 1"
|
||||
>
|
||||
<div class="col-4 gap-2 d-grid">
|
||||
<button
|
||||
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
|
||||
class="btn btn-light btn-sm"
|
||||
@click="turnSignature(-1)"
|
||||
>
|
||||
{{ $t("last_sign_zone") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4 gap-2 d-grid">
|
||||
<button
|
||||
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
|
||||
class="btn btn-light btn-sm"
|
||||
@click="turnSignature(1)"
|
||||
>
|
||||
{{ $t("next_sign_zone") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="turn-page"
|
||||
class="row justify-content-center mb-2"
|
||||
v-if="pageCount > 1"
|
||||
>
|
||||
<div class="col-6-sm col-3-md text-center">
|
||||
<button
|
||||
class="btn btn-light btn-sm"
|
||||
:disabled="page <= 1"
|
||||
@click="turnPage(-1)"
|
||||
>
|
||||
❮
|
||||
</button>
|
||||
<span>page {{ page }} / {{ pageCount }}</span>
|
||||
<button
|
||||
class="btn btn-light btn-sm"
|
||||
:disabled="page >= pageCount"
|
||||
@click="turnPage(1)"
|
||||
>
|
||||
❯
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 text-center">
|
||||
<canvas class="m-auto" id="canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="col-12 p-4" id="action-buttons" v-if="signedState !== 'signed'">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<button
|
||||
class="btn btn-action me-2"
|
||||
:disabled="!userSignatureZone"
|
||||
@click="sign"
|
||||
>
|
||||
{{ $t("sign") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 d-flex justify-content-end">
|
||||
<button
|
||||
class="btn btn-misc me-2"
|
||||
:hidden="!userSignatureZone"
|
||||
@click="undoSign"
|
||||
v-if="signature.zones.length > 1"
|
||||
>
|
||||
{{ $t("choose_another_signature") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-misc me-2"
|
||||
:hidden="!userSignatureZone"
|
||||
@click="undoSign"
|
||||
v-else
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
<button class="btn btn-delete" @click="undoSign">
|
||||
{{ $t("cancel_signing") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, Ref, reactive } from "vue";
|
||||
import { useToast } from "vue-toast-notification";
|
||||
import "vue-toast-notification/dist/theme-sugar.css";
|
||||
import { Signature, SignatureZone, SignedState } from "../../types";
|
||||
import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import {
|
||||
PDFDocumentProxy,
|
||||
PDFPageProxy,
|
||||
} from "pdfjs-dist/types/src/display/api";
|
||||
|
||||
// @ts-ignore
|
||||
import * as PdfWorker from "pdfjs-dist/build/pdf.worker.mjs";
|
||||
console.log(PdfWorker); // incredible but this is needed
|
||||
|
||||
// import { PdfWorker } from 'pdfjs-dist/build/pdf.worker.mjs'
|
||||
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
|
||||
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import {
|
||||
download_and_decrypt_doc,
|
||||
} from "../StoredObjectButton/helpers";
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
|
||||
|
||||
const modalOpen: Ref<boolean> = ref(false);
|
||||
const loading: Ref<boolean> = ref(false);
|
||||
const signedState: Ref<SignedState> = ref("pending");
|
||||
const page: Ref<number> = ref(1);
|
||||
const pageCount: Ref<number> = ref(0);
|
||||
let userSignatureZone: Ref<null | SignatureZone> = ref(null);
|
||||
let pdfSource: Ref<string> = ref("");
|
||||
let pdf = {} as PDFDocumentProxy;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
signature: Signature;
|
||||
}
|
||||
}
|
||||
|
||||
const $toast = useToast();
|
||||
|
||||
const signature = window.signature;
|
||||
|
||||
const mountPdf = async (url: string) => {
|
||||
const loadingTask = pdfjsLib.getDocument(url);
|
||||
pdf = await loadingTask.promise;
|
||||
pageCount.value = pdf.numPages;
|
||||
await setPage(1);
|
||||
};
|
||||
|
||||
const getRenderContext = (pdfPage: PDFPageProxy) => {
|
||||
const scale = 1;
|
||||
const viewport = pdfPage.getViewport({ scale });
|
||||
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
|
||||
const context = canvas.getContext("2d") as CanvasRenderingContext2D;
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
return {
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
};
|
||||
};
|
||||
|
||||
const setPage = async (page: number) => {
|
||||
const pdfPage = await pdf.getPage(page);
|
||||
const renderContext = getRenderContext(pdfPage);
|
||||
await pdfPage.render(renderContext);
|
||||
};
|
||||
|
||||
async function downloadAndOpen(): Promise<Blob> {
|
||||
let raw;
|
||||
try {
|
||||
raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion);
|
||||
} catch (e) {
|
||||
console.error("error while downloading and decrypting document", e);
|
||||
throw e;
|
||||
}
|
||||
await mountPdf(URL.createObjectURL(raw));
|
||||
initPdf();
|
||||
return raw;
|
||||
}
|
||||
|
||||
const initPdf = () => {
|
||||
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
|
||||
canvas.addEventListener(
|
||||
"pointerup",
|
||||
(e: PointerEvent) => canvasClick(e, canvas),
|
||||
false
|
||||
);
|
||||
setTimeout(() => addZones(page.value), 800);
|
||||
};
|
||||
|
||||
const hitSignature = (
|
||||
zone: SignatureZone,
|
||||
xy: number[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number
|
||||
) => {
|
||||
const scaleXToCanvas = (x: number) =>
|
||||
Math.round((x * canvasWidth) / zone.PDFPage.width);
|
||||
const scaleHeightToCanvas = (h: number) =>
|
||||
Math.round((h * canvasHeight) / zone.PDFPage.height);
|
||||
const scaleYToCanvas = (y: number) =>
|
||||
Math.round(zone.PDFPage.height - scaleHeightToCanvas(y));
|
||||
return (
|
||||
scaleXToCanvas(zone.x) < xy[0] &&
|
||||
xy[0] < scaleXToCanvas(zone.x + zone.width) &&
|
||||
scaleYToCanvas(zone.y) < xy[1] &&
|
||||
xy[1] < scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height)
|
||||
);
|
||||
};
|
||||
|
||||
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
|
||||
userSignatureZone.value = z;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
setPage(page.value);
|
||||
setTimeout(() => drawZone(z, ctx, canvas.width, canvas.height), 200);
|
||||
}
|
||||
};
|
||||
|
||||
const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) =>
|
||||
signature.zones
|
||||
.filter((z) => z.PDFPage.index + 1 === page.value)
|
||||
.map((z) => {
|
||||
if (
|
||||
hitSignature(z, [e.offsetX, e.offsetY], canvas.width, canvas.height)
|
||||
) {
|
||||
if (userSignatureZone.value === null) {
|
||||
selectZone(z, canvas);
|
||||
} else {
|
||||
if (userSignatureZone.value.index === z.index) {
|
||||
sign();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const turnPage = async (upOrDown: number) => {
|
||||
userSignatureZone.value = null;
|
||||
page.value = page.value + upOrDown;
|
||||
await setPage(page.value);
|
||||
setTimeout(() => addZones(page.value), 200);
|
||||
};
|
||||
|
||||
const turnSignature = async (upOrDown: number) => {
|
||||
let zoneIndex = userSignatureZone.value?.index ?? -1;
|
||||
if (zoneIndex < -1) {
|
||||
zoneIndex = -1;
|
||||
}
|
||||
if (zoneIndex < signature.zones.length) {
|
||||
zoneIndex = zoneIndex + upOrDown;
|
||||
} else {
|
||||
zoneIndex = 0;
|
||||
}
|
||||
let currentZone = signature.zones[zoneIndex];
|
||||
if (currentZone) {
|
||||
page.value = currentZone.PDFPage.index + 1;
|
||||
userSignatureZone.value = currentZone;
|
||||
const canvas = document.querySelectorAll("canvas")[0];
|
||||
selectZone(currentZone, canvas);
|
||||
}
|
||||
};
|
||||
|
||||
const drawZone = (
|
||||
zone: SignatureZone,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number
|
||||
) => {
|
||||
const unselectedBlue = "#007bff";
|
||||
const selectedBlue = "#034286";
|
||||
const scaleXToCanvas = (x: number) =>
|
||||
Math.round((x * canvasWidth) / zone.PDFPage.width);
|
||||
const scaleHeightToCanvas = (h: number) =>
|
||||
Math.round((h * canvasHeight) / zone.PDFPage.height);
|
||||
const scaleYToCanvas = (y: number) =>
|
||||
Math.round(zone.PDFPage.height - scaleHeightToCanvas(y));
|
||||
ctx.strokeStyle =
|
||||
userSignatureZone.value?.index === zone.index
|
||||
? selectedBlue
|
||||
: unselectedBlue;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = "bevel";
|
||||
ctx.strokeRect(
|
||||
scaleXToCanvas(zone.x),
|
||||
scaleYToCanvas(zone.y),
|
||||
scaleXToCanvas(zone.width),
|
||||
scaleHeightToCanvas(zone.height)
|
||||
);
|
||||
ctx.font = "bold 16px serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "black";
|
||||
const xText = scaleXToCanvas(zone.x) + scaleXToCanvas(zone.width) / 2;
|
||||
const yText = scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height) / 2;
|
||||
if (userSignatureZone.value?.index === zone.index) {
|
||||
ctx.fillStyle = selectedBlue;
|
||||
ctx.fillText("Signer ici", xText, yText);
|
||||
} else {
|
||||
ctx.fillStyle = unselectedBlue;
|
||||
ctx.fillText("Choisir cette", xText, yText - 12);
|
||||
ctx.fillText("zone de signature", xText, yText + 12);
|
||||
// ctx.strokeStyle = "#c6c6c6"; // halo
|
||||
// ctx.strokeText("Choisir cette", xText, yText - 12);
|
||||
// ctx.strokeText("zone de signature", xText, yText + 12);
|
||||
}
|
||||
};
|
||||
|
||||
const addZones = (page: number) => {
|
||||
const canvas = document.querySelectorAll("canvas")[0];
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
signature.zones
|
||||
.filter((z) => z.PDFPage.index + 1 === page)
|
||||
.map((z) => drawZone(z, ctx, canvas.width, canvas.height));
|
||||
}
|
||||
};
|
||||
|
||||
const checkSignature = () => {
|
||||
const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
|
||||
return makeFetch("GET", url)
|
||||
.then((r) => {
|
||||
signedState.value = r as SignedState;
|
||||
checkForReady();
|
||||
})
|
||||
.catch((error) => {
|
||||
signedState.value = "error";
|
||||
console.log("Error while checking the signature", error);
|
||||
$toast.error(
|
||||
`Erreur lors de la vérification de la signature: ${error.txt}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const maxTryForReady = 60; //2 minutes for trying to sign
|
||||
let tryForReady = 0;
|
||||
|
||||
const stopTrySigning = () => {
|
||||
loading.value = false;
|
||||
modalOpen.value = false;
|
||||
};
|
||||
|
||||
const checkForReady = () => {
|
||||
if (tryForReady > maxTryForReady) {
|
||||
stopTrySigning();
|
||||
tryForReady = 0;
|
||||
console.log("Reached the maximum number of tentative to try signing");
|
||||
$toast.error(
|
||||
"Le nombre maximum de tentatives pour essayer de signer est atteint"
|
||||
);
|
||||
}
|
||||
if (signedState.value === "rejected") {
|
||||
stopTrySigning();
|
||||
console.log("Signature rejected by the server");
|
||||
$toast.error("Signature rejetée par le serveur");
|
||||
}
|
||||
if (signedState.value === "canceled") {
|
||||
stopTrySigning();
|
||||
console.log("Signature canceled");
|
||||
$toast.error("Signature annulée");
|
||||
}
|
||||
if (signedState.value === "pending") {
|
||||
tryForReady = tryForReady + 1;
|
||||
setTimeout(() => checkSignature(), 2000);
|
||||
} else {
|
||||
stopTrySigning();
|
||||
if (signedState.value === "signed") {
|
||||
userSignatureZone.value = null;
|
||||
downloadAndOpen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sign = () => (modalOpen.value = true);
|
||||
|
||||
const confirmSign = () => {
|
||||
loading.value = true;
|
||||
const url = `/api/1.0/document/workflow/${signature.id}/signature-request`;
|
||||
const body = {
|
||||
storedObject: signature.storedObject,
|
||||
zone: userSignatureZone.value,
|
||||
};
|
||||
makeFetch("POST", url, body)
|
||||
.then((r) => {
|
||||
checkForReady();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("Error while posting the signature", error);
|
||||
stopTrySigning();
|
||||
$toast.error(
|
||||
`Erreur lors de la soumission de la signature: ${error.txt}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const undoSign = async () => {
|
||||
// const canvas = document.querySelectorAll("canvas")[0];
|
||||
// const ctx = canvas.getContext("2d");
|
||||
// if (ctx && userSignatureZone.value) {
|
||||
// //drawZone(userSignatureZone.value, ctx, canvas.width, canvas.height);
|
||||
// }
|
||||
await setPage(page.value);
|
||||
setTimeout(() => addZones(page.value), 200);
|
||||
userSignatureZone.value = null;
|
||||
};
|
||||
|
||||
downloadAndOpen();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#canvas {
|
||||
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
div#action-buttons {
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
background-color: white;
|
||||
z-index: 100;
|
||||
}
|
||||
div#turn-page {
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0.4rem;
|
||||
}
|
||||
}
|
||||
div.signature-modal-body {
|
||||
height: 8rem;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,30 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
// @ts-ignore
|
||||
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
|
||||
import App from "./App.vue";
|
||||
|
||||
const appMessages = {
|
||||
fr: {
|
||||
yes: 'Oui',
|
||||
are_you_sure: 'Êtes-vous sûr·e?',
|
||||
you_are_going_to_sign: 'Vous allez signer le document',
|
||||
signature_confirmation: 'Confirmation de la signature',
|
||||
sign: 'Signer',
|
||||
choose_another_signature: 'Choisir une autre zone de signature',
|
||||
cancel: 'Annuler',
|
||||
cancel_signing: 'Refuser de signer',
|
||||
last_sign_zone: 'Zone de signature précédente',
|
||||
next_sign_zone: 'Zone de signature suivante',
|
||||
electronic_signature_in_progress: 'Signature électronique en cours...',
|
||||
loading: 'Chargement...'
|
||||
}
|
||||
}
|
||||
|
||||
const i18n = _createI18n(appMessages);
|
||||
|
||||
const app = createApp({
|
||||
template: `<app></app>`,
|
||||
})
|
||||
.use(i18n)
|
||||
.component("app", App)
|
||||
.mount("#document-signature");
|
@@ -1,21 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectVersionCreated} from "../../types";
|
||||
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {encryptFile, uploadFile} from "../_components/helper";
|
||||
import {computed, ref, Ref} from "vue";
|
||||
|
||||
interface DropFileConfig {
|
||||
existingDoc?: StoredObject,
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {existingDoc: null});
|
||||
const props = defineProps<DropFileConfig>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', {stored_object_version: StoredObjectVersionCreated, stored_object: StoredObject}): void,
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
}>();
|
||||
|
||||
const is_dragging: Ref<boolean> = ref(false);
|
||||
const uploading: Ref<boolean> = ref(false);
|
||||
const display_filename: Ref<string|null> = ref(null);
|
||||
|
||||
const has_existing_doc = computed<boolean>(() => {
|
||||
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||
@@ -34,6 +35,7 @@ const onDragLeave = (e: Event) => {
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
console.log('on drop', e);
|
||||
e.preventDefault();
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
@@ -63,6 +65,7 @@ const onZoneClick = (e: Event) => {
|
||||
|
||||
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]);
|
||||
@@ -77,29 +80,23 @@ const onFileChange = async (event: Event): Promise<void> => {
|
||||
|
||||
const handleFile = async (file: File): Promise<void> => {
|
||||
uploading.value = true;
|
||||
display_filename.value = file.name;
|
||||
const type = file.type;
|
||||
|
||||
// create a stored_object if not exists
|
||||
let stored_object;
|
||||
if (null === props.existingDoc) {
|
||||
stored_object = await fetchNewStoredObject();
|
||||
} else {
|
||||
stored_object = props.existingDoc;
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
|
||||
const filename = await uploadVersion(encrypted, stored_object);
|
||||
const filename = await uploadFile(encrypted);
|
||||
|
||||
const stored_object_version: StoredObjectVersionCreated = {
|
||||
console.log(iv, jsonWebKey);
|
||||
|
||||
const storedObject: StoredObjectCreated = {
|
||||
filename: filename,
|
||||
iv: Array.from(iv),
|
||||
iv,
|
||||
keyInfos: jsonWebKey,
|
||||
type: type,
|
||||
persisted: false,
|
||||
status: "stored_object_created",
|
||||
}
|
||||
|
||||
emit('addDocument', {stored_object, stored_object_version});
|
||||
emit('addDocument', storedObject);
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
@@ -108,7 +105,7 @@ const handleFile = async (file: File): Promise<void> => {
|
||||
<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">
|
||||
<p v-if="has_existing_doc" class="file-icon">
|
||||
<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>
|
||||
@@ -120,6 +117,8 @@ const handleFile = async (file: File): Promise<void> => {
|
||||
<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>
|
||||
|
||||
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</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>
|
||||
@@ -135,19 +134,23 @@ const handleFile = async (file: File): Promise<void> => {
|
||||
.drop-file {
|
||||
width: 100%;
|
||||
|
||||
.file-icon {
|
||||
font-size: xx-large;
|
||||
}
|
||||
|
||||
.display-filename {
|
||||
font-variant: small-caps;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
& > .area, & > .waiting {
|
||||
width: 100%;
|
||||
height: 8rem;
|
||||
height: 10rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
// require for display in DropFileModal
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
& > .area {
|
||||
@@ -158,4 +161,5 @@ const handleFile = async (file: File): Promise<void> => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@@ -1,69 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
|
||||
import {computed, reactive} from "vue";
|
||||
import {useToast} from 'vue-toast-notification';
|
||||
|
||||
interface DropFileConfig {
|
||||
allowRemove: boolean,
|
||||
existingDoc?: StoredObject,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
allowRemove: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
|
||||
(e: 'removeDocument'): void
|
||||
}>();
|
||||
|
||||
const $toast = useToast();
|
||||
|
||||
const state = reactive({showModal: false});
|
||||
|
||||
const modalClasses = {"modal-dialog-centered": true, "modal-md": true};
|
||||
|
||||
const buttonState = computed<'add'|'replace'>(() => {
|
||||
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||
return 'add';
|
||||
}
|
||||
|
||||
return 'replace';
|
||||
})
|
||||
|
||||
function onAddDocument({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
|
||||
const message = buttonState.value === 'add' ? "Document ajouté" : "Document remplacé";
|
||||
$toast.success(message);
|
||||
emit('addDocument', {stored_object_version, stored_object});
|
||||
state.showModal = false;
|
||||
}
|
||||
|
||||
function onRemoveDocument(): void {
|
||||
emit('removeDocument');
|
||||
}
|
||||
|
||||
function openModal(): void {
|
||||
state.showModal = true;
|
||||
}
|
||||
|
||||
function closeModal(): void {
|
||||
state.showModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button v-if="buttonState === 'add'" @click="openModal" class="btn btn-create">Ajouter un document</button>
|
||||
<button v-else @click="openModal" class="btn btn-edit">Remplacer le document</button>
|
||||
<modal v-if="state.showModal" :modal-dialog-class="modalClasses" @close="closeModal">
|
||||
<template v-slot:body>
|
||||
<drop-file-widget :existing-doc="existingDoc" :allow-remove="allowRemove" @add-document="onAddDocument" @remove-document="onRemoveDocument" ></drop-file-widget>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
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?: StoredObject,
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
@@ -15,8 +15,8 @@ const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
|
||||
(e: 'removeDocument'): void
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
(e: 'removeDocument', stored_object: null): void
|
||||
}>();
|
||||
|
||||
const has_existing_doc = computed<boolean>(() => {
|
||||
@@ -45,14 +45,14 @@ const dav_link_href = computed<string|undefined>(() => {
|
||||
return props.existingDoc._links?.dav_link?.href;
|
||||
})
|
||||
|
||||
const onAddDocument = ({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void => {
|
||||
emit('addDocument', {stored_object, stored_object_version});
|
||||
const onAddDocument = (s: StoredObjectCreated): void => {
|
||||
emit('addDocument', s);
|
||||
}
|
||||
|
||||
const onRemoveDocument = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
emit('removeDocument');
|
||||
emit('removeDocument', null);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<a :class="props.classes" @click="download_and_open($event)">
|
||||
<a :class="props.classes" @click="download_and_open($event)" ref="btn">
|
||||
<i class="fa fa-file-pdf-o"></i>
|
||||
Télécharger en pdf
|
||||
</a>
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
||||
import mime from "mime";
|
||||
import {reactive} from "vue";
|
||||
import {StoredObject} from "../../types";
|
||||
import {reactive, ref} from "vue";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
|
||||
interface ConvertButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
@@ -24,6 +24,7 @@ interface DownloadButtonState {
|
||||
|
||||
const props = defineProps<ConvertButtonConfig>();
|
||||
const state: DownloadButtonState = reactive({content: null});
|
||||
const btn = ref<HTMLAnchorElement | null>(null);
|
||||
|
||||
async function download_and_open(event: Event): Promise<void> {
|
||||
const button = event.target as HTMLAnchorElement;
|
||||
@@ -41,11 +42,19 @@ async function download_and_open(event: Event): Promise<void> {
|
||||
}
|
||||
|
||||
button.click();
|
||||
const reset_pending = setTimeout(reset_state, 45000);
|
||||
}
|
||||
|
||||
function reset_state(): void {
|
||||
state.content = null;
|
||||
btn.value?.removeAttribute('download');
|
||||
btn.value?.removeAttribute('href');
|
||||
btn.value?.removeAttribute('type');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style scoped lang="sass">
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
|
@@ -63,7 +63,4 @@ const editionUntilFormatted = computed<string>(() => {
|
||||
.desktop-edit {
|
||||
text-align: center;
|
||||
}
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
@@ -11,13 +11,12 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {reactive, ref, nextTick, onMounted} from "vue";
|
||||
import {download_and_decrypt_doc} from "./helpers";
|
||||
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
||||
import mime from "mime";
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
|
||||
interface DownloadButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
atVersion: StoredObjectVersion,
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
classes: { [k: string]: boolean },
|
||||
filename?: string,
|
||||
}
|
||||
@@ -34,9 +33,8 @@ const state: DownloadButtonState = reactive({is_ready: false, is_running: false,
|
||||
const open_button = ref<HTMLAnchorElement | null>(null);
|
||||
|
||||
function buildDocumentName(): string {
|
||||
const document_name = props.filename ?? props.storedObject.title ?? 'document';
|
||||
|
||||
const ext = mime.getExtension(props.atVersion.type);
|
||||
const document_name = props.filename || 'document';
|
||||
const ext = mime.getExtension(props.storedObject.type);
|
||||
|
||||
if (null !== ext) {
|
||||
return document_name + '.' + ext;
|
||||
@@ -60,26 +58,46 @@ async function download_and_open(event: Event): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlInfo = build_download_info_link(props.storedObject.filename);
|
||||
let raw;
|
||||
|
||||
try {
|
||||
raw = await download_and_decrypt_doc(props.storedObject, props.atVersion);
|
||||
raw = await download_and_decrypt_doc(urlInfo, props.storedObject.keyInfos, new Uint8Array(props.storedObject.iv));
|
||||
} catch (e) {
|
||||
console.error("error while downloading and decrypting document");
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('document downloading (and decrypting) successfully');
|
||||
|
||||
console.log('creating the url')
|
||||
state.href_url = window.URL.createObjectURL(raw);
|
||||
console.log('url created', state.href_url);
|
||||
state.is_running = false;
|
||||
state.is_ready = true;
|
||||
console.log('new button marked as ready');
|
||||
console.log('will click on button');
|
||||
|
||||
console.log('openbutton is now', open_button.value);
|
||||
|
||||
await nextTick();
|
||||
console.log('next tick actions');
|
||||
console.log('openbutton after next tick', open_button.value);
|
||||
open_button.value?.click();
|
||||
console.log('open button should have been clicked');
|
||||
|
||||
const timer = setTimeout(reset_state, 45000);
|
||||
}
|
||||
|
||||
function reset_state(): void {
|
||||
state.href_url = '#';
|
||||
state.is_ready = false;
|
||||
state.is_running = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style scoped lang="sass">
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<script lang="ts" setup>
|
||||
import WopiEditButton from "./WopiEditButton.vue";
|
||||
import {build_wopi_editor_link} from "./helpers";
|
||||
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||
|
||||
interface WopiEditButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
@@ -22,6 +22,7 @@ const props = defineProps<WopiEditButtonConfig>();
|
||||
let executed = false;
|
||||
|
||||
async function beforeLeave(event: Event): Promise<true> {
|
||||
console.log(executed);
|
||||
if (props.executeBeforeLeave === undefined || executed === true) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
@@ -38,7 +39,7 @@ async function beforeLeave(event: Event): Promise<true> {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style scoped lang="sass">
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange, StoredObjectVersion} from "../../types";
|
||||
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types";
|
||||
|
||||
const MIMES_EDIT = new Set([
|
||||
'application/vnd.ms-powerpoint',
|
||||
@@ -98,13 +97,6 @@ const MIMES_VIEW = new Set([
|
||||
]
|
||||
])
|
||||
|
||||
export interface SignedUrlGet {
|
||||
method: 'GET'|'HEAD',
|
||||
url: string,
|
||||
expires: number,
|
||||
object_name: string,
|
||||
}
|
||||
|
||||
function is_extension_editable(mimeType: string): boolean {
|
||||
return MIMES_EDIT.has(mimeType);
|
||||
}
|
||||
@@ -117,20 +109,8 @@ function build_convert_link(uuid: string) {
|
||||
return `/chill/wopi/convert/${uuid}`;
|
||||
}
|
||||
|
||||
function build_download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): string {
|
||||
const url = `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/get`;
|
||||
|
||||
if (null !== atVersion) {
|
||||
const params = new URLSearchParams({version: atVersion.filename});
|
||||
|
||||
return url + '?' + params.toString();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<SignedUrlGet> {
|
||||
return makeFetch('GET', build_download_info_link(storedObject, atVersion));
|
||||
function build_download_info_link(object_name: string) {
|
||||
return `/asyncupload/temp_url/generate/GET?object_name=${object_name}`;
|
||||
}
|
||||
|
||||
function build_wopi_editor_link(uuid: string, returnPath?: string) {
|
||||
@@ -151,39 +131,43 @@ function download_doc(url: string): Promise<Blob> {
|
||||
});
|
||||
}
|
||||
|
||||
async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<Blob>
|
||||
async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKey, iv: Uint8Array): Promise<Blob>
|
||||
{
|
||||
const algo = 'AES-CBC';
|
||||
// get an url to download the object
|
||||
const downloadInfoResponse = await window.fetch(urlGenerator);
|
||||
|
||||
const atVersionToDownload = atVersion ?? storedObject.currentVersion;
|
||||
|
||||
if (null === atVersionToDownload) {
|
||||
throw new Error("no version associated to stored object");
|
||||
if (!downloadInfoResponse.ok) {
|
||||
throw new Error("error while downloading url " + downloadInfoResponse.status + " " + downloadInfoResponse.statusText);
|
||||
}
|
||||
|
||||
const downloadInfo= await download_info_link(storedObject, atVersionToDownload);
|
||||
|
||||
const downloadInfo = await downloadInfoResponse.json() as {url: string};
|
||||
const rawResponse = await window.fetch(downloadInfo.url);
|
||||
|
||||
if (!rawResponse.ok) {
|
||||
throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText);
|
||||
}
|
||||
|
||||
if (atVersionToDownload.iv.length === 0) {
|
||||
if (iv.length === 0) {
|
||||
console.log('returning document immediatly');
|
||||
return rawResponse.blob();
|
||||
}
|
||||
|
||||
console.log('start decrypting doc');
|
||||
|
||||
const rawBuffer = await rawResponse.arrayBuffer();
|
||||
|
||||
try {
|
||||
const key = await window.crypto.subtle
|
||||
.importKey('jwk', atVersionToDownload.keyInfos, { name: algo }, false, ['decrypt']);
|
||||
const iv = Uint8Array.from(atVersionToDownload.iv);
|
||||
.importKey('jwk', keyData, { name: algo }, false, ['decrypt']);
|
||||
console.log('key created');
|
||||
const decrypted = await window.crypto.subtle
|
||||
.decrypt({ name: algo, iv: iv }, key, rawBuffer);
|
||||
console.log('doc decrypted');
|
||||
|
||||
return Promise.resolve(new Blob([decrypted]));
|
||||
} catch (e) {
|
||||
console.error('encounter error while keys and decrypt operations');
|
||||
console.error('get error while keys and decrypt operations');
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
@@ -204,6 +188,7 @@ async function is_object_ready(storedObject: StoredObject): Promise<StoredObject
|
||||
|
||||
export {
|
||||
build_convert_link,
|
||||
build_download_info_link,
|
||||
build_wopi_editor_link,
|
||||
download_and_decrypt_doc,
|
||||
download_doc,
|
||||
|
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<a :class="btnClasses" :title="$t(buttonTitle)" @click="openModal">
|
||||
<span>{{ $t(buttonTitle) }}</span>
|
||||
</a>
|
||||
<teleport to="body">
|
||||
<div>
|
||||
<modal v-if="modal.showModal"
|
||||
:modalDialogClass="modal.modalDialogClass"
|
||||
@close="modal.showModal = false">
|
||||
|
||||
<template v-slot:header>
|
||||
{{ $t('upload_a_document') }}
|
||||
</template>
|
||||
|
||||
<template v-slot:body>
|
||||
<div id="dropZoneWrapper" ref="dropZoneWrapper">
|
||||
<div
|
||||
data-stored-object="data-stored-object"
|
||||
:data-label-preparing="$t('data_label_preparing')"
|
||||
:data-label-quiet-button="$t('data_label_quiet_button')"
|
||||
:data-label-ready="$t('data_label_ready')"
|
||||
:data-dict-file-too-big="$t('data_dict_file_too_big')"
|
||||
:data-dict-default-message="$t('data_dict_default_message')"
|
||||
:data-dict-remove-file="$t('data_dict_remove_file')"
|
||||
:data-dict-max-files-exceeded="$t('data_dict_max_files_exceeded')"
|
||||
:data-dict-cancel-upload="$t('data_dict_cancel_upload')"
|
||||
:data-dict-cancel-upload-confirm="$t('data_dict_cancel_upload_confirm')"
|
||||
:data-dict-upload-canceled="$t('data_dict_upload_canceled')"
|
||||
:data-dict-remove="$t('data_dict_remove')"
|
||||
:data-allow-remove="!options.required"
|
||||
data-temp-url-generator="/asyncupload/temp_url/generate/GET">
|
||||
<input
|
||||
type="hidden"
|
||||
data-async-file-upload="data-async-file-upload"
|
||||
data-generate-temp-url-post="/asyncupload/temp_url/generate/post?expires_delay=180&submit_delay=3600"
|
||||
data-temp-url-get="/asyncupload/temp_url/generate/GET"
|
||||
:data-max-files="options.maxFiles"
|
||||
:data-max-post-size="options.maxPostSize"
|
||||
:v-model="dataAsyncFileUpload"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
data-stored-object-key="1"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
data-stored-object-iv="1"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
data-async-file-type="1"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:footer>
|
||||
<button class="btn btn-create"
|
||||
@click.prevent="saveDocument">
|
||||
{{ $t('action.add')}}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
</modal>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
|
||||
import { searchForZones } from '../../module/async_upload/uploader';
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
const i18n = {
|
||||
messages: {
|
||||
fr: {
|
||||
upload_a_document: "Téléversez un document",
|
||||
data_label_preparing: "Chargement...",
|
||||
data_label_quiet_button: "Téléchargez le fichier existant",
|
||||
data_label_ready: "Prêt à montrer",
|
||||
data_dict_file_too_big: "Fichier trop volumineux",
|
||||
data_dict_default_message: "Glissez votre fichier ou cliquez ici",
|
||||
data_dict_remove_file: "Enlevez votre fichier pour en téléversez un autre",
|
||||
data_dict_max_files_exceeded: "Nombre maximum de fichiers atteint. Enlevez les fichiers précédents",
|
||||
data_dict_cancel_upload: "Annulez le téléversement",
|
||||
data_dict_cancel_upload_confirm: "Êtes-vous sûr·e de vouloir annuler ce téléversement?",
|
||||
data_dict_upload_canceled: "Téléversement annulé",
|
||||
data_dict_remove: "Enlevez le fichier existant",
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "AddAsyncUpload",
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
i18n,
|
||||
props: {
|
||||
buttonTitle: {
|
||||
type: String,
|
||||
default: 'Ajouter un document',
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: {
|
||||
maxFiles: 1,
|
||||
maxPostSize: 262144000, // 250MB
|
||||
required: false,
|
||||
}
|
||||
},
|
||||
btnClasses: {
|
||||
type: Object,
|
||||
default: {
|
||||
btn: true,
|
||||
'btn-create': true
|
||||
}
|
||||
}
|
||||
},
|
||||
emits: ['addDocument'],
|
||||
data() {
|
||||
return {
|
||||
modal: {
|
||||
showModal: false,
|
||||
modalDialogClass: "modal-dialog-centered modal-md"
|
||||
},
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
if (this.modal.showModal){
|
||||
searchForZones(this.$refs.dropZoneWrapper);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openModal() {
|
||||
this.modal.showModal = true;
|
||||
},
|
||||
saveDocument() {
|
||||
const dropzone = this.$refs.dropZoneWrapper;
|
||||
if (dropzone) {
|
||||
const inputKey = dropzone.querySelector('input[data-stored-object-key]');
|
||||
const inputIv = dropzone.querySelector('input[data-stored-object-iv]');
|
||||
const inputObject = dropzone.querySelector('input[data-async-file-upload]');
|
||||
const inputType = dropzone.querySelector('input[data-async-file-type]');
|
||||
|
||||
const url = '/api/1.0/docstore/stored-object.json';
|
||||
const body = {
|
||||
filename: inputObject.value,
|
||||
keyInfos: JSON.parse(inputKey.value),
|
||||
iv: JSON.parse(inputIv.value),
|
||||
type: inputType.value,
|
||||
};
|
||||
makeFetch('POST', url, body)
|
||||
.then(r => {
|
||||
this.$emit("addDocument", r);
|
||||
this.modal.showModal = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name === 'ValidationException') {
|
||||
for (let v of error.violations) {
|
||||
this.$toast.open({message: v });
|
||||
}
|
||||
} else {
|
||||
console.error(error);
|
||||
this.$toast.open({message: 'An error occurred'});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.$toast.open({message: 'An error occurred - drop zone not found'});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<a
|
||||
class="btn btn-download"
|
||||
:title="$t(buttonTitle)"
|
||||
:data-key=JSON.stringify(storedObject.keyInfos)
|
||||
:data-iv=JSON.stringify(storedObject.iv)
|
||||
:data-mime-type=storedObject.type
|
||||
:data-label-preparing="$t('dataLabelPreparing')"
|
||||
:data-label-ready="$t('dataLabelReady')"
|
||||
:data-temp-url-get-generator="url"
|
||||
@click.once="downloadDocument">
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { download } from '../../module/async_upload/downloader';
|
||||
|
||||
const i18n = {
|
||||
messages: {
|
||||
fr: {
|
||||
dataLabelPreparing: "Chargement...",
|
||||
dataLabelReady: "",
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "AddAsyncUploadDownloader",
|
||||
i18n,
|
||||
props: [
|
||||
'buttonTitle',
|
||||
'storedObject'
|
||||
],
|
||||
computed: {
|
||||
url() {
|
||||
return `/asyncupload/temp_url/generate/GET?object_name=${this.storedObject.filename}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
downloadDocument(e) {
|
||||
download(e.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@@ -1,5 +1,5 @@
|
||||
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||
import {PostStoreObjectSignature, StoredObject} from "../../types";
|
||||
import {PostStoreObjectSignature} from "../../types";
|
||||
|
||||
const algo = 'AES-CBC';
|
||||
|
||||
@@ -21,22 +21,11 @@ const createFilename = (): string => {
|
||||
return text;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a new stored object from the server.
|
||||
*
|
||||
* @async
|
||||
* @function fetchNewStoredObject
|
||||
* @returns {Promise<StoredObject>} A Promise that resolves to the newly created StoredObject.
|
||||
*/
|
||||
export const fetchNewStoredObject = async (): Promise<StoredObject> => {
|
||||
return makeFetch("POST", '/api/1.0/doc-store/stored-object/create', null);
|
||||
}
|
||||
|
||||
export const uploadVersion = async (uploadFile: ArrayBuffer, storedObject: StoredObject): Promise<string> => {
|
||||
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", `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/post` + "?" + params.toString());
|
||||
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
|
||||
const suffix = createFilename();
|
||||
const filename = asyncData.prefix + suffix;
|
||||
const formData = new FormData();
|
||||
@@ -61,6 +50,7 @@ export const uploadVersion = async (uploadFile: ArrayBuffer, storedObject: Store
|
||||
}
|
||||
|
||||
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);
|
@@ -38,11 +38,6 @@
|
||||
|
||||
{% if display_action is defined and display_action == true %}
|
||||
<ul class="record_actions">
|
||||
{% for dam in display_action_more|default([]) %}
|
||||
<li>
|
||||
{{ dam|raw }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if document.course != null and is_granted('CHILL_PERSON_ACCOMPANYING_PERIOD_SEE', document.course) %}
|
||||
<li>
|
||||
<a href="{{ path('chill_person_accompanying_course_index', {'accompanying_period_id': document.course.id}) }}" class="btn btn-show change-icon">
|
||||
|
@@ -71,7 +71,7 @@
|
||||
</li>
|
||||
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
|
||||
<li>
|
||||
{{ document.object|chill_document_button_group(document.title) }}
|
||||
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }}
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
|
||||
@@ -90,7 +90,7 @@
|
||||
{% else %}
|
||||
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
|
||||
<li>
|
||||
{{ document.object|chill_document_button_group(document.title) }}
|
||||
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }}
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>
|
||||
|
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon">
|
||||
<title>Signature</title>
|
||||
|
||||
{{ encore_entry_link_tags('mod_bootstrap') }}
|
||||
{{ encore_entry_link_tags('mod_forkawesome') }}
|
||||
{{ encore_entry_link_tags('chill') }}
|
||||
{{ encore_entry_link_tags('vue_document_signature') }}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
{% block js %}
|
||||
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
|
||||
<script type="text/javascript">
|
||||
window.signature = {{ signature|json_encode|raw }};
|
||||
</script>
|
||||
{{ encore_entry_script_tags('vue_document_signature') }}
|
||||
{% endblock %}
|
||||
|
||||
<div class="content" id="content">
|
||||
<div class="container-xxl">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-12 col-lg-9 my-5 m-auto">
|
||||
<h4>{{ 'Document %title%' | trans({ '%title%': document.title }) }}</h4>
|
||||
<div class="row" id="document-signature"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@@ -70,12 +70,12 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov
|
||||
return [];
|
||||
}
|
||||
|
||||
public function supports($attribute, $subject): bool
|
||||
protected function supports($attribute, $subject): bool
|
||||
{
|
||||
return $this->voterHelper->supports($attribute, $subject);
|
||||
}
|
||||
|
||||
public function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
if (!$token->getUser() instanceof User) {
|
||||
return false;
|
||||
|
@@ -12,7 +12,6 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
@@ -23,7 +22,6 @@ final class AsyncUploadVoter extends Voter
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly StoredObjectRepository $storedObjectRepository,
|
||||
) {}
|
||||
|
||||
protected function supports($attribute, $subject): bool
|
||||
@@ -34,16 +32,10 @@ final class AsyncUploadVoter extends Voter
|
||||
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
/** @var SignedUrl $subject */
|
||||
if (!in_array($subject->method, ['POST', 'GET', 'HEAD', 'PUT'], true)) {
|
||||
if (!in_array($subject->method, ['POST', 'GET', 'HEAD'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$storedObject = $this->storedObjectRepository->findOneBy(['filename' => $subject->object_name]);
|
||||
|
||||
return match ($subject->method) {
|
||||
'GET', 'HEAD' => $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject),
|
||||
'PUT' => $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject),
|
||||
'POST' => $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'),
|
||||
};
|
||||
return $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN');
|
||||
}
|
||||
}
|
||||
|
@@ -12,10 +12,9 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* Voter for the content of a stored object.
|
||||
@@ -24,10 +23,6 @@ use Symfony\Component\Security\Core\Security;
|
||||
*/
|
||||
class StoredObjectVoter extends Voter
|
||||
{
|
||||
public const LOG_PREFIX = '[stored object voter] ';
|
||||
|
||||
public function __construct(private readonly Security $security, private readonly iterable $storedObjectVoters, private readonly LoggerInterface $logger) {}
|
||||
|
||||
protected function supports($attribute, $subject): bool
|
||||
{
|
||||
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
|
||||
@@ -37,28 +32,24 @@ class StoredObjectVoter extends Voter
|
||||
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
/** @var StoredObject $subject */
|
||||
$attributeAsEnum = StoredObjectRoleEnum::from($attribute);
|
||||
|
||||
// Loop through context-specific voters
|
||||
foreach ($this->storedObjectVoters as $storedObjectVoter) {
|
||||
if ($storedObjectVoter->supports($attributeAsEnum, $subject)) {
|
||||
$grant = $storedObjectVoter->voteOnAttribute($attributeAsEnum, $subject, $token);
|
||||
|
||||
if (false === $grant) {
|
||||
$this->logger->debug(self::LOG_PREFIX.'deny access by storedObjectVoter', ['stored_object_voter' => $storedObjectVoter::class]);
|
||||
}
|
||||
|
||||
return $grant;
|
||||
}
|
||||
if (
|
||||
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User role-based fallback
|
||||
if ($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')) {
|
||||
// TODO: this maybe considered as a security issue, as all authenticated users can reach a stored object which
|
||||
// is potentially detached from an existing entity.
|
||||
return true;
|
||||
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
$askedRole = StoredObjectRoleEnum::from($attribute);
|
||||
$tokenRoleAuthorization =
|
||||
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
|
||||
|
||||
return match ($askedRole) {
|
||||
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
|
||||
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
|
||||
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
|
||||
{
|
||||
abstract protected function getRepository(): AssociatedEntityToStoredObjectInterface;
|
||||
|
||||
/**
|
||||
* @return class-string
|
||||
*/
|
||||
abstract protected function getClass(): string;
|
||||
|
||||
abstract protected function attributeToRole(StoredObjectRoleEnum $attribute): string;
|
||||
|
||||
abstract protected function canBeAssociatedWithWorkflow(): bool;
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null,
|
||||
) {}
|
||||
|
||||
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
|
||||
{
|
||||
$class = $this->getClass();
|
||||
|
||||
return $this->getRepository()->findAssociatedEntityToStoredObject($subject) instanceof $class;
|
||||
}
|
||||
|
||||
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
|
||||
{
|
||||
// Retrieve the related accompanying course document
|
||||
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
|
||||
|
||||
// Determine the attribute to pass to AccompanyingCourseDocumentVoter
|
||||
$voterAttribute = $this->attributeToRole($attribute);
|
||||
|
||||
if (false === $this->security->isGranted($voterAttribute, $entity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) {
|
||||
if (null === $this->workflowDocumentService) {
|
||||
throw new \LogicException('Provide a workflow document service');
|
||||
}
|
||||
|
||||
return $this->workflowDocumentService->notBlockedByWorkflow($entity);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
||||
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
|
||||
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
|
||||
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccompanyingCourseDocumentRepository $repository,
|
||||
Security $security,
|
||||
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
|
||||
) {
|
||||
parent::__construct($security, $workflowDocumentService);
|
||||
}
|
||||
|
||||
protected function getRepository(): AssociatedEntityToStoredObjectInterface
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
||||
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
|
||||
{
|
||||
return match ($attribute) {
|
||||
StoredObjectRoleEnum::EDIT => AccompanyingCourseDocumentVoter::UPDATE,
|
||||
StoredObjectRoleEnum::SEE => AccompanyingCourseDocumentVoter::SEE_DETAILS,
|
||||
};
|
||||
}
|
||||
|
||||
protected function getClass(): string
|
||||
{
|
||||
return AccompanyingCourseDocument::class;
|
||||
}
|
||||
|
||||
protected function canBeAssociatedWithWorkflow(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\PersonDocument;
|
||||
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
|
||||
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
|
||||
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PersonDocumentRepository $repository,
|
||||
Security $security,
|
||||
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
|
||||
) {
|
||||
parent::__construct($security, $workflowDocumentService);
|
||||
}
|
||||
|
||||
protected function getRepository(): AssociatedEntityToStoredObjectInterface
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
||||
protected function getClass(): string
|
||||
{
|
||||
return PersonDocument::class;
|
||||
}
|
||||
|
||||
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
|
||||
{
|
||||
return match ($attribute) {
|
||||
StoredObjectRoleEnum::EDIT => PersonDocumentVoter::UPDATE,
|
||||
StoredObjectRoleEnum::SEE => PersonDocumentVoter::SEE_DETAILS,
|
||||
};
|
||||
}
|
||||
|
||||
protected function canBeAssociatedWithWorkflow(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
|
||||
interface StoredObjectVoterInterface
|
||||
{
|
||||
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;
|
||||
|
||||
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool;
|
||||
}
|
@@ -12,75 +12,37 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
|
||||
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||
use Symfony\Component\Serializer\Exception\LogicException;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
|
||||
|
||||
/**
|
||||
* Implements the DenormalizerInterface and is responsible for denormalizing data into StoredObject objects.
|
||||
*
|
||||
* If a new StoredObjectVersion has been added to the StoredObject, the version is created here and registered
|
||||
* to the StoredObject.
|
||||
*/
|
||||
class StoredObjectDenormalizer implements DenormalizerInterface
|
||||
{
|
||||
use ObjectToPopulateTrait;
|
||||
|
||||
public function __construct(private readonly StoredObjectRepositoryInterface $storedObjectRepository) {}
|
||||
public function __construct(private readonly StoredObjectRepository $storedObjectRepository) {}
|
||||
|
||||
public function denormalize($data, $type, $format = null, array $context = []): ?StoredObject
|
||||
public function denormalize($data, $type, $format = null, array $context = [])
|
||||
{
|
||||
$storedObject = $this->extractObjectToPopulate(StoredObject::class, $context);
|
||||
$object = $this->extractObjectToPopulate(StoredObject::class, $context);
|
||||
|
||||
if (null === $storedObject) {
|
||||
if (array_key_exists('uuid', $data)) {
|
||||
$storedObject = $this->storedObjectRepository->findOneByUUID($data['uuid']);
|
||||
} else {
|
||||
$storedObject = $this->storedObjectRepository->find($data['id']);
|
||||
}
|
||||
|
||||
if (null === $storedObject) {
|
||||
throw new LogicException('Object not found');
|
||||
}
|
||||
if (null !== $object) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
$storedObject->setTitle($data['title'] ?? $storedObject->getTitle());
|
||||
|
||||
if (true === ($data['currentVersion']['persisted'] ?? true)) {
|
||||
// nothing has change, stop here
|
||||
return $storedObject;
|
||||
}
|
||||
|
||||
if ([] !== $diff = array_diff(['filename', 'iv', 'keyInfos', 'type'], array_keys($data['currentVersion']))) {
|
||||
throw new TransformationFailedException(sprintf('missing some keys in currentVersion: %s', implode(', ', $diff)));
|
||||
}
|
||||
|
||||
$storedObject->registerVersion(
|
||||
$data['currentVersion']['iv'],
|
||||
$data['currentVersion']['keyInfos'],
|
||||
$data['currentVersion']['type'],
|
||||
$data['currentVersion']['filename']
|
||||
);
|
||||
|
||||
return $storedObject;
|
||||
return $this->storedObjectRepository->find($data['id']);
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, $type, $format = null): bool
|
||||
public function supportsDenormalization($data, $type, $format = null)
|
||||
{
|
||||
if (StoredObject::class !== $type) {
|
||||
if (false === \is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (false === is_array($data)) {
|
||||
if (false === \array_key_exists('id', $data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists('id', $data) || array_key_exists('uuid', $data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return StoredObject::class === $type;
|
||||
}
|
||||
}
|
||||
|
@@ -15,8 +15,6 @@ 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\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
@@ -29,44 +27,41 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
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,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = [])
|
||||
{
|
||||
/** @var StoredObject $object */
|
||||
$datas = [
|
||||
'id' => $object->getId(),
|
||||
'datas' => $object->getDatas(),
|
||||
'prefix' => $object->getPrefix(),
|
||||
'filename' => $object->getFilename(),
|
||||
'id' => $object->getId(),
|
||||
'iv' => $object->getIv(),
|
||||
'keyInfos' => $object->getKeyInfos(),
|
||||
'title' => $object->getTitle(),
|
||||
'uuid' => $object->getUuid()->toString(),
|
||||
'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),
|
||||
'currentVersion' => $this->normalizer->normalize($object->getCurrentVersion(), $format, [...$context, [AbstractNormalizer::GROUPS => 'read']]),
|
||||
'totalVersions' => $object->getVersions()->count(),
|
||||
];
|
||||
|
||||
// deprecated property
|
||||
$datas['creationDate'] = $datas['createdAt'];
|
||||
|
||||
$canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
|
||||
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);
|
||||
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||
|
||||
$datas['_permissions'] = [
|
||||
'canEdit' => $canEdit,
|
||||
'canSee' => $canSee,
|
||||
];
|
||||
|
||||
if ($canSee || $canEdit) {
|
||||
if ($canDavSee || $canDavEdit) {
|
||||
$accessToken = $this->JWTDavTokenProvider->createToken(
|
||||
$object,
|
||||
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||
);
|
||||
|
||||
$datas['_links'] = [
|
||||
@@ -79,7 +74,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
),
|
||||
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->getTimestamp(),
|
||||
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@@ -1,45 +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\StoredObjectVersion;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = [])
|
||||
{
|
||||
if (!$object instanceof StoredObjectVersion) {
|
||||
throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $object->getId(),
|
||||
'filename' => $object->getFilename(),
|
||||
'version' => $object->getVersion(),
|
||||
'iv' => array_values($object->getIv()),
|
||||
'keyInfos' => $object->getKeyInfos(),
|
||||
'type' => $object->getType(),
|
||||
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||
];
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, ?string $format = null, array $context = [])
|
||||
{
|
||||
return $data instanceof StoredObjectVersion;
|
||||
}
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
|
||||
|
||||
/**
|
||||
* Message which is received when a pdf is signed.
|
||||
*/
|
||||
final readonly class PdfSignedMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $signatureId,
|
||||
public readonly int $signatureZoneIndex,
|
||||
public readonly string $content,
|
||||
) {}
|
||||
}
|
@@ -1,62 +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\Service\Signature\Driver\BaseSigner;
|
||||
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
|
||||
|
||||
final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
|
||||
{
|
||||
/**
|
||||
* log prefix.
|
||||
*/
|
||||
private const P = '[pdf signed message] ';
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private EntityWorkflowManager $entityWorkflowManager,
|
||||
private StoredObjectManagerInterface $storedObjectManager,
|
||||
private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
public function __invoke(PdfSignedMessage $message): void
|
||||
{
|
||||
$this->logger->info(self::P.'a message is received', ['signaturedId' => $message->signatureId]);
|
||||
|
||||
$signature = $this->entityWorkflowStepSignatureRepository->find($message->signatureId);
|
||||
|
||||
if (null === $signature) {
|
||||
throw new \RuntimeException('no signature found');
|
||||
}
|
||||
|
||||
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($signature->getStep()->getEntityWorkflow());
|
||||
|
||||
if (null === $storedObject) {
|
||||
throw new \RuntimeException('no stored object found');
|
||||
}
|
||||
|
||||
$this->storedObjectManager->write($storedObject, $message->content);
|
||||
|
||||
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now());
|
||||
$signature->setZoneSignatureIndex($message->signatureZoneIndex);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
}
|
@@ -1,67 +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\Service\Signature\Driver\BaseSigner;
|
||||
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
|
||||
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
|
||||
|
||||
/**
|
||||
* Decode (and requeue) @see{PdfSignedMessage}, which comes from an external producer.
|
||||
*/
|
||||
final readonly class PdfSignedMessageSerializer implements SerializerInterface
|
||||
{
|
||||
public function decode(array $encodedEnvelope): Envelope
|
||||
{
|
||||
$body = $encodedEnvelope['body'];
|
||||
|
||||
try {
|
||||
$decoded = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
throw new MessageDecodingFailedException('Could not deserialize message', previous: $e);
|
||||
}
|
||||
|
||||
if (!array_key_exists('signatureId', $decoded) || !array_key_exists('content', $decoded)) {
|
||||
throw new MessageDecodingFailedException('Could not find expected keys: signatureId or content');
|
||||
}
|
||||
|
||||
$content = base64_decode((string) $decoded['content'], true);
|
||||
|
||||
if (false === $content) {
|
||||
throw new MessageDecodingFailedException('Invalid character found in the base64 encoded content');
|
||||
}
|
||||
|
||||
$message = new PdfSignedMessage($decoded['signatureId'], $decoded['signatureZoneIndex'], $content);
|
||||
|
||||
return new Envelope($message);
|
||||
}
|
||||
|
||||
public function encode(Envelope $envelope): array
|
||||
{
|
||||
$message = $envelope->getMessage();
|
||||
|
||||
if (!$message instanceof PdfSignedMessage) {
|
||||
throw new MessageDecodingFailedException('Expected a PdfSignedMessage');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'signatureId' => $message->signatureId,
|
||||
'signatureZoneIndex' => $message->signatureZoneIndex,
|
||||
'content' => base64_encode($message->content),
|
||||
];
|
||||
|
||||
return [
|
||||
'body' => json_encode($data, JSON_THROW_ON_ERROR),
|
||||
'headers' => [],
|
||||
];
|
||||
}
|
||||
}
|
@@ -1,29 +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\Service\Signature\Driver\BaseSigner;
|
||||
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
|
||||
|
||||
/**
|
||||
* Message which is sent when we request a signature on a pdf.
|
||||
*/
|
||||
final readonly class RequestPdfSignMessage
|
||||
{
|
||||
public function __construct(
|
||||
public int $signatureId,
|
||||
public PDFSignatureZone $PDFSignatureZone,
|
||||
public int $signatureZoneIndex,
|
||||
public string $reason,
|
||||
public string $signerText,
|
||||
public string $content,
|
||||
) {}
|
||||
}
|
@@ -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\Service\Signature\Driver\BaseSigner;
|
||||
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
|
||||
use Symfony\Component\Messenger\Stamp\NonSendableStampInterface;
|
||||
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* Serialize a RequestPdfSignMessage, for external consumer.
|
||||
*/
|
||||
final readonly class RequestPdfSignMessageSerializer implements SerializerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private NormalizerInterface $normalizer,
|
||||
private DenormalizerInterface $denormalizer,
|
||||
) {}
|
||||
|
||||
public function decode(array $encodedEnvelope): Envelope
|
||||
{
|
||||
$body = $encodedEnvelope['body'];
|
||||
$headers = $encodedEnvelope['headers'];
|
||||
|
||||
if (RequestPdfSignMessage::class !== ($headers['Message'] ?? null)) {
|
||||
throw new MessageDecodingFailedException('serializer does not support this message');
|
||||
}
|
||||
|
||||
$data = json_decode((string) $body, true);
|
||||
|
||||
$zoneSignature = $this->denormalizer->denormalize($data['signatureZone'], PDFSignatureZone::class, 'json', [
|
||||
AbstractNormalizer::GROUPS => ['write'],
|
||||
]);
|
||||
|
||||
$content = base64_decode((string) $data['content'], true);
|
||||
|
||||
if (false === $content) {
|
||||
throw new MessageDecodingFailedException('the content could not be converted from base64 encoding');
|
||||
}
|
||||
|
||||
$message = new RequestPdfSignMessage(
|
||||
$data['signatureId'],
|
||||
$zoneSignature,
|
||||
$data['signatureZoneIndex'],
|
||||
$data['reason'],
|
||||
$data['signerText'],
|
||||
$content,
|
||||
);
|
||||
|
||||
// in case of redelivery, unserialize any stamps
|
||||
$stamps = [];
|
||||
if (isset($headers['stamps'])) {
|
||||
$stamps = unserialize($headers['stamps']);
|
||||
}
|
||||
|
||||
return new Envelope($message, $stamps);
|
||||
}
|
||||
|
||||
public function encode(Envelope $envelope): array
|
||||
{
|
||||
$message = $envelope->getMessage();
|
||||
|
||||
if (!$message instanceof RequestPdfSignMessage) {
|
||||
throw new MessageDecodingFailedException('Message is not a RequestPdfSignMessage');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'signatureId' => $message->signatureId,
|
||||
'signatureZoneIndex' => $message->signatureZoneIndex,
|
||||
'signatureZone' => $this->normalizer->normalize($message->PDFSignatureZone, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
'reason' => $message->reason,
|
||||
'signerText' => $message->signerText,
|
||||
'content' => base64_encode($message->content),
|
||||
];
|
||||
|
||||
$allStamps = [];
|
||||
foreach ($envelope->all() as $stamp) {
|
||||
if ($stamp instanceof NonSendableStampInterface) {
|
||||
continue;
|
||||
}
|
||||
$allStamps = [...$allStamps, ...$stamp];
|
||||
}
|
||||
|
||||
return [
|
||||
'body' => json_encode($data, JSON_THROW_ON_ERROR, 512),
|
||||
'headers' => [
|
||||
'stamps' => serialize($allStamps),
|
||||
'Message' => RequestPdfSignMessage::class,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
@@ -1,33 +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\Service\Signature;
|
||||
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
||||
final readonly class PDFPage
|
||||
{
|
||||
public function __construct(
|
||||
#[Groups(['read'])]
|
||||
public int $index,
|
||||
#[Groups(['read'])]
|
||||
public float $width,
|
||||
#[Groups(['read'])]
|
||||
public float $height,
|
||||
) {}
|
||||
|
||||
public function equals(self $page): bool
|
||||
{
|
||||
return $page->index === $this->index
|
||||
&& round($page->width, 2) === round($this->width, 2)
|
||||
&& round($page->height, 2) === round($this->height, 2);
|
||||
}
|
||||
}
|
@@ -1,43 +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\Service\Signature;
|
||||
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
||||
final readonly class PDFSignatureZone
|
||||
{
|
||||
public function __construct(
|
||||
#[Groups(['read'])]
|
||||
public int $index,
|
||||
#[Groups(['read'])]
|
||||
public float $x,
|
||||
#[Groups(['read'])]
|
||||
public float $y,
|
||||
#[Groups(['read'])]
|
||||
public float $height,
|
||||
#[Groups(['read'])]
|
||||
public float $width,
|
||||
#[Groups(['read'])]
|
||||
public PDFPage $PDFPage,
|
||||
) {}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return
|
||||
$this->index == $other->index
|
||||
&& $this->x == $other->x
|
||||
&& $this->y == $other->y
|
||||
&& $this->height == $other->height
|
||||
&& $this->width == $other->width
|
||||
&& $this->PDFPage->equals($other->PDFPage);
|
||||
}
|
||||
}
|
@@ -1,69 +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\Service\Signature;
|
||||
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
|
||||
class PDFSignatureZoneAvailable
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityWorkflowManager $entityWorkflowManager,
|
||||
private readonly PDFSignatureZoneParser $pdfSignatureZoneParser,
|
||||
private readonly StoredObjectManagerInterface $storedObjectManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<PDFSignatureZone>
|
||||
*/
|
||||
public function getAvailableSignatureZones(EntityWorkflow $entityWorkflow): array
|
||||
{
|
||||
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
|
||||
|
||||
if (null === $storedObject) {
|
||||
throw new \RuntimeException('No stored object found');
|
||||
}
|
||||
|
||||
if ('application/pdf' !== $storedObject->getType()) {
|
||||
throw new \RuntimeException('Only PDF documents are supported');
|
||||
}
|
||||
|
||||
$zones = $this->pdfSignatureZoneParser->findSignatureZones($this->storedObjectManager->read($storedObject));
|
||||
$signatureZonesIndexes = array_map(
|
||||
fn (EntityWorkflowStepSignature $step) => $step->getZoneSignatureIndex(),
|
||||
$this->collectSignaturesInUse($entityWorkflow)
|
||||
);
|
||||
|
||||
return array_values(array_filter($zones, fn (PDFSignatureZone $zone) => !in_array($zone->index, $signatureZonesIndexes, true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<EntityWorkflowStepSignature>
|
||||
*/
|
||||
private function collectSignaturesInUse(EntityWorkflow $entityWorkflow): array
|
||||
{
|
||||
return array_reduce($entityWorkflow->getSteps()->toArray(), function (array $result, EntityWorkflowStep $step) {
|
||||
$current = [...$result];
|
||||
foreach ($step->getSignatures() as $signature) {
|
||||
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
|
||||
$current[] = $signature;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
}, []);
|
||||
}
|
||||
}
|
@@ -1,60 +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\Service\Signature;
|
||||
|
||||
use Smalot\PdfParser\Parser;
|
||||
|
||||
class PDFSignatureZoneParser
|
||||
{
|
||||
public const ZONE_SIGNATURE_START = 'signature_zone';
|
||||
|
||||
private readonly Parser $parser;
|
||||
|
||||
public function __construct(
|
||||
public float $defaultHeight = 90.0,
|
||||
public float $defaultWidth = 180.0,
|
||||
) {
|
||||
$this->parser = new Parser();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<PDFSignatureZone>
|
||||
*/
|
||||
public function findSignatureZones(string $fileContent): array
|
||||
{
|
||||
$pdf = $this->parser->parseContent($fileContent);
|
||||
$zones = [];
|
||||
|
||||
$defaults = $pdf->getObjectsByType('Pages');
|
||||
$defaultPage = reset($defaults);
|
||||
$defaultPageDetails = $defaultPage->getDetails();
|
||||
$zoneIndex = 0;
|
||||
|
||||
foreach ($pdf->getPages() as $index => $page) {
|
||||
$details = $page->getDetails();
|
||||
$pdfPage = new PDFPage(
|
||||
$index,
|
||||
(float) ($details['MediaBox'][2] ?? $defaultPageDetails['MediaBox'][2]),
|
||||
(float) ($details['MediaBox'][3] ?? $defaultPageDetails['MediaBox'][3]),
|
||||
);
|
||||
|
||||
foreach ($page->getDataTm() as $dataTm) {
|
||||
if (str_starts_with((string) $dataTm[1], self::ZONE_SIGNATURE_START)) {
|
||||
$zones[] = new PDFSignatureZone($zoneIndex, (float) $dataTm[0][4], (float) $dataTm[0][5], $this->defaultHeight, $this->defaultWidth, $pdfPage);
|
||||
++$zoneIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $zones;
|
||||
}
|
||||
}
|
@@ -1,65 +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\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
|
||||
use Chill\MainBundle\Cron\CronJobInterface;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Represents a cron job that removes expired stored objects.
|
||||
*
|
||||
* This cronjob is executed every 7days, to remove expired stored object. For every
|
||||
* expired stored object, every version is sent to message bus for async deletion.
|
||||
*/
|
||||
final readonly class RemoveExpiredStoredObjectCronJob implements CronJobInterface
|
||||
{
|
||||
public const KEY = 'remove-expired-stored-object';
|
||||
|
||||
private const LAST_DELETED_KEY = 'last-deleted-stored-object-id';
|
||||
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private MessageBusInterface $messageBus,
|
||||
private StoredObjectRepositoryInterface $storedObjectRepository,
|
||||
) {}
|
||||
|
||||
public function canRun(?CronJobExecution $cronJobExecution): bool
|
||||
{
|
||||
if (null === $cronJobExecution) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P7D'));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return self::KEY;
|
||||
}
|
||||
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
$lastDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0;
|
||||
|
||||
foreach ($this->storedObjectRepository->findByExpired($this->clock->now()) as $storedObject) {
|
||||
foreach ($storedObject->getVersions() as $version) {
|
||||
$this->messageBus->dispatch(new RemoveOldVersionMessage($version->getId()));
|
||||
}
|
||||
$lastDeleted = max($lastDeleted, $storedObject->getId());
|
||||
}
|
||||
|
||||
return [self::LAST_DELETED_KEY => $lastDeleted];
|
||||
}
|
||||
}
|
@@ -1,60 +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\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
|
||||
use Chill\MainBundle\Cron\CronJobInterface;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final readonly class RemoveOldVersionCronJob implements CronJobInterface
|
||||
{
|
||||
public const KEY = 'remove-old-stored-object-version';
|
||||
|
||||
private const LAST_DELETED_KEY = 'last-deleted-stored-object-version-id';
|
||||
|
||||
public const KEEP_INTERVAL = 'P90D';
|
||||
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private MessageBusInterface $messageBus,
|
||||
private StoredObjectVersionRepository $storedObjectVersionRepository,
|
||||
) {}
|
||||
|
||||
public function canRun(?CronJobExecution $cronJobExecution): bool
|
||||
{
|
||||
if (null === $cronJobExecution) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P1D'));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return self::KEY;
|
||||
}
|
||||
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
$deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL));
|
||||
$maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0;
|
||||
|
||||
foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime($deleteBeforeDate) as $id) {
|
||||
$this->messageBus->dispatch(new RemoveOldVersionMessage($id));
|
||||
$maxDeleted = max($maxDeleted, $id);
|
||||
}
|
||||
|
||||
return [self::LAST_DELETED_KEY => $maxDeleted];
|
||||
}
|
||||
}
|
@@ -1,19 +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\Service\StoredObjectCleaner;
|
||||
|
||||
final readonly class RemoveOldVersionMessage
|
||||
{
|
||||
public function __construct(
|
||||
public int $storedObjectVersionId,
|
||||
) {}
|
||||
}
|
@@ -1,78 +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\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
|
||||
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
|
||||
|
||||
/**
|
||||
* Class RemoveOldVersionMessageHandler.
|
||||
*
|
||||
* This class is responsible for handling the RemoveOldVersionMessage. It implements the MessageHandlerInterface.
|
||||
* It removes old versions of stored objects based on certain conditions.
|
||||
*
|
||||
* If a StoredObject is a candidate for deletion (is expired and no more version stored), it is also removed from the
|
||||
* database.
|
||||
*/
|
||||
final readonly class RemoveOldVersionMessageHandler implements MessageHandlerInterface
|
||||
{
|
||||
private const LOG_PREFIX = '[RemoveOldVersionMessageHandler] ';
|
||||
|
||||
public function __construct(
|
||||
private StoredObjectVersionRepository $storedObjectVersionRepository,
|
||||
private LoggerInterface $logger,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private StoredObjectManagerInterface $storedObjectManager,
|
||||
private ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function __invoke(RemoveOldVersionMessage $message): void
|
||||
{
|
||||
$this->logger->info(self::LOG_PREFIX.'Received one message', ['storedObjectVersionId' => $message->storedObjectVersionId]);
|
||||
|
||||
$storedObjectVersion = $this->storedObjectVersionRepository->find($message->storedObjectVersionId);
|
||||
|
||||
if (null === $storedObjectVersion) {
|
||||
$this->logger->error(self::LOG_PREFIX.'StoredObjectVersion not found in database', ['storedObjectVersionId' => $message->storedObjectVersionId]);
|
||||
throw new \RuntimeException('StoredObjectVersion not found with id '.$message->storedObjectVersionId);
|
||||
}
|
||||
|
||||
if ($storedObjectVersion->hasPointInTimes()) {
|
||||
throw new UnrecoverableMessageHandlingException('the stored object version is now associated with a point in time');
|
||||
}
|
||||
|
||||
$storedObject = $storedObjectVersion->getStoredObject();
|
||||
|
||||
$this->storedObjectManager->delete($storedObjectVersion);
|
||||
// to ensure an immediate deletion
|
||||
$this->entityManager->remove($storedObjectVersion);
|
||||
|
||||
if (StoredObject::canBeDeleted($this->clock->now(), $storedObject)) {
|
||||
$this->entityManager->remove($storedObject);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// clear the entity manager for future usage
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
}
|
@@ -14,7 +14,6 @@ namespace Chill\DocStoreBundle\Service;
|
||||
use Base64Url\Base64Url;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -33,20 +32,10 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
) {}
|
||||
|
||||
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
|
||||
public function getLastModified(StoredObject $document): \DateTimeInterface
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if (null !== $createdAt = $version->getCreatedAt()) {
|
||||
// as a createdAt datetime is set, return the date and time from database
|
||||
return $createdAt;
|
||||
}
|
||||
|
||||
// if no createdAt version exists in the database, we fetch the date and time from the
|
||||
// file. This situation happens for files created before July 2024.
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
if ($this->hasCache($document)) {
|
||||
$response = $this->getResponseFromCache($document);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
@@ -57,7 +46,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
$document->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
@@ -69,13 +58,11 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $this->extractLastModifiedFromResponse($response);
|
||||
}
|
||||
|
||||
public function getContentLength(StoredObject|StoredObjectVersion $document): int
|
||||
public function getContentLength(StoredObject $document): int
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if (!$this->isVersionEncrypted($version)) {
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
if ([] === $document->getKeyInfos()) {
|
||||
if ($this->hasCache($document)) {
|
||||
$response = $this->getResponseFromCache($document);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
@@ -86,7 +73,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
$document->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
@@ -101,43 +88,10 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return strlen($this->read($document));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function exists(StoredObject|StoredObjectVersion $document): bool
|
||||
public function etag(StoredObject $document): string
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
$response = $this
|
||||
->client
|
||||
->request(
|
||||
Request::METHOD_HEAD,
|
||||
$this
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
|
||||
return 200 === $response->getStatusCode();
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function etag(StoredObject|StoredObjectVersion $document): string
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
if ($this->hasCache($document)) {
|
||||
$response = $this->getResponseFromCache($document);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
@@ -148,7 +102,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
$document->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
@@ -157,14 +111,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extractEtagFromResponse($response);
|
||||
return $this->extractEtagFromResponse($response, $document);
|
||||
}
|
||||
|
||||
public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string
|
||||
public function read(StoredObject $document): string
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
$response = $this->getResponseFromCache($version);
|
||||
$response = $this->getResponseFromCache($document);
|
||||
|
||||
try {
|
||||
$data = $response->getContent();
|
||||
@@ -172,7 +124,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
throw StoredObjectManagerException::unableToGetResponseContent($e);
|
||||
}
|
||||
|
||||
if (!$this->isVersionEncrypted($version)) {
|
||||
if (false === $this->hasKeysAndIv($document)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -180,9 +132,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
$data,
|
||||
self::ALGORITHM,
|
||||
// TODO: Why using this library and not use base64_decode() ?
|
||||
Base64Url::decode($version->getKeyInfos()['k']),
|
||||
Base64Url::decode($document->getKeyInfos()['k']),
|
||||
\OPENSSL_RAW_DATA,
|
||||
pack('C*', ...$version->getIv())
|
||||
pack('C*', ...$document->getIv())
|
||||
);
|
||||
|
||||
if (false === $clearData) {
|
||||
@@ -192,25 +144,20 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $clearData;
|
||||
}
|
||||
|
||||
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
|
||||
public function write(StoredObject $document, string $clearContent): void
|
||||
{
|
||||
$newIv = $document->getIv();
|
||||
$newKey = $document->getKeyInfos();
|
||||
$newType = $contentType ?? $document->getType();
|
||||
$version = $document->registerVersion(
|
||||
$newIv,
|
||||
$newKey,
|
||||
$newType
|
||||
);
|
||||
if ($this->hasCache($document)) {
|
||||
unset($this->inMemory[$document->getUuid()->toString()]);
|
||||
}
|
||||
|
||||
$encryptedContent = $this->isVersionEncrypted($version)
|
||||
$encryptedContent = $this->hasKeysAndIv($document)
|
||||
? openssl_encrypt(
|
||||
$clearContent,
|
||||
self::ALGORITHM,
|
||||
// TODO: Why using this library and not use base64_decode() ?
|
||||
Base64Url::decode($version->getKeyInfos()['k']),
|
||||
Base64Url::decode($document->getKeyInfos()['k']),
|
||||
\OPENSSL_RAW_DATA,
|
||||
pack('C*', ...$version->getIv())
|
||||
pack('C*', ...$document->getIv())
|
||||
)
|
||||
: $clearContent;
|
||||
|
||||
@@ -229,7 +176,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_PUT,
|
||||
$version->getFilename()
|
||||
$document->getFilename()
|
||||
)
|
||||
->url,
|
||||
[
|
||||
@@ -244,29 +191,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
if (Response::HTTP_CREATED !== $response->getStatusCode()) {
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$this->clearCache();
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function delete(StoredObjectVersion $storedObjectVersion): void
|
||||
{
|
||||
$signedUrl = $this->tempUrlGenerator->generate('DELETE', $storedObjectVersion->getFilename());
|
||||
|
||||
try {
|
||||
$response = $this->client->request('DELETE', $signedUrl->url);
|
||||
if (! (Response::HTTP_NO_CONTENT === $response->getStatusCode() || Response::HTTP_NOT_FOUND === $response->getStatusCode())) {
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$storedObjectVersion->getStoredObject()->removeVersion($storedObjectVersion);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
@@ -291,19 +215,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the content length from a ResponseInterface object.
|
||||
*
|
||||
* Does work only if the object is not encrypted.
|
||||
*
|
||||
* @return int the extracted content length as an integer
|
||||
*/
|
||||
private function extractContentLengthFromResponse(ResponseInterface $response): int
|
||||
{
|
||||
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
|
||||
}
|
||||
|
||||
private function extractEtagFromResponse(ResponseInterface $response): ?string
|
||||
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
|
||||
{
|
||||
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
|
||||
|
||||
@@ -314,7 +231,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $etag;
|
||||
}
|
||||
|
||||
private function fillCache(StoredObjectVersion $document): void
|
||||
private function fillCache(StoredObject $document): void
|
||||
{
|
||||
try {
|
||||
$response = $this
|
||||
@@ -337,30 +254,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$this->inMemory[$this->buildCacheKey($document)] = $response;
|
||||
$this->inMemory[$document->getUuid()->toString()] = $response;
|
||||
}
|
||||
|
||||
private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string
|
||||
{
|
||||
return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId();
|
||||
}
|
||||
|
||||
private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface
|
||||
private function getResponseFromCache(StoredObject $document): ResponseInterface
|
||||
{
|
||||
if (!$this->hasCache($document)) {
|
||||
$this->fillCache($document);
|
||||
}
|
||||
|
||||
return $this->inMemory[$this->buildCacheKey($document)];
|
||||
return $this->inMemory[$document->getUuid()->toString()];
|
||||
}
|
||||
|
||||
private function hasCache(StoredObjectVersion $document): bool
|
||||
private function hasCache(StoredObject $document): bool
|
||||
{
|
||||
return \array_key_exists($this->buildCacheKey($document), $this->inMemory);
|
||||
return \array_key_exists($document->getUuid()->toString(), $this->inMemory);
|
||||
}
|
||||
|
||||
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
|
||||
private function hasKeysAndIv(StoredObject $storedObject): bool
|
||||
{
|
||||
return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv());
|
||||
return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv());
|
||||
}
|
||||
}
|
||||
|
@@ -12,74 +12,36 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Service;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
|
||||
interface StoredObjectManagerInterface
|
||||
{
|
||||
/**
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
*/
|
||||
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface;
|
||||
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
||||
|
||||
/**
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
*/
|
||||
public function getContentLength(StoredObject|StoredObjectVersion $document): int;
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function exists(StoredObject|StoredObjectVersion $document): bool;
|
||||
public function getContentLength(StoredObject $document): int;
|
||||
|
||||
/**
|
||||
* Get the content of a StoredObject.
|
||||
*
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
* @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|StoredObjectVersion $document): string;
|
||||
public function read(StoredObject $document): string;
|
||||
|
||||
/**
|
||||
* Register the content of a new version for the StoredObject.
|
||||
*
|
||||
* The manager is also responsible for registering a version in the StoredObject, and return this version.
|
||||
* Set the content of a StoredObject.
|
||||
*
|
||||
* @param StoredObject $document the document
|
||||
* @param string $clearContent The content to store in clear
|
||||
* @param string|null $contentType The new content type. If set to null, the content-type is supposed not to change. If there is no content type, an empty string will be used.
|
||||
*
|
||||
* @return StoredObjectVersion the newly created @see{StoredObjectVersion} for the given @see{StoredObject}
|
||||
* @param $clearContent The content to store in clear
|
||||
*
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion;
|
||||
public function write(StoredObject $document, string $clearContent): void;
|
||||
|
||||
/**
|
||||
* Remove a version from the storage.
|
||||
*
|
||||
* This method is also responsible for removing the version from the StoredObject (using @see{StoredObject::removeVersion})
|
||||
* in case of success.
|
||||
*
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function delete(StoredObjectVersion $storedObjectVersion): void;
|
||||
public function etag(StoredObject $document): string;
|
||||
|
||||
/**
|
||||
* return or compute the etag for the document.
|
||||
*
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
*
|
||||
* @return string the etag of this document
|
||||
*/
|
||||
public function etag(StoredObject|StoredObjectVersion $document): string;
|
||||
|
||||
/**
|
||||
* Clears the cache for the stored object.
|
||||
*/
|
||||
public function clearCache(): void;
|
||||
}
|
||||
|
@@ -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\Service;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Chill\WopiBundle\Service\WopiConverter;
|
||||
use Symfony\Component\Mime\MimeTypesInterface;
|
||||
|
||||
/**
|
||||
* Class StoredObjectToPdfConverter.
|
||||
*
|
||||
* Converts stored objects to PDF or other specified formats using WopiConverter.
|
||||
*/
|
||||
class StoredObjectToPdfConverter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StoredObjectManagerInterface $storedObjectManager,
|
||||
private readonly WopiConverter $wopiConverter,
|
||||
private readonly MimeTypesInterface $mimeTypes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Converts the given stored object to a specified format and stores the new version.
|
||||
*
|
||||
* @param StoredObject $storedObject the stored object to be converted
|
||||
* @param string $lang the language for the conversion context
|
||||
* @param string $convertTo The target format for the conversion. Default is 'pdf'.
|
||||
*
|
||||
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion} contains the point in time before conversion and the new version of the stored object
|
||||
*
|
||||
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
|
||||
* @throws \RuntimeException if the conversion or storage of the new version fails
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf'): array
|
||||
{
|
||||
$newMimeType = $this->mimeTypes->getMimeTypes($convertTo)[0] ?? null;
|
||||
|
||||
if (null === $newMimeType) {
|
||||
throw new \UnexpectedValueException(sprintf('could not find a preferred mime type for conversion to %s', $convertTo));
|
||||
}
|
||||
|
||||
$currentVersion = $storedObject->getCurrentVersion();
|
||||
|
||||
if ($currentVersion->getType() === $newMimeType) {
|
||||
throw new \UnexpectedValueException('Already at the same mime type');
|
||||
}
|
||||
|
||||
$content = $this->storedObjectManager->read($currentVersion);
|
||||
|
||||
try {
|
||||
$converted = $this->wopiConverter->convert($lang, $content, $newMimeType, $convertTo);
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new \RuntimeException('could not store a new version for document', previous: $e);
|
||||
}
|
||||
|
||||
$pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
|
||||
$version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType);
|
||||
|
||||
return [$pointInTime, $version];
|
||||
}
|
||||
}
|
@@ -1,38 +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\Service;
|
||||
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
class WorkflowStoredObjectPermissionHelper
|
||||
{
|
||||
public function __construct(private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager) {}
|
||||
|
||||
public function notBlockedByWorkflow(object $entity): bool
|
||||
{
|
||||
$workflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
$currentUser = $this->security->getUser();
|
||||
|
||||
foreach ($workflows as $workflow) {
|
||||
if ($workflow->isFinal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -16,7 +16,6 @@ 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\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Twig\Environment;
|
||||
@@ -129,7 +128,6 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
||||
private NormalizerInterface $normalizer,
|
||||
private JWTDavTokenProviderInterface $davTokenProvider,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -150,10 +148,8 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
||||
* @throws \Twig\Error\RuntimeError
|
||||
* @throws \Twig\Error\SyntaxError
|
||||
*/
|
||||
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $showEditButtons = true, array $options = []): string
|
||||
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
|
||||
{
|
||||
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $document) && $showEditButtons;
|
||||
|
||||
$accessToken = $this->davTokenProvider->createToken(
|
||||
$document,
|
||||
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||
|
@@ -14,37 +14,19 @@ namespace AsyncUpload\Driver\OpenstackObjectStore;
|
||||
use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\TempUrlOpenstackGenerator;
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
|
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class TempUrlOpenstackGeneratorTest extends KernelTestCase
|
||||
class TempUrlOpenstackGeneratorTest extends TestCase
|
||||
{
|
||||
private ParameterBagInterface $parameterBag;
|
||||
private HttpClientInterface $client;
|
||||
|
||||
private const TESTING_OBJECT_NAME_PREFIX = 'test-prefix-o0o008wk404gcos40k8s4s4c44cgwwos4k4o8k/';
|
||||
private const TESTING_OBJECT_NAME = 'object-name-4fI0iAtq';
|
||||
|
||||
private function setUpIntegration(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->parameterBag = self::getContainer()->get(ParameterBagInterface::class);
|
||||
$this->client = self::getContainer()->get(HttpClientInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderGenerate
|
||||
*/
|
||||
@@ -140,8 +122,7 @@ class TempUrlOpenstackGeneratorTest extends KernelTestCase
|
||||
$signedUrl = new SignedUrl(
|
||||
'GET',
|
||||
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
|
||||
\DateTimeImmutable::createFromFormat('U', '1702043543'),
|
||||
$objectName
|
||||
\DateTimeImmutable::createFromFormat('U', '1702043543')
|
||||
);
|
||||
|
||||
foreach ($baseUrls as $baseUrl) {
|
||||
@@ -172,7 +153,6 @@ class TempUrlOpenstackGeneratorTest extends KernelTestCase
|
||||
$signedUrl = new SignedUrlPost(
|
||||
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
|
||||
\DateTimeImmutable::createFromFormat('U', '1702043543'),
|
||||
$objectName,
|
||||
150,
|
||||
1,
|
||||
1800,
|
||||
@@ -193,62 +173,4 @@ class TempUrlOpenstackGeneratorTest extends KernelTestCase
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @group openstack-integration
|
||||
*/
|
||||
public function testGeneratePostIntegration(): void
|
||||
{
|
||||
$this->setUpIntegration();
|
||||
$generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag);
|
||||
|
||||
$signedUrl = $generator->generatePost(object_name: self::TESTING_OBJECT_NAME_PREFIX);
|
||||
$formData = new FormDataPart([
|
||||
'redirect', $signedUrl->redirect,
|
||||
'max_file_size' => (string) $signedUrl->max_file_size,
|
||||
'max_file_count' => (string) $signedUrl->max_file_count,
|
||||
'expires' => (string) $signedUrl->expires->getTimestamp(),
|
||||
'signature' => $signedUrl->signature,
|
||||
self::TESTING_OBJECT_NAME => DataPart::fromPath(
|
||||
__DIR__.'/file-to-upload.txt',
|
||||
self::TESTING_OBJECT_NAME
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->client
|
||||
->request(
|
||||
'POST',
|
||||
$signedUrl->url,
|
||||
[
|
||||
'body' => $formData->bodyToString(),
|
||||
'headers' => $formData->getPreparedHeaders()->toArray(),
|
||||
]
|
||||
);
|
||||
|
||||
self::assertEquals(201, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @group openstack-integration
|
||||
*
|
||||
* @depends testGeneratePostIntegration
|
||||
*/
|
||||
public function testGenerateGetIntegration(): void
|
||||
{
|
||||
$this->setUpIntegration();
|
||||
$generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag);
|
||||
|
||||
$signedUrl = $generator->generate('GET', self::TESTING_OBJECT_NAME_PREFIX.self::TESTING_OBJECT_NAME);
|
||||
|
||||
$response = $this->client->request('GET', $signedUrl->url);
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
|
||||
try {
|
||||
$content = $response->getContent();
|
||||
self::assertEquals(file_get_contents(__DIR__.'/file-to-upload.txt'), $content);
|
||||
} catch (HttpExceptionInterface $exception) {
|
||||
$this->fail('could not retrieve file content: '.$exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1 +0,0 @@
|
||||
test file
|
@@ -35,7 +35,7 @@ class AsyncUploadExtensionTest extends KernelTestCase
|
||||
{
|
||||
$generator = $this->prophesize(TempUrlGeneratorInterface::class);
|
||||
$generator->generate(Argument::in(['GET', 'POST']), Argument::type('string'), Argument::any())
|
||||
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'), $args[1]));
|
||||
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours')));
|
||||
|
||||
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||
$urlGenerator->generate('async_upload.generate_url', Argument::type('array'))
|
||||
@@ -70,7 +70,7 @@ class AsyncUploadExtensionTest extends KernelTestCase
|
||||
|
||||
public static function dataProviderStoredObject(): iterable
|
||||
{
|
||||
yield [(new StoredObject())->registerVersion(filename: 'blabla')->getStoredObject()];
|
||||
yield [(new StoredObject())->setFilename('blabla')];
|
||||
|
||||
yield ['blabla'];
|
||||
}
|
||||
|
@@ -15,8 +15,7 @@ use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Controller\AsyncUploadController;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
@@ -35,168 +34,45 @@ class AsyncUploadControllerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testGetSignedUrlPost(): void
|
||||
public function testGenerateWhenUserIsNotGranted(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE_AND_EDIT', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$controller = $this->buildAsyncUploadController(false);
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
$controller->getSignedUrl('POST', new Request());
|
||||
}
|
||||
|
||||
$actual = $controller->getSignedUrlPost(new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800]), $storedObject);
|
||||
public function testGeneratePost(): void
|
||||
{
|
||||
$controller = $this->buildAsyncUploadController(true);
|
||||
|
||||
$actual = $controller->getSignedUrl('POST', new Request());
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('POST', $decodedActual['method']);
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetSimpleScenarioHappy(): void
|
||||
public function testGenerateGet(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
$security->getUser()->willReturn(new User());
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$actual = $controller->getSignedUrlGet(new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800]), $storedObject, 'get');
|
||||
$controller = $this->buildAsyncUploadController(true);
|
||||
|
||||
$actual = $controller->getSignedUrl('GET', new Request(['object_name' => 'abc']));
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('GET', $decodedActual['method']);
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetSimpleScenarioNotAuthorized(): void
|
||||
{
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(false)->shouldBeCalled();
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$controller->getSignedUrlGet(new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800]), $storedObject, 'get');
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetForSpecificVersionOfTheStoredObjectStoredInDatabase(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$version = $storedObject->registerVersion();
|
||||
// we add a version to be sure that the we do not get the last one
|
||||
$storedObject->registerVersion();
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
$security->getUser()->willReturn(new User());
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$actual = $controller->getSignedUrlGet(
|
||||
new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800, 'version' => $version->getFilename()]),
|
||||
$storedObject,
|
||||
'get'
|
||||
);
|
||||
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('GET', $decodedActual['method']);
|
||||
self::assertArrayHasKey('object_name', $decodedActual);
|
||||
self::assertEquals($version->getFilename(), $decodedActual['object_name']);
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetForSpecificVersionOfTheStoredObjectNotYetStoredInDatabase(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
// we generate a valid name
|
||||
$version = $storedObject->getPrefix().'/some-version';
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
$security->getUser()->willReturn(new User());
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$actual = $controller->getSignedUrlGet(
|
||||
new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800, 'version' => $version]),
|
||||
$storedObject,
|
||||
'get'
|
||||
);
|
||||
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('GET', $decodedActual['method']);
|
||||
self::assertArrayHasKey('object_name', $decodedActual);
|
||||
self::assertEquals($version, $decodedActual['object_name']);
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetForSpecificVersionNotBelongingToTheStoreObject(): void
|
||||
{
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
// we generate a random version
|
||||
$version = 'something/else';
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$controller->getSignedUrlGet(
|
||||
new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800, 'version' => $version]),
|
||||
$storedObject,
|
||||
'get'
|
||||
);
|
||||
}
|
||||
|
||||
public function buildTempUrlGenerator(): TempUrlGeneratorInterface
|
||||
{
|
||||
return new class () implements TempUrlGeneratorInterface {
|
||||
public function generatePost(?int $expire_delay = null, ?int $submit_delay = null, int $max_file_count = 1, ?string $object_name = null): SignedUrlPost
|
||||
private function buildAsyncUploadController(
|
||||
bool $isGranted,
|
||||
): AsyncUploadController {
|
||||
$tempUrlGenerator = new class () implements TempUrlGeneratorInterface {
|
||||
public function generatePost(?int $expire_delay = null, ?int $submit_delay = null, int $max_file_count = 1): SignedUrlPost
|
||||
{
|
||||
return new SignedUrlPost(
|
||||
'https://object.store.example',
|
||||
new \DateTimeImmutable('1 hour'),
|
||||
$object_name ?? 'abc',
|
||||
150,
|
||||
1,
|
||||
1800,
|
||||
@@ -209,25 +85,26 @@ class AsyncUploadControllerTest extends TestCase
|
||||
public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl
|
||||
{
|
||||
return new SignedUrl(
|
||||
strtoupper($method),
|
||||
$method,
|
||||
'https://object.store.example',
|
||||
new \DateTimeImmutable('1 hour'),
|
||||
$object_name
|
||||
new \DateTimeImmutable('1 hour')
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function buildSerializer(): SerializerInterface
|
||||
{
|
||||
$serializer = $this->prophesize(SerializerInterface::class);
|
||||
$serializer->serialize(Argument::type(SignedUrl::class), 'json', Argument::type('array'))
|
||||
->will(fn (array $args): string => json_encode(
|
||||
['method' => $args[0]->method, 'object_name' => $args[0]->object_name],
|
||||
JSON_THROW_ON_ERROR,
|
||||
3
|
||||
));
|
||||
->will(fn (array $args): string => json_encode(['method' => $args[0]->method], JSON_THROW_ON_ERROR, 3));
|
||||
|
||||
return $serializer->reveal();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, Argument::type(SignedUrl::class))
|
||||
->willReturn($isGranted);
|
||||
|
||||
return new AsyncUploadController(
|
||||
$tempUrlGenerator,
|
||||
$serializer->reveal(),
|
||||
$security->reveal(),
|
||||
new NullLogger()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user