Compare commits

..

138 Commits

Author SHA1 Message Date
e00ece4200 Update form builder parameter in SearchController
Changed the first argument in the `createNamedBuilder` method from `null` to an empty string. This adjustment ensures the form factory correctly creates the builder in the SearchController.
2024-05-28 15:58:17 +02:00
640fd71402 merge ticket-app-master and fix rector / cs 2024-05-28 15:54:52 +02:00
aae50ca290 Merge branch 'ticket-app-master' into chill-bundles-ticket-app-adaptations 2024-05-28 15:08:59 +02:00
1fa483598b Merge branch 'upgrade-sf5' into ticket-app-master 2024-05-28 14:59:25 +02:00
785c01d42d enable parallelization in php-cs-fixer config 2024-05-28 14:58:31 +02:00
06dd7dd4f5 apply rector rules 2024-05-28 14:58:09 +02:00
84f515d451 Merge remote-tracking branch 'origin/master' into upgrade-sf5 2024-05-28 14:16:01 +02:00
e4b6a468f8 adding fixtures for ticket in every environment 2024-05-28 13:47:58 +02:00
27b0ec0ae7 Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into 'master'
Webdav access point to edit documents using LibreOffice

Closes #145

See merge request Chill-Projet/chill-bundles!592
2024-05-28 11:36:57 +00:00
9f141468c7 fix phpstan, cs, and rector rules 2024-05-28 13:23:54 +02:00
56d173046d fix phpstan, cs, and rector rules 2024-05-28 12:54:56 +02:00
059e4a0acd fixes for feature "refactor store object form widget" 2024-05-28 12:31:46 +02:00
111a21fcec Add new file for StoredObjectType tests and update class definitions
This commit adds a new file, StoredObjectTypeTest.php, to ChillDocStoreBundle Tests. It contains unit tests for the StoredObjectType class. Changes are also made in StoredObjectNormalizer and StoredObjectDataMapper classes, making JWTDavTokenProviderInterface and UrlGeneratorInterface as readonly in StoredObjectNormalizer and removing unnecessary EntityManagerInterface and debug commands on StoredObjectDataMapper. These changes improve test coverage and optimize the code for better performance.
2024-05-28 12:08:02 +02:00
775535e683 refactor file drop widget 2024-05-28 11:25:59 +02:00
47a928a6cd Add DAV edit link to StoredObject serialization
Enabled the adding of access link, specifically DAV edit link to the JSON serialization of the StoredObject entity. The patch also adjusted the serializer groups of various attributes of StoredObject from "read, write" to "write". Lastly, these changes were reflected in the accompanying CourseWork Controller and the FormEvaluation Vue component.
2024-05-23 18:25:20 +02:00
0dd58cebec optional parameter after the required one 2024-05-23 17:00:46 +02:00
4cff706306 Apply new CS rules on the webdav feature 2024-05-23 17:00:46 +02:00
fca929f56f Dav: add UI to edit document 2024-05-23 17:00:46 +02:00
8d44bb2c32 Dav: add some documentation on classes 2024-05-23 17:00:46 +02:00
a57e6c0cc9 Dav: Introduce access control inside de dav controller 2024-05-23 17:00:45 +02:00
3fe870ba71 Dav: refactor WebdavController 2024-05-23 17:00:45 +02:00
6f6683f549 Dav: implements JWT extraction from the URL, and add the access_token in dav urls 2024-05-23 17:00:45 +02:00
146e0090fb Webdav: fully implements the controller and response
The controller is tested from real request scraped from apache mod_dav implementation. The requests were scraped using a wireshark-like tool. Those requests have been adapted to suit to our xml.
2024-05-23 17:00:42 +02:00
Boris Waaub
66c7758023 Adapt module name 2024-05-22 11:17:07 +02:00
Boris Waaub
4750d2c24e Adapt module name 2024-05-22 11:16:18 +02:00
Boris Waaub
ca05e3d979 Layout adaptation 2024-05-22 11:12:22 +02:00
Boris Waaub
a20f9b4f86 Generalize ticket actions 2024-05-22 00:38:47 +02:00
Boris Waaub
c73c1eb8d5 Rename "appelant" by "patient" 2024-05-21 22:24:30 +02:00
Boris Waaub
8778bb0731 Use colors and badges for history and banner 2024-05-21 22:22:33 +02:00
Boris Waaub
c7d20eebc5 chore: Remove unused code in AddresseeSelectorComponent.vue 2024-05-21 20:53:15 +02:00
Boris Waaub
b9e130c159 Use suggestion for user asignee 2024-05-21 20:44:23 +02:00
Boris Waaub
3e8bc94af3 Remove user object display 2024-05-21 18:14:11 +02:00
Boris Waaub
0c914c9f9f Remove "remove_addressee" history line 2024-05-21 17:32:40 +02:00
Boris Waaub
580a60c939 Add user_group for returning type 2024-05-21 17:32:05 +02:00
Boris Waaub
4996ac3b7c Adapt layout action toolbar 2024-05-21 15:22:13 +02:00
Boris Waaub
2a23bf19cb use record_actions sticky-form-buttons 2024-05-21 10:53:25 +02:00
Boris Waaub
650d2596d9 Update ticket display to use ticket ID instead of external reference 2024-05-21 09:54:06 +02:00
Boris Waaub
2bdd5a329e Merge branch 'ticket-app-master' of gitlab.com:boriswa/chill-bundles into ticket-app-master 2024-05-21 09:53:32 +02:00
78d1776733 Add functionality to find a caller by phone number
Added a new method in PersonRepository to allow querying people by phone number. Also, a new REST API endpoint "/public/api/1.0/ticket/find-caller" was introduced and it can find a caller by their phone number. Accompanied this feature addition with corresponding test cases.
2024-05-17 13:14:26 +02:00
66dc603c85 fix cs with new version of php-cs-fixer 2024-05-17 12:20:33 +02:00
3a8154ecce Replace PhoneNumberUtil with PhonenumberHelper
The PhoneNumberUtil has been replaced with PhonenumberHelper in AssociateByPhonenumberCommandHandler and its test class. The purpose of this change is to improve phone number parsing which is now delegated to the PhonenumberHelper class in the Chill\MainBundle\Phonenumber namespace. As a consequence, the related dependencies in both the service and the test class have been updated accordingly.
2024-05-17 12:17:00 +02:00
c81828e04f Add phone number parsing functionality
Added a new method 'parse' in the PhonenumberHelper class in ChillMainBundle to sanitize and parse phone numbers. This method specifically handles phone numbers that start with '00', '+' or '0'. Associated unit tests for this new method were also added in PhonenumberHelperTest.php.
2024-05-17 12:16:28 +02:00
20291026fb release 2.19.0 2024-05-14 16:05:51 +02:00
Boris Waaub
ec17dd7de2 Merge branch 'master' of https://gitlab.com/Chill-Projet/chill-bundles into ticket-app-master 2024-05-13 16:08:19 +02:00
76c076a5f3 Merge branch 'ticket-app-create-template' into 'ticket-app-master'
Mise à jour des messages de l'interface utilisateur pour inclure les...

See merge request Chill-Projet/chill-bundles!689
2024-05-13 13:34:43 +00:00
d0e27d51fe fix path to doctrine-fixtures-bundles and prefix composer command by "symfony" 2024-05-13 13:20:08 +00:00
Boris Waaub
f0045edd6c FIX: Ouvert depuis 2024-05-13 12:33:11 +02:00
Boris Waaub
d00b76ffcd $tc n'est plus supporté pour i18n composition api, il faut utiliser $t.
FIX: Person PersonRenderBox
2024-05-13 12:16:07 +02:00
Boris Waaub
8991f0ef3f Modification i18n 2024-05-13 12:00:11 +02:00
Boris Waaub
d6f5eae0c9 Rendre les commentaire markdown 2024-05-13 11:59:50 +02:00
Boris Waaub
821fce3dd8 $tc n'est plus supporté pour i18n composiontion api, il faut utiliser $t.
Source : https://github.com/intlify/vue-cli-plugin-i18n/issues/214
i18n composion api : https://vue-i18n.intlify.dev/api/composition
2024-05-13 11:38:28 +02:00
Boris Waaub
1d33ae1e39 use ckeditor 2024-05-08 18:03:50 +02:00
Boris Waaub
19af0feb57 Use PersonRenderBox 2024-05-08 17:54:03 +02:00
Boris Waaub
1c09e9a692 Merge branch 'ticket-app-master' into ticket-app-create-template 2024-05-08 16:05:35 +02:00
Boris Waaub
d72e748388 Merge branch 'ticket-app-master' of https://gitlab.com/boriswa/chill-bundles into ticket-app-master 2024-05-08 16:02:09 +02:00
85d6765178 Merge branch '266-event-bundle-graphic' into 'master'
Resolve "Module évenements: finaliser les bugs graphiques"

Closes #266

See merge request Chill-Projet/chill-bundles!692
2024-05-08 08:57:42 +00:00
30955752c3 fix pipeline and add changie 2024-05-08 10:28:14 +02:00
f7f7e1cb1d updated translation by changing translation in twig 2024-05-08 10:05:58 +02:00
651c455bdf added translation for No item 2024-05-08 10:05:49 +02:00
d50d067bf7 added button for moderators and fixed participant styling 2024-05-08 10:05:37 +02:00
46c647cbb7 fixed events width 2024-05-08 10:05:27 +02:00
d63e1a15bd Language corrections 2024-05-07 14:51:31 +00:00
da83b1e98c Merge branch '239-create-documents' into 'master'
239 - generated doc block moved to top page

See merge request Chill-Projet/chill-bundles!682
2024-05-07 14:30:16 +00:00
536c2622c7 239 - doc generation form added to top of doc list page when more than 5 documents 2024-05-07 14:30:16 +00:00
Boris Waaub
ab850b7b70 Fusionner les utilisateurs/goupes en une "Card" 2024-05-06 20:07:15 +02:00
Boris Waaub
3f9745d8cf Use teleport for banner 2024-05-06 18:03:04 +02:00
Boris Waaub
473765366a Add tranfert with AddPerson 2024-05-06 16:38:56 +02:00
5334779b55 fix issue in installation instructions 2024-05-03 13:20:02 +02:00
1230b7f4c0 documentation for installation process 2024-05-03 13:17:00 +02:00
Boris Waaub
6500c24a7f Déplacer le répertoire translation dans source 2024-05-02 14:10:22 +02:00
Boris Waaub
1d00457141 Ajouter les propriétés createdAt et updatedBy à l'interface Ticket 2024-05-02 14:09:52 +02:00
Boris Waaub
eb0bf56cff Add user group addressee 2024-05-02 13:18:45 +02:00
Boris Waaub
7b8cd90cf1 Add user store 2024-05-02 12:03:10 +02:00
Boris Waaub
a27d92aba0 Add comment and motive 2024-05-02 00:50:33 +02:00
Boris Waaub
85bdfb9e21 Remove banner component 2024-05-01 22:04:07 +02:00
Boris Waaub
4cffcf4de1 Use translate in setup 2024-05-01 22:03:36 +02:00
Boris Waaub
b2587a688f Déplacer le composant banner dans twig 2024-05-01 15:51:12 +02:00
Boris Waaub
c9f0e9843b Déplacer le composant banner dans twig 2024-05-01 15:49:32 +02:00
394c752fc3 undo wrong fix of workflow configuration 2024-04-30 16:29:33 +02:00
43c846d02e Merge branch 'upgrade-sf5' of gitlab.com:Chill-Projet/chill-bundles into upgrade-sf5 2024-04-30 16:21:06 +02:00
e24c4dc2e8 Fix workflow hierarchy 2024-04-30 09:25:09 +02:00
cb70c322a4 Merge branch '276-take-closing-date-on-period-when-export' into 'master'
Update geographical unit computation for closed periods in exports

Closes #276

See merge request Chill-Projet/chill-bundles!687
2024-04-29 13:44:42 +00:00
89c231de41 Update geographical unit computation for closed periods in exports
The geographical unit computation in the ChillPersonBundle now considers the closing date of an accompanying period when a person changes location. This provides more accurate statistics, especially in situations where the individual moved after the period closed. The changes also include refinements for the validFrom and validTo data within the AccompanyingCourseFilters and AccompanyingCourseAggregators.
2024-04-29 13:03:22 +02:00
c773f9c6db Update geographical unit filter for period's location
The geographical unit filter in the accompanying course filters now takes the period's location on the address into account. This enhancement was achieved by modifying the GeographicalUnitStatFilter class. As a result, the "filter accompanying period by geographical unit" option provides more accurate data.
2024-04-29 11:39:46 +02:00
4c05d1e026 Merge branch '197-fix-calendar-synchronisation-script' into 'master'
Make the script which subscribe to user calendars on ms-graph more tolerant to errors

Closes #197

See merge request Chill-Projet/chill-bundles!685
2024-04-26 12:04:44 +00:00
3e2ff463bc make the script which subscribe to user calendars on ms-graph more tolerant to errors
The script does not fails and exit when some calendar settings are not reachable
2024-04-26 13:56:24 +02:00
Boris Waaub
b40ad9e445 Mise à jour des messages de l'interface utilisateur pour inclure les fonctionnalités de commentaire, de motif et de transfert 2024-04-25 11:16:08 +02:00
Boris Waaub
3e10e47e29 Merge branch 'ticket-app-master' into ticket-app-create-template 2024-04-25 10:37:42 +02:00
Boris Waaub
2a1963e993 Mise à jour de l'interface utilisateur pour le composant ActionToolbarComponent 2024-04-25 10:36:45 +02:00
34c171659b Merge branch 'ticket-app/backend-3' into 'ticket-app-master'
Add functionality to set addressees for a ticket

See merge request Chill-Projet/chill-bundles!683
2024-04-24 16:50:29 +00:00
2d8b960d9e Re-open the same ticket if a ticket already exists with the same externalRef, instead of creating a new one 2024-04-24 18:48:00 +02:00
831ae03431 Merge branch 'ticket-app/backend-2' into 'ticket-app-master'
Add functionality to add comments to tickets

See merge request Chill-Projet/chill-bundles!681
2024-04-23 21:42:07 +00:00
45828174d1 Add addressee history to ticket serialization
This update extends the tickets serialization and normalisation process to include addressee history. With the changes, AddresseeHistory class now also keeps track of who removed an addressee. Additional types, tests and interfaces have been introduced to support this change.
2024-04-23 23:39:01 +02:00
ed45f14a45 Add tracking of addressee history in ticket system
The updates introduce tracking for the history of addressees in the ticket system, both when added and when removed. The user who removed an addressee is now recorded. The changes also ensure these updated aspects are correctly normalized and users can see them in the ticket history. A new database migration file was created for the changes.
2024-04-23 23:38:34 +02:00
fa67835690 Add functionality to add single addressee to tickets
This update introduces a new feature allowing end-users to add a single addressee to a ticket without removing the existing ones. This was achieved by adding a new API endpoint and updating the SetAddresseesController to handle the addition of a single addressee. Accompanying tests have also been provided to ensure the new feature works as expected.
2024-04-23 23:00:12 +02:00
b434d38091 Add functionality to set addressees for a ticket
This update includes the implementation of methods to add and retrieve addressee history in the Ticket entity, a handler for addressee setting command, denormalizer for transforming request data to SetAddresseesCommand, and corresponding tests. Additionally, it adds a SetAddresseesController for handling addressee related requests and updates the API specifications.
2024-04-23 22:50:51 +02:00
Boris Waaub
800a952532 Add base template 2024-04-23 20:41:32 +02:00
9f355032a8 Create a "do not exclude" validation constraint for user groups 2024-04-22 12:41:43 +02:00
0bc6e62d4d Add fixtures for UserGroup 2024-04-22 12:01:49 +02:00
46fb1c04b5 Add color and exclusion fields to UserGroup
This commit introduces new fields to the UserGroup entity, specifically background color, foreground color, and an exclusion key. These have been implemented both in the PHP entity and TypeScript interface definitions. Additionally, a Doctrine migration has been created to reflect these changes on the database side.
2024-04-22 12:01:28 +02:00
3b2c3d1464 Merge branch 'ticket-app-create-store' into 'ticket-app-master'
Create vuex store

See merge request Chill-Projet/chill-bundles!678
2024-04-22 08:29:56 +00:00
Boris Waaub
0bd6038160 Merge branch chill-bundles:master into ticket-app-master 2024-04-19 15:54:24 +00:00
Boris Waaub
baab8e94ce Add ticket to storeand catch error with toast in component 2024-04-19 17:46:12 +02:00
e2deb55fdb Create api endpoint for listing user-group 2024-04-19 15:34:43 +02:00
Boris Waaub
2cdfb50058 Mise en œuvre de la fonctionnalité de remplacement du motif du ticket
La validation introduit plusieurs fonctionnalités liées à la gestion du motif du ticket dans le bundle Chill-TicketBundle :
- Ajoute la possibilité de remplacer le motif d'un ticket par un nouveau.
- Fournit des fonctionnalités de gestion de l'historique des motifs du ticket.
- Implémente les modifications pertinentes au niveau du contrôleur, du gestionnaire d'actions et de l'entité.
- Intègre de nouvelles points d'API et met à jour le fichier de spécification de l'API pour la nouvelle fonctionnalité.
- Inclut des tests pour garantir le bon fonctionnement de la nouvelle fonctionnalité.
2024-04-19 14:12:09 +02:00
39d701feb2 Serialize ticket's Comment 2024-04-18 22:10:56 +02:00
613ee8b186 Add functionality to add comments to tickets
A new controller, 'AddCommentController', has been added. This controller implements the 'AddCommentCommandHandler', allowing users to add comments to tickets. Additionally, corresponding test cases were implemented. The Ticket entity was also updated to accept and manage comments. API endpoint specs were updated to reflect these changes.
2024-04-18 21:57:55 +02:00
56a1a488de Return the content of the ticket on replace motive POST request 2024-04-18 15:44:05 +02:00
3f789ad0f4 Merge branch 'ticket-app/create-entities' into 'ticket-app-master'
Add phone number search function to PersonACLAwareRepository

See merge request Chill-Projet/chill-bundles!677
2024-04-18 11:21:46 +00:00
467bea7cde Serialization of tickets with history 2024-04-18 13:13:09 +02:00
670b8eb82b Implement functionality to replace ticket's motive
The commit introduces several features related to ticket motive management in the Chill-TicketBundle:
- Adds capability to replace a ticket's motive with a new one.
- Provides ticket motive history management features.
- Implements relevant changes in Controller, Action Handler, and Entity levels.
- Incorporates new API endpoints and updates the API specification file for the new feature.
- Includes tests to ensure the new functionality works as expected.
2024-04-18 13:13:08 +02:00
a9760b323f Add ChillTicketBundle to configuration and autoload-dev
The commit includes the ChillTicketBundle in the bundles configuration file for testing. Additionally, the autoload-dev directive in the composer.json file was updated to include the "App" namespace for testing purposes. This ensures that the tests related to the "App" namespace are correctly autoloaded.
2024-04-18 13:13:08 +02:00
71a3a1924a Add Motive API and related fixtures to ChillTicketBundle
This update introduces the Motive API Controller to the ChillTicket bundle with its corresponding service configuration. Also included are related data fixtures for loading motive information. The motive entity has been updated to improve its serialization properties and new types were added to the TypeScript definitions of the bundle.
2024-04-18 13:13:07 +02:00
ecdc1e25bf Layout of banner for ticket 2024-04-18 13:13:07 +02:00
dd37427be1 Bootstrap ticket layout and vue app to edit ticket 2024-04-18 13:13:07 +02:00
c8467df1b1 fixup! Rename Command directory to Action to avoid confusion with symfony commands 2024-04-18 13:13:06 +02:00
4c89a954fa Refactor test, fixing the constructor 2024-04-18 13:13:05 +02:00
7c1f3b114d Rename Command directory to Action to avoid confusion with symfony commands 2024-04-18 13:13:05 +02:00
36bc4dab24 Configure a testsuite for TicketBundle 2024-04-18 13:13:04 +02:00
4b30d92282 Add ticket creation and associating by phone number functionality
This update introduces new features allowing the creation of tickets and associating them with a phone number. Specifically, relevant commands and their handlers have been created along with corresponding tests. An endpoint for ticket creation has also been set up, and the ViewTicketController has been renamed and refactored to EditTicketController to better reflect its function.
2024-04-18 13:13:04 +02:00
75fbec5489 Create entities and doctrine mapping for ticket 2024-04-18 13:13:03 +02:00
59005e83c4 Merge branch '270_do_not_display_eval_from_closed_period_in_homepage' into 'master'
Do not display evaluation from closed accompanying period on homepage

Closes #270

See merge request Chill-Projet/chill-bundles!676
2024-04-18 07:37:06 +00:00
juminet
ab6feef371 Do not display evaluation from closed accompanying period on homepage 2024-04-18 07:37:05 +00:00
8516e89c9c update docs to include import of countries which is necessary to be able to import addresses 2024-04-17 16:16:56 +02:00
912fdd6349 Add phone number search function to PersonACLAwareRepository
A new function, findByPhone, has been added to the PersonACLAwareRepository. This function allows searching for people based on their phone numbers. Changes also reflect in the PersonACLAwareRepositoryInterface, and new test cases have been added to the PersonACLAwareRepositoryTest.
2024-04-16 14:41:55 +02:00
5832542978 load also tests for ticket bundle 2024-04-16 14:41:39 +02:00
5c3585a1ed Fix loading of environment variable in bootstrap process 2024-04-16 14:41:29 +02:00
a2f1e20ddf Fix cs 2024-04-15 15:49:47 +02:00
4d67702a76 Bootstrap loading of controllers and routes for ticket bundle 2024-04-15 15:48:25 +02:00
18e442db29 Merge branch 'ticket-app-init' into 'ticket-app-master'
Add ChillTicketBundle webpack configuration

See merge request Chill-Projet/chill-bundles!673
2024-04-15 12:44:21 +00:00
Boris Waaub
deb3d92189 Add ChillTicketBundle webpack configuration 2024-04-15 14:34:09 +02:00
a59ea7db31 Compiles with ticket bundle 2024-04-15 13:48:49 +02:00
a738b0cac9 Initialize ChillTicketBundle 2024-04-15 13:22:36 +02:00
4091efc72e Update bundles version to 2.18.2 2024-04-12 12:58:23 +02:00
b577bd7123 Merge branch '250-fix-import-postal-codes' into 'master'
Resolve "Import postal codes"

Closes #250

See merge request Chill-Projet/chill-bundles!672
2024-04-12 10:54:15 +00:00
dbb9feb129 rector correction: cast value to string 2024-04-12 12:35:52 +02:00
e8b95f8491 Add changie for fix import postal codes 2024-04-11 16:45:43 +02:00
8de37a9ef3 Fix import of postal codes. URL changed + names of keys 2024-04-11 16:45:22 +02:00
205 changed files with 9810 additions and 1082 deletions

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

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

20
.changes/v2.19.0.md Normal file
View File

@@ -0,0 +1,20 @@
## v2.19.0 - 2024-05-14
### Feature
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
### Fixed
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
### UX
* Form for document generation moved to the top of document list page
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
### Traduction francophone des principaux changements
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
- correction du filtre "filtrer par zone géographique"
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
- module événement: améliorerations graphiques

View File

@@ -27,6 +27,7 @@ $config
->setRiskyAllowed(true)
->setCacheFile('.cache/php-cs-fixer.cache')
->setUsingCache(true)
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
;
$rules = $config->getRules();

View File

@@ -6,6 +6,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.19.0 - 2024-05-14
### Feature
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
### Fixed
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
### UX
* Form for document generation moved to the top of document list page
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
### Traduction francophone des principaux changements
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
- correction du filtre "filtrer par zone géographique"
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
- module événement: améliorerations graphiques
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
## v2.18.1 - 2024-03-26
### Fixed
* Fix layout issue in document generation for admin (minor)

View File

@@ -55,7 +55,7 @@ Arborescence:
- person
- personvendee
- household_edit_metadata
- index.js
- index.ts
```
## Organisation des feuilles de styles

View File

@@ -9,6 +9,7 @@
],
"require": {
"php": "^8.2",
"ext-dom": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",
@@ -92,7 +93,7 @@
"phpstan/phpstan-deprecation-rules": "^1.1",
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": ">= 7.5",
"rector/rector": "^1.0.0",
"rector/rector": "^1.1.0",
"symfony/debug-bundle": "^5.4",
"symfony/dotenv": "^5.4",
"symfony/maker-bundle": "^1.20",
@@ -118,6 +119,7 @@
"Chill\\PersonBundle\\": "src/Bundle/ChillPersonBundle",
"Chill\\ReportBundle\\": "src/Bundle/ChillReportBundle",
"Chill\\TaskBundle\\": "src/Bundle/ChillTaskBundle",
"Chill\\TicketBundle\\": "src/Bundle/ChillTicketBundle/src",
"Chill\\ThirdPartyBundle\\": "src/Bundle/ChillThirdPartyBundle",
"Chill\\WopiBundle\\": "src/Bundle/ChillWopiBundle/src",
"Chill\\Utils\\Rector\\": "utils/rector/src"
@@ -125,8 +127,9 @@
},
"autoload-dev": {
"psr-4": {
"App\\": "tests/",
"App\\": "tests",
"Chill\\DocGeneratorBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests",
"Chill\\TicketBundle\\Tests\\": "src/Bundle/ChillTicketBundle/tests",
"Chill\\WopiBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests",
"Chill\\Utils\\Rector\\Tests\\": "utils/rector/tests"
}

View File

@@ -14,6 +14,352 @@
Installation & Usage
####################
You will learn here how to install a new symfony project with chill, and configure it.
Requirements
============
The installation is tested on a Debian-like linux distribution. The installation on other operating systems is not documented.
You have to install the following tools on your computer:
- `PHP <https://www.php.net/>`_, version 8.3+, with the following extensions: pdo_pgsql, intl, mbstring, zip, bcmath, exif, sockets, redis, ast, gd;
- `composer <https://getcomposer.org/>`_;
- `symfony cli <https://symfony.com/download>`_;
- `node, we encourage you to use nvm to configure the correct version <https://github.com/nvm-sh/nvm>`_. The project contains an
:code:`.nvmrc` file which selects automatically the required version of node (if present).
- `yarn <https://classic.yarnpkg.com/lang/en/docs/install/>`_. We use the version 1.22+ for now.
- `docker and the plugin compose <https://docker.com>`_ to run the database
Chill needs a redis server and a postgresql database, and a few other things like a "relatorio service" which will
generate documents from templates. **All these things are available through docker using the plugin compose**. We do not provide
information on how to run this without docker compose.
Install a new project
=====================
Initialize project and dependencies
***********************************
.. code-block:: bash
symfony new --version=5.4 my_chill_project
cd my_chill_project
We strongly encourage you to initialize a git repository at this step, to track further changes.
.. code-block:: bash
# add the flex endpoints required for custom recipes
cat <<< "$(jq '.extra.symfony += {"endpoint": ["flex://defaults", "https://gitlab.com/api/v4/projects/57371968/repository/files/index.json/raw?ref=main"]}' composer.json)" > composer.json
# install chill and some dependencies
# TODO fix the suffix "alpha1" and replace by ^3.0.0 when version 3.0.0 will be released
symfony composer require chill-project/chill-bundles v3.0.0-alpha1 champs-libres/wopi-lib dev-master@dev champs-libres/wopi-bundle dev-master@dev
We encourage you to accept the inclusion of the "Docker configuration from recipes": this is the documented way to run the database.
You must also accept to configure recipes from the contrib repository, unless you want to configure the bundles manually).
.. code-block:: bash
# fix some configuration
./post-install-chill.sh
# install node dependencies
yarn install
# and compile assets
yarn run encore production
.. note::
If you encounter this error during assets compilation (:code:`yarn run encore production`) (repeated multiple times):
.. code-block:: txt
[tsl] ERROR in /tmp/chill/v1/public/bundles/chillcalendar/types.ts(2,65)
TS2307: Cannot find module '../../../ChillMainBundle/Resources/public/types' or its corresponding type declarations.
run:
.. code-block:: bash
rm -rf public/bundles/*
Then restart the compilation of assets (:code:```yarn run encore production```)
Configure your project
**********************
You should read the configuration files in :code:`chill/config/packages` carefully, especially if you have
custom developments. But most of the time, this should be fine.
You have to configure some local variables, which are described in the :code:`.env` file. The secrets should not be stored
in this :code:`.env` file, but instead using the `secrets management tool <https://symfony.com/doc/current/configuration/secrets.html>`_
or in the :code:`.env.local` file, which should not be commited to the git repository.
You do not need to set variables for the smtp server, redis server and relatorio server, as they are generated automatically
by the symfony server, from the docker compose services.
The only required variable is the :code:`ADMIN_PASSWORD`. You can generate a hashed and salted admin password using the command
:code:`symfony console security:hash-password <your password> 'Symfony\Component\Security\Core\User\User'`. Then,
you can either:
- add this password to the :code:`.env.local` file, you must escape the character :code:`$`: if the generated password
is :code:`$2y$13$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm`, your :code:`.env.local` file will be:
.. code-block:: env
ADMIN_PASSWORD=\$2y\$13\$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm
- add the generated password to the secrets manager (**note**: you must add the generated hashed password to the secrets env,
not the password in clear text).
Prepare migrations and other tools
**********************************
To continue the installation process, you will have to run migrations:
.. code-block:: bash
# start databases and other services
docker compose up -d
# the first start, it may last some seconds, you can check with docker compose ps
# run migrations
symfony console doctrine:migrations:migrate
# setup messenger
symfony console messenger:setup-transports
# prepare some views
symfony console chill:db:sync-views
.. warning::
If you encounter an error while running :code:`symfony console messenger:setup-transports`, you can set up the messenger
transport to redis, by adding this in the :code:`.env.local` or :code:`.env` file:
.. code-block:: env
MESSENGER_TRANSPORT_DSN=redis://${REDIS_HOST}:${REDIS_PORT}/messages
Start your web server locally
*****************************
At this step, Chill will be ready to be served locally, but without any configuration. You can run the project
locally using the `local symfony server <https://symfony.com/doc/current/setup/symfony_server.html>`_:
.. code-block:: bash
# see the whole possibilities at https://symfony.com/doc/current/setup/symfony_server.html
symfony server:start -d
If you need to test the instance with accounts and some basic configuration, please install the fixtures (see below).
Add capabilities for dev
========================
If you need to add custom bundles, you can develop them in the `src/` directory, like for any other symfony project. You
can rely on the whole chill framework, meaning there is no need to add them to the original `chill-bundles`.
You will require some bundles to have the following development tools:
- add fixtures
- add profiler and var-dumper to debug
Install fixtures
****************
.. code-block:: bash
# generate fixtures for chill
symfony composer require --dev doctrine/doctrine-fixtures-bundle nelmio/alice
# now, you can generate fixtures (this will reset your database)
symfony console doctrine:fixtures:load
This will generate user accounts, centers, and some basic configuration.
The accounts created are: :code:`center a_social`, :code:`center b_social`, :code:`center a_direction`, ... The full list is
visibile in the "users" table: :code:`docker compose exec database psql -U app -c "SELECT username FROM users"`.
The password is always :code:`password`.
.. warning::
The fixtures are not fully functional. See the `corresponding issue <https://gitlab.com/Chill-Projet/chill-bundles/-/issues/280>`_.
Add web profiler and debugger
*****************************
.. code-block:: bash
symfony composer require --dev symfony/web-profiler-bundle symfony/var-dumper
Working on chill bundles
************************
If you plan to improve the chill-bundles repository, that's great!
You will have to download chill-bundles as a git repository (and not as an archive, which is barely editable).
In your :code:`composer.json` file, add these lines:
.. code-block:: diff
{
"config": {
+ "preferred-install": {
+ "chill-project/chill-bundles": "source",
"*": "dist"
+ }
}
Then, run :code:`symfony composer reinstall chill-project/chill-bundles` to re-install the package from source.
Code style, code quality and other tools
****************************************
For development, you will also have to install:
- `php-cs-fixer <https://cs.symfony.com/>`_
We also encourage you to use tools like `phpstan <https://phpstan.org>`_ and `rector <https://getrector.com>`_.
Commit and share your project
=============================
If multiple developers work on a project, you can commit your symfony project and share it with other people.
When another developer clones your project, they will have to:
- run :code:`symfony composer install` and :code:`yarn install` to install the same dependencies as the initial developer;
- run :code:`yarn run encore production` to compile assets;
- copy any possible variables from the :code:`.env.local` files;
- start the docker compose stack, using :code:`docker compose`, and run migrations, set up transports, and prepare chill db views
(see the corresponding command above)
Update
======
In order to update your app, you must update dependencies:
- for chill-bundles, you can `set the last version <https://gitlab.com/Chill-Projet/chill-bundles/-/releases>`_ manually
in the :code:`composer.json` file, or set the version to `^3.0.0` and run :code:`symfony composer update` regularly
- run :code:`composer update` and :code:`yarn update` to maintain your dependencies up-to-date.
After each update, you must update your database schema:
.. code-block:: bash
symfony console doctrine:migrations:migrate
symfony console chill:db:sync-views
Operations
==========
Build assets
************
run those commands:
.. code-block:: bash
# for production (or in dev, when you don't need to work on your assets and need some speed)
yarn run encore production
# in dev, when you wan't to reload the assets on each changes
yarn run encore dev --watch
How to execute the console ?
****************************
.. code-block:: bash
# start the console with all required variables
symfony console
# you can add your command after that:
symfony console list
How to generate documents
*************************
Documents are generated asynchronously by `"consuming messages" <https://symfony.com/doc/current/messenger.html#consuming-messages-running-the-worker>`_.
You must generate them using a dedicated process:
.. code-block:: bash
symfony console messenger:consume async priority
To avoid memory issues, we encourage you to also use the :code:`--limit` parameter of the command.
How to read emails sent by the program ?
*******************************************
In development, there is a built-in "mail catcher". Open it with :code:`symfony open:local:webmail`
How to run cron-jobs ?
**********************
Some commands must be executed in :ref:`cron jobs <cronjob>`. To execute them:
.. code-block:: bash
symfony console chill:cron-job:execute
What about materialized views ?
*******************************
There are some materialized views in chill, to speed up some complex computations in the database.
In order to refresh them, run a cron job or refresh them manually in your database.
How to run tests for chill-bundles
**********************************
Tests reside inside the installed bundles. You must `cd` into that directory, download the required packages, and execute them from this place.
**Note**: some bundles require the fixtures to be executed. See the dedicated _how-tos_.
Example, for running a unit test inside `main` bundle:
.. code-block:: bash
# cd into main directory
cd vendor/chill-project/chill-bundles
composer install
# run tests
bin/phpunit src/Bundle/path/to/your/test
Or for running tests to check code style and php conventions with csfixer and phpstan:
Troubleshooting
===============
Error `An exception has been thrown during the rendering of a template ("Asset manifest file "/var/www/app/web/build/manifest.json" does not exist.").` on first run
********************************************************************************************************************************************************************
Build assets, see above.
Running in production
=====================
Currently, to run this software in production, the *state of the art* is the following :
1. Run the software locally and tweak the configuration to your needs ;
2. Build the image and store it in a private container registry.
.. warning::
In production, you **must** set these variables:
* ``APP_ENV`` to ``prod``
* ``APP_DEBUG`` to ``false``
There are security issues if you keep the same variables as for production.
Going further
=============
.. toctree::
:maxdepth: 2
@@ -21,386 +367,3 @@ Installation & Usage
load-addresses.rst
prod-calendar-sms-sending.rst
msgraph-configure.rst
Requirements
************
- This project use `docker <https://docker.com>`_ to be run. As a developer, use `docker-compose <https://docs.docker.com/compose/overview/>`_ to bootstrap a dev environment in a glance. You do not need any other dependencies ;
- Make is used to automate scripts.
Installation
************
If you plan to run chill in production:
1. install it locally first, and check if everything is ok on your local machine;
2. once ready, build the image from your local machine, and deploy them.
If you want to develop some bundles, the first step is sufficient (until you deploy on production).
1. Get the code
===============
Clone or download the chill-skeleton project and `cd` into the main directory.
.. code-block:: bash
git clone https://gitea.champs-libres.be/Chill-project/chill-skeleton-basic.git
cd chill-skeleton-basic
As a developer, the code will stay on your computer and will be executed in docker container. To avoid permission problem, the code should be run with the same uid/gid from your current user. This is why we get your current user id with the command ``id -u`` in each following scripts.
2. Prepare composer to download the sources
===========================================
As you are running in dev, you must configure an auth token for getting the source code.
.. warning
If you skip this part, the code will be downloaded from dist instead of source (with git repository). You will probably replace the source manually, but the next time you will run ```composer update```, your repository will be replaced and you might loose something.
1. Create a personal access token from https://gitlab.com/-/profile/personal_access_tokens, with the `read_api` scope.
2. add a file called ```.composer/auth.json``` with this content:
.. code-block:: json
{
"gitlab-token": {
"gitlab.com": "glXXX-XXXXXXXXXXXXXXXXXXXX"
}
}
2. Prepare your variables and environment
=========================================
Copy ```docker-compose.override.dev.yml``` into ```docker-compose.override.yml```
.. code-block:: bash
cp docker-compose.override.dev.template.yml docker-compose.override.yml
2. Prepare your variables and docker-compose
============================================
Have a look at the variable in ``.env`` and check if you need to adapt them. If they do not adapt with your need, or if some are missing:
1. copy the file as ``.env.local``: ``cp .env .env.local``
2. you may replace some variables inside ``.env``
Prepare also you docker-compose installation, and adapt it to your needs:
1. If you plan to deploy on dev, copy the file ``docker-compose.override.dev.template.yml`` to ``docker-compose.override.yml``.
2. adapt to your needs.
**Note**: If you intend to use the bundle ``Chill-Doc-Store``, you will need to configure and install an openstack object storage container with temporary url middleware. You will have to configure `secret keys <https://docs.openstack.org/swift/latest/api/temporary_url_middleware.html#secret-keys>`_.
3. Run the bootstrap script
===========================
This script can be run using `make`
.. code-block:: bash
make init
This script will :
1. force docker-compose to, eventually, pull the base images and build the image used by this project ;
2. run an install script to download `composer <https://getcomposer.org>`_ ;
3. install the php dependencies
4. build assets
.. warning::
The script will work only if the binary ``docker-compose`` is located into your ``PATH``. If you use ``compose`` as a docker plugin,
you can simulate this binary by creating this file at (for instance), ``/usr/local/bin/docker-compose`` (and run ``chmod +x /usr/local/bin/docker-compose``):
.. code-block:: bash
#!/bin/bash
/usr/bin/docker compose "$@"
.. note::
In some cases it can happen that an old image (chill_base_php82 or chill_php82) stored in the docker cache will make the script fail. To solve this problem you have to delete the image and the container, before the make init :
.. code-block:: bash
docker-compose images php
docker rmi -f chill_php82:prod
docker-compose rm php
4. Start the project
====================
.. code-block:: bash
docker-compose up
**On the first run** (and after each upgrade), you must execute *post update commands* and run database migrations. With a container up and running, execute the following commands:
.. code-block:: bash
# mount into to container
./docker-php.sh
bin/console chill:db:sync-views
# and load fixtures
bin/console doctrine:migrations:migrate
Chill will be available at ``http://localhost:8001.`` Currently, there isn't any user or data. To add fixtures, run
.. code-block:: bash
# mount into to container
./docker-php.sh
# and load fixtures (do not this for production)
bin/console doctrine:fixtures:load --purge-with-truncate
There are several users available:
- ``center a_social``
- ``center b_social``
The password is always ``password``.
Now, read `Operations` below. For running in production, read `prod_`.
Operations
**********
Build assets
============
run those commands:
.. code-block:: bash
make build-assets
How to execute the console ?
============================
.. code-block:: bash
# if a container is running
./docker-php.sh
# if not
docker-compose run --user $(id -u) php bin/console
How to create the database schema (= run migrations) ?
======================================================
.. code-block:: bash
# if a container is running
./docker-php.sh
bin/console doctrine:migrations:migrate
bin/console chill:db:sync-views
# if not
docker-compose run --user $(id -u) php bin/console doctrine:migrations:migrate
docker-compose run --user $(id -u) php bin/console chill:db:sync-views
How to read the email sent by the program ?
===========================================
Go at ``http://localhost:8005`` and you should have access to mailcatcher.
In case of you should click on a link in the email, be aware that you should remove the "s" from https.
How to load fixtures ? (development mode only)
==============================================
.. code-block:: bash
# if a container is running
./docker-php.sh
bin/console doctrine:fixtures:load
# if not
docker-compose run --user $(id -u) php bin/console doctrine:fixtures:load
How to open a terminal in the project
=====================================
.. code-block:: bash
# if a container is running
./docker-php.sh
# if not
docker-compose run --user $(id -u) php /bin/bash
How to run cron-jobs ?
======================
Some command must be executed in :ref:`cron jobs <cronjob>`. To execute them:
.. code-block:: bash
# if a container is running
./docker-php.sh
bin/console chill:cron-job:execute
# some of them are executed only during the night. So, we have to force the execution during the day:
bin/console chill:cron-job:execute 'name-of-the-cron'
# if not
docker-compose run --user $(id -u) php bin/console chill:cron-job:execute
# some of them are executed only during the night. So, we have to force the execution during the day:
docker-compose run --user $(id -u) php bin/console chill:cron-job:execute 'name-of-the-cron'
How to run composer ?
=====================
.. code-block:: bash
# if a container is running
./docker-php.sh
composer
# if not
docker-compose run --user $(id -u) php composer
How to access to PGADMIN ?
==========================
Pgadmin is installed with docker-compose, and is available **only if you uncomment the appropriate lines into ``docker-compose.override.yml``.
You can access it at ``http://localhost:8002``.
Credentials:
- login: from the variable you set into ``docker-composer.override.yml``
- password: same :-)
How to run tests ?
==================
Tests reside inside the installed bundles. You must `cd` into that directory, download the required packages, and execute them from this place.
**Note**: some bundle require the fixture to be executed. See the dedicated _how-tos_.
Exemple, for running unit test inside `main` bundle:
.. code-block:: bash
# mount into the php image
./docker-php.sh
# cd into main directory
cd vendor/chill-project/chill-bundles
# download deps
git submodule init
git submodule update
composer install
# run tests
bin/phpunit src/Bundle/path/to/your/test
Or for running tests to check code style and php conventions with csfixer and phpstan:
.. code-block:: bash
# run code style fixer
bin/grumphp run --tasks=phpcsfixer
# run phpstan
bin/grumphp run --tasks=phpstan
.. note::
To avoid phpstan block your commits:
.. code-block:: bash
git commit -n ...
To avoid phpstan block your commits permanently:
.. code-block:: bash
./bin/grumphp git:deinit
How to run webpack interactively
================================
Executing :code:`bash docker-node.sh` will open a terminal in a node container, with volumes mounted.
How to switch the branch for chill-bundles, and get new dependencies
====================================================================
During development, you will switch to new branches for chill-bundles. As long as the dependencies are equals, this does not cause any problem. But sometimes, a new branch introduces a new dependency, and you must download it.
.. warning::
Ensure that you have gitlab-token ready before updating your branches. See above.
In order to do that without pain, use those steps:
0. Ensuire you have a token, set
1. at the app's root, update the ``composer.json`` to your current branch:
.. code-block:: json
{
"require": {
"chill-bundles": "dev-<my-branch>@dev"
}
2. mount into the php container (``./docker-php.sh``), and run ``composer update``
Error `An exception has been thrown during the rendering of a template ("Asset manifest file "/var/www/app/web/build/manifest.json" does not exist.").` on first run
====================================================================================================================================================================
Run :code:`make build-assets`
Running in production
*********************
Currently, to run this software in production, the *state of the art* is the following :
1. Run the software locally and tweak the configuration to your needs ;
2. Build the image and store them into a private container registry. This can be done using :code:`make build-and-push-image`.
To be sure to target the correct container registry, you have to adapt the image names into your ``docker-compose.override.yml`` file.
3. Push the image on your registry, or upload them to the destination machine using ``docker image save`` and ``docker image load``.
3. Run the image on your production server, using docker-compose or eventually docker stack. You have to customize the variable set in docker-compose.
See also the :ref:`running-production-tips-and-tricks` below.
.. warning::
In production, you **must** set those variables:
* ``APP_ENV`` to ``prod``
* ``APP_DEBUG`` to ``false``
There are security issues if you keep the same variable than for production.
.. _running-production-tips-and-tricks:
Tips and tricks
===============
Operation on database (backups, running custom sql, replication) are easier to set when run outside of a container. If you run into a container, take care of the volume where data are stored.
The PHP sessions are stored inside redis. This is useful if you distribute the traffic amongst different php server: they will share same sessions if a request goes into a different instance of the container.
When the PHP servers are shared across multiple instances, take care that some data is stored into redis: the same redis server should be reachable by all instances.
It is worth having an eye on the configuration of logstash container.
Design principles
*****************
Why the DB URL is also set in environment, and not in .env file ?
=================================================================
Because, at startup, a script does check the db is up and, if not, wait for a couple of seconds before running ``entrypoint.sh``. For avoiding double configuration, the configuration of the PHP app takes his configuration from environment also (and it will be standard in future releases, with symfony 4.0).

View File

@@ -8,6 +8,16 @@ Chill can store a list of geolocated address references, which are used to sugge
Those addresses may be load from a dedicated source.
Countries
=========
In order to load addresses into the chill application we first have to make sure that a list of countries is present.
To import the countries run the following command.
.. code-block:: bash
bin/console chill:main:countries:populate
In France
=========

View File

@@ -0,0 +1,14 @@
parameters:
ignoreErrors:
-
message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\<array\\<float\\|int\\|string\\|Stringable\\|null\\>\\>, iterable\\<array\\<string, bool\\|int\\|string\\>\\> given\\.$#"
count: 1
path: src/Bundle/ChillMainBundle/Controller/UserExportController.php
-
message: """
#^Fetching deprecated class constant ASC of class Doctrine\\\\Common\\\\Collections\\\\Criteria\\:
use Order\\:\\:Ascending instead$#
"""
count: 1
path: src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php

View File

@@ -33,3 +33,5 @@ includes:
- phpstan-baseline-level-5.neon
- phpstan-deprecations-sf54.neon
- phpstan-baseline-deprecations-doctrine-orm.neon
- phpstan-baseline-2024-05.neon

View File

@@ -49,6 +49,10 @@
<!-- temporarily removed, the time to find a fix -->
<exclude>src/Bundle/ChillPersonBundle/Tests/Controller/PersonDuplicateControllerViewTest.php</exclude>
</testsuite>
<testsuite name="TicketBundle">
<directory suffix="Test.php">src/Bundle/ChillTicketBundle/tests/</directory>
</testsuite>
<!--
<testsuite name="ReportBundle">
<directory suffix="Test.php">src/Bundle/ChillReportBundle/Tests/</directory>

View File

@@ -15,11 +15,10 @@ use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Entity\ActivityPresence;
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\DocStoreBundle\Form\StoredObjectType;
use Chill\DocStoreBundle\Form\CollectionStoredObjectType;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
@@ -276,16 +275,9 @@ class ActivityType extends AbstractType
}
if ($activityType->isVisible('documents')) {
$builder->add('documents', ChillCollectionType::class, [
'entry_type' => StoredObjectType::class,
$builder->add('documents', CollectionStoredObjectType::class, [
'label' => $activityType->getLabel('documents'),
'required' => $activityType->isRequired('documents'),
'allow_add' => true,
'allow_delete' => true,
'button_add_label' => 'activity.Insert a document',
'button_remove_label' => 'activity.Remove a document',
'empty_collection_explain' => 'No documents',
'entry_options' => ['has_title' => true],
]);
}

View File

@@ -92,7 +92,9 @@
{% endif %}
{%- if edit_form.documents is defined -%}
{{ form_row(edit_form.documents) }}
{{ form_label(edit_form.documents) }}
{{ form_errors(edit_form.documents) }}
{{ form_widget(edit_form.documents) }}
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
{% endif %}
@@ -127,4 +129,4 @@
{% block css %}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% endblock %}

View File

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

View File

@@ -51,8 +51,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
$limit = 50;
$offset = 0;
/** @var \DateInterval $interval the interval before the end of the expiration */
$interval = new \DateInterval('P1D');
$expiration = (new \DateTimeImmutable('now'))->add(new \DateInterval($input->getOption('subscription-duration')));
$users = $this->userRepository->findAllAsArray('fr');
$created = 0;
@@ -95,7 +93,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
} catch (UserAbsenceSyncException $e) {
$this->logger->error('could not sync user absence', ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), 'message' => $e->getMessage()]);
$output->writeln(sprintf('Could not sync user absence: id: %s and email: %s', $user->getId(), $user->getEmail()));
throw $e;
}
// we first try to renew an existing subscription, if any.

View File

@@ -49,15 +49,12 @@ interface CustomFieldInterface
/**
* Return if the value can be considered as empty.
*
* @param mixed $value the value passed throug the deserialize function
*/
public function isEmptyValue(mixed $value, CustomField $customField);
/**
* Return a repsentation of the value of the CustomField.
*
* @param mixed $value the raw value, **not deserialized** (= as stored in the db)
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
*
* @return string an html representation of the value

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,15 +39,15 @@ class StoredObject implements Document, TrackCreationInterface
final public const STATUS_PENDING = 'pending';
final public const STATUS_FAILURE = 'failure';
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
private array $datas = [];
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
private string $filename = '';
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
@@ -56,23 +56,23 @@ class StoredObject implements Document, TrackCreationInterface
/**
* @var int[]
*/
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
private array $iv = [];
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
private array $keyInfos = [];
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
private string $title = '';
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
private string $type = '';
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
#[ORM\Column(type: 'uuid', unique: true)]
private UuidInterface $uuid;
@@ -98,7 +98,7 @@ class StoredObject implements Document, TrackCreationInterface
* @param StoredObject::STATUS_* $status
*/
public function __construct(
#[Serializer\Groups(['read'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
private string $status = 'ready'
) {
$this->uuid = Uuid::uuid4();
@@ -114,7 +114,7 @@ class StoredObject implements Document, TrackCreationInterface
/**
* @deprecated
*/
#[Serializer\Groups(['read', 'write'])]
#[Serializer\Groups(['write'])]
public function getCreationDate(): \DateTime
{
if (null === $this->createdAt) {

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AccompanyingCourseDocumentType extends AbstractType
final class AccompanyingCourseDocumentType extends AbstractType
{
public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Form;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CollectionStoredObjectType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('entry_type', StoredObjectType::class)
->setDefault('allow_add', true)
->setDefault('allow_delete', true)
->setDefault('button_add_label', 'stored_object.Insert a document')
->setDefault('button_remove_label', 'stored_object.Remove a document')
->setDefault('empty_collection_explain', 'No documents')
->setDefault('entry_options', ['has_title' => true])
->setDefault('js_caller', 'data-collection-stored-object');
}
public function getParent()
{
return ChillCollectionType::class;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Form\DataMapper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Exception;
use Symfony\Component\Form\FormInterface;
class StoredObjectDataMapper implements DataMapperInterface
{
public function __construct() {}
/**
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
*/
public function mapDataToForms($viewData, \Traversable $forms)
{
if (null === $viewData) {
return;
}
if (!$viewData instanceof StoredObject) {
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
}
$forms = iterator_to_array($forms);
if (array_key_exists('title', $forms)) {
$forms['title']->setData($viewData->getTitle());
}
$forms['stored_object']->setData($viewData);
}
/**
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
*/
public function mapFormsToData(\Traversable $forms, &$viewData)
{
$forms = iterator_to_array($forms);
if (!(null === $viewData || $viewData instanceof StoredObject)) {
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
}
if (null === $forms['stored_object']->getData()) {
return;
}
/** @var StoredObject $viewData */
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
// we do not want to erase the previous object
$viewData = new StoredObject();
}
$viewData->setFilename($forms['stored_object']->getData()['filename']);
$viewData->setIv($forms['stored_object']->getData()['iv']);
$viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']);
$viewData->setType($forms['stored_object']->getData()['type']);
if (array_key_exists('title', $forms)) {
$viewData->setTitle($forms['title']->getData());
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Form\DataTransformer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\Serializer\SerializerInterface;
class StoredObjectDataTransformer implements DataTransformerInterface
{
public function __construct(
private readonly SerializerInterface $serializer
) {}
public function transform(mixed $value): mixed
{
if (null === $value) {
return '';
}
if ($value instanceof StoredObject) {
return $this->serializer->serialize($value, 'json', [
'groups' => [
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
],
]);
}
throw new UnexpectedTypeException($value, StoredObject::class);
}
public function reverseTransform(mixed $value): mixed
{
if ('' === $value || null === $value) {
return null;
}
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
}
}

View File

@@ -11,11 +11,10 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Form;
use Chill\DocStoreBundle\Form\Type\AsyncUploaderType;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -24,9 +23,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type which allow to join a document.
*/
class StoredObjectType extends AbstractType
final class StoredObjectType extends AbstractType
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function __construct(
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
private readonly StoredObjectDataMapper $storedObjectDataMapper,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
@@ -37,30 +39,9 @@ class StoredObjectType extends AbstractType
]);
}
$builder
->add('filename', AsyncUploaderType::class)
->add('type', HiddenType::class)
->add('keyInfos', HiddenType::class)
->add('iv', HiddenType::class);
$builder
->get('keyInfos')
->addModelTransformer(new CallbackTransformer(
$this->transform(...),
$this->reverseTransform(...)
));
$builder
->get('iv')
->addModelTransformer(new CallbackTransformer(
$this->transform(...),
$this->reverseTransform(...)
));
$builder
->addModelTransformer(new CallbackTransformer(
$this->transformObject(...),
$this->reverseTransformObject(...)
));
$builder->add('stored_object', HiddenType::class);
$builder->get('stored_object')->addModelTransformer($this->storedObjectDataTransformer);
$builder->setDataMapper($this->storedObjectDataMapper);
}
public function configureOptions(OptionsResolver $resolver)
@@ -72,43 +53,4 @@ class StoredObjectType extends AbstractType
->setDefault('has_title', false)
->setAllowedTypes('has_title', ['bool']);
}
public function reverseTransform($value)
{
if (null === $value) {
return null;
}
return \json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
}
public function reverseTransformObject($object)
{
if (null === $object) {
return null;
}
if (null === $object->getFilename()) {
// remove the original object
$this->em->remove($object);
return null;
}
return $object;
}
public function transform($object)
{
if (null === $object) {
return null;
}
return \json_encode($object, JSON_THROW_ON_ERROR);
}
public function transformObject($object = null)
{
return $object;
}
}

View File

@@ -0,0 +1,86 @@
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
import {createApp} from "vue";
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
import {StoredObject, StoredObjectCreated} from "../../types";
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
const i18n = _createI18n({});
const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElement): void => {
console.log('app started', divElement);
const input_stored_object: HTMLInputElement|null = divElement.querySelector("input[data-stored-object]");
if (null === input_stored_object) {
throw new Error('input to stored object not found');
}
let existingDoc: StoredObject|null = null;
if (input_stored_object.value !== "") {
existingDoc = JSON.parse(input_stored_object.value);
}
const app_container = document.createElement("div");
divElement.appendChild(app_container);
const app = createApp({
template: '<drop-file-widget :existingDoc="this.$data.existingDoc" :allowRemove="true" @addDocument="this.addDocument" @removeDocument="removeDocument"></drop-file-widget>',
data(vm) {
return {
existingDoc: existingDoc,
}
},
components: {
DropFileWidget,
},
methods: {
addDocument: function(object: StoredObjectCreated): void {
console.log('object added', object);
this.$data.existingDoc = object;
input_stored_object.value = JSON.stringify(object);
},
removeDocument: function(object: StoredObject): void {
console.log('catch remove document', object);
input_stored_object.value = "";
this.$data.existingDoc = null;
console.log('collectionEntry', collectionEntry);
if (null !== collectionEntry) {
console.log('will remove collection');
collectionEntry.remove();
}
}
}
});
app.use(i18n).mount(app_container);
}
window.addEventListener('collection-add-entry', ((e: CustomEvent<CollectionEventPayload>) => {
const detail = e.detail;
const divElement: null|HTMLDivElement = detail.entry.querySelector('div[data-stored-object]');
if (null === divElement) {
throw new Error('div[data-stored-object] not found');
}
startApp(divElement, detail.entry);
}) as EventListener);
window.addEventListener('DOMContentLoaded', () => {
const upload_inputs: NodeListOf<HTMLDivElement> = document.querySelectorAll('div[data-stored-object]');
upload_inputs.forEach((input: HTMLDivElement): void => {
// test for a parent to check if this is a collection entry
let collectionEntry: null|HTMLLIElement = null;
let parent = input.parentElement;
console.log('parent', parent);
if (null !== parent) {
let grandParent = parent.parentElement;
console.log('grandParent', grandParent);
if (null !== grandParent) {
if (grandParent.tagName.toLowerCase() === 'li' && grandParent.classList.contains('entry')) {
collectionEntry = grandParent as HTMLLIElement;
}
}
}
startApp(input, collectionEntry);
})
});
export {}

View File

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

View File

@@ -17,6 +17,20 @@ export interface StoredObject {
type: string,
uuid: string,
status: StoredObjectStatus,
_links?: {
dav_link?: {
href: string
expiration: number
},
}
}
export interface StoredObjectCreated {
status: "stored_object_created",
filename: string,
iv: Uint8Array,
keyInfos: object,
type: string,
}
export interface StoredObjectStatusChange {
@@ -33,3 +47,18 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = {
(): Promise<void>
}
/**
* Object containing information for performering a POST request to a swift object store
*/
export interface PostStoreObjectSignature {
method: "POST",
max_file_size: number,
max_file_count: 1,
expires: number,
submit_delay: 180,
redirect: string,
prefix: string,
url: string,
signature: string,
}

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="'ready' === props.storedObject.status" class="btn-group">
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
@@ -7,6 +7,9 @@
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
@@ -32,13 +35,14 @@ import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
import {
StoredObject,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
StoredObject, StoredObjectCreated,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
storedObject: StoredObject|StoredObjectCreated,
small?: boolean,
canEdit?: boolean,
canDownload?: boolean,
@@ -57,6 +61,16 @@ interface DocumentActionButtonsGroupConfig {
* If set, will execute this function before leaving to the editor
*/
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
/**
* a link to download and edit file using webdav
*/
davLink?: string,
/**
* the expiration date of the download, as a unix timestamp
*/
davLinkExpiration?: number,
}
const emit = defineEmits<{
@@ -68,7 +82,7 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
canEdit: true,
canDownload: true,
canConvertPdf: true,
returnPath: window.location.pathname + window.location.search + window.location.hash,
returnPath: window.location.pathname + window.location.search + window.location.hash
});
/**
@@ -85,6 +99,7 @@ const checkForReady = function(): void {
if (
'ready' === props.storedObject.status
|| 'failure' === props.storedObject.status
|| 'stored_object_created' === props.storedObject.status
// stop reloading if the page stays opened for a long time
|| tryiesForReady > maxTryiesForReady
) {
@@ -97,6 +112,11 @@ const checkForReady = function(): void {
};
const onObjectNewStatusCallback = async function(): Promise<void> {
if (props.storedObject.status === 'stored_object_created') {
return Promise.resolve();
}
const new_status = await is_object_ready(props.storedObject);
if (props.storedObject.status !== new_status.status) {
emit('onStoredObjectStatusChange', new_status);

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import {StoredObject, StoredObjectCreated} from "../../types";
import {encryptFile, uploadFile} from "../_components/helper";
import {computed, ref, Ref} from "vue";
interface DropFileConfig {
existingDoc?: StoredObjectCreated|StoredObject,
}
const props = defineProps<DropFileConfig>();
const emit = defineEmits<{
(e: 'addDocument', stored_object: StoredObjectCreated): void,
}>();
const is_dragging: Ref<boolean> = ref(false);
const uploading: Ref<boolean> = ref(false);
const has_existing_doc = computed<boolean>(() => {
return props.existingDoc !== undefined && props.existingDoc !== null;
});
const onDragOver = (e: Event) => {
e.preventDefault();
is_dragging.value = true;
}
const onDragLeave = (e: Event) => {
e.preventDefault();
is_dragging.value = false;
}
const onDrop = (e: DragEvent) => {
console.log('on drop', e);
e.preventDefault();
const files = e.dataTransfer?.files;
if (null === files || undefined === files) {
console.error("no files transferred", e.dataTransfer);
return;
}
if (files.length === 0) {
console.error("no files given");
return;
}
handleFile(files[0])
}
const onZoneClick = (e: Event) => {
e.stopPropagation();
e.preventDefault();
const input = document.createElement("input");
input.type = "file";
input.addEventListener("change", onFileChange);
input.click();
}
const onFileChange = async (event: Event): Promise<void> => {
const input = event.target as HTMLInputElement;
console.log('event triggered', input);
if (input.files && input.files[0]) {
console.log('file added', input.files[0]);
const file = input.files[0];
await handleFile(file);
return Promise.resolve();
}
throw 'No file given';
}
const handleFile = async (file: File): Promise<void> => {
uploading.value = true;
const type = file.type;
const buffer = await file.arrayBuffer();
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
const filename = await uploadFile(encrypted);
console.log(iv, jsonWebKey);
const storedObject: StoredObjectCreated = {
filename: filename,
iv,
keyInfos: jsonWebKey,
type: type,
status: "stored_object_created",
}
emit('addDocument', storedObject);
uploading.value = false;
}
</script>
<template>
<div class="drop-file">
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<p v-if="has_existing_doc">
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i>
</p>
<!-- todo i18n -->
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
</div>
<div v-else class="waiting">
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
</template>
<style scoped lang="scss">
.drop-file {
width: 100%;
& > .area, & > .waiting {
width: 100%;
height: 8rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
& > .area {
border: 4px dashed #ccc;
&.dragging {
border: 4px dashed blue;
}
}
}
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import {StoredObject, StoredObjectCreated} from "../../types";
import {computed, ref, Ref} from "vue";
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface DropFileConfig {
allowRemove: boolean,
existingDoc?: StoredObjectCreated|StoredObject,
}
const props = withDefaults(defineProps<DropFileConfig>(), {
allowRemove: false,
});
const emit = defineEmits<{
(e: 'addDocument', stored_object: StoredObjectCreated): void,
(e: 'removeDocument', stored_object: null): void
}>();
const has_existing_doc = computed<boolean>(() => {
return props.existingDoc !== undefined && props.existingDoc !== null;
});
const dav_link_expiration = computed<number|undefined>(() => {
if (props.existingDoc === undefined || props.existingDoc === null) {
return undefined;
}
if (props.existingDoc.status !== 'ready') {
return undefined;
}
return props.existingDoc._links?.dav_link?.expiration;
});
const dav_link_href = computed<string|undefined>(() => {
if (props.existingDoc === undefined || props.existingDoc === null) {
return undefined;
}
if (props.existingDoc.status !== 'ready') {
return undefined;
}
return props.existingDoc._links?.dav_link?.href;
})
const onAddDocument = (s: StoredObjectCreated): void => {
emit('addDocument', s);
}
const onRemoveDocument = (e: Event): void => {
e.stopPropagation();
e.preventDefault();
emit('removeDocument', null);
}
</script>
<template>
<div>
<drop-file :existingDoc="props.existingDoc" @addDocument="onAddDocument"></drop-file>
<ul class="record_actions">
<li v-if="has_existing_doc">
<document-action-buttons-group
:stored-object="props.existingDoc"
:can-edit="props.existingDoc?.status === 'ready'"
:can-download="true"
:dav-link="dav_link_href"
:dav-link-expiration="dav_link_expiration"
/>
</li>
<li>
<button v-if="allowRemove" class="btn btn-delete" @click="onRemoveDocument($event)" ></button>
</li>
</ul>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -10,10 +10,10 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive} from "vue";
import {StoredObject} from "../../types";
import {StoredObject, StoredObjectCreated} from "../../types";
interface ConvertButtonConfig {
storedObject: StoredObject,
storedObject: StoredObject|StoredObjectCreated,
classes: { [key: string]: boolean},
filename?: string,
};

View File

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

View File

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

View File

@@ -8,10 +8,10 @@
<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,
storedObject: StoredObject|StoredObjectCreated,
returnPath?: string,
classes: {[k: string] : boolean},
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,62 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $this->extractLastModifiedFromResponse($response);
}
public function getContentLength(StoredObject $document): int
{
if ([] === $document->getKeyInfos()) {
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractContentLengthFromResponse($response);
}
return strlen($this->read($document));
}
public function etag(StoredObject $document): string
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractEtagFromResponse($response, $document);
}
public function read(StoredObject $document): string
{
$response = $this->getResponseFromCache($document);
@@ -159,6 +215,22 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $date;
}
private function extractContentLengthFromResponse(ResponseInterface $response): int
{
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
}
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
{
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
if ('' === $etag) {
return null;
}
return $etag;
}
private function fillCache(StoredObject $document): void
{
try {

View File

@@ -18,6 +18,8 @@ interface StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface;
public function getContentLength(StoredObject $document): int;
/**
* Get the content of a StoredObject.
*
@@ -39,5 +41,7 @@ interface StoredObjectManagerInterface
*/
public function write(StoredObject $document, string $clearContent): void;
public function etag(StoredObject $document): string;
public function clearCache(): void;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ Update document: Modifier le document
Edit attributes: Modifier les propriétés du document
Existing document: Document existant
No document to download: Aucun document à télécharger
'Choose a document category': Choisissez une catégorie de document
"Choose a document category": Choisissez une catégorie de document
No document found: Aucun document trouvé
The document is successfully registered: Le document est enregistré
The document is successfully updated: Le document est mis à jour
@@ -36,7 +36,6 @@ Delete document ?: Supprimer le document ?
Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ?
The document is successfully removed: Le document a été supprimé
# dropzone upload
File too big: Fichier trop volumineux
Drop your file or click here: Cliquez ici ou faites glissez votre nouveau fichier dans cette zone
@@ -47,6 +46,9 @@ Are you sure you want to cancel this upload ?: Êtes-vous sûrs de vouloir annul
Upload canceled: Téléversement annulé
Remove existing file: Supprimer le document existant
stored_object:
Insert a document: Ajouter un document
# ROLES
PersonDocument: Documents
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document

View File

@@ -201,8 +201,6 @@ class EventTypeController extends AbstractController
/**
* Creates a form to delete a EventType entity by id.
*
* @param mixed $id The entity id
*
* @return \Symfony\Component\Form\Form The form
*/
private function createDeleteForm(mixed $id)

View File

@@ -201,8 +201,6 @@ class RoleController extends AbstractController
/**
* Creates a form to delete a Role entity by id.
*
* @param mixed $id The entity id
*
* @return \Symfony\Component\Form\Form The form
*/
private function createDeleteForm(mixed $id)

View File

@@ -201,8 +201,6 @@ class StatusController extends AbstractController
/**
* Creates a form to delete a Status entity by id.
*
* @param mixed $id The entity id
*
* @return \Symfony\Component\Form\Form The form
*/
private function createDeleteForm(mixed $id)

View File

@@ -18,9 +18,9 @@ use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillDateTimeType;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\PickUserLocationType;
use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Form\Type\UserPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -45,14 +45,8 @@ class EventType extends AbstractType
'class' => '',
],
])
->add('moderator', UserPickerType::class, [
'center' => $options['center'],
'role' => $options['role'],
'placeholder' => 'Pick a moderator',
'attr' => [
'class' => '',
],
'required' => false,
->add('moderator', PickUserDynamicType::class, [
'label' => 'Pick a moderator',
])
->add('location', PickUserLocationType::class, [
'label' => 'event.fields.location',

View File

@@ -1,10 +1,14 @@
{% extends '@ChillEvent/layout.html.twig' %}
{% extends '@ChillEvent/layout.html.twig' %} {% block js %}
{{ encore_entry_script_tags("mod_async_upload") }}
{{ encore_entry_script_tags("mod_pickentity_type") }}
{% block title 'Event edit'|trans %}
{% endblock %} {% block css %}
{{ encore_entry_link_tags("mod_async_upload") }}
{{ encore_entry_link_tags("mod_pickentity_type") }}
{% block event_content -%}
{% endblock %} {% block title 'Event edit'|trans %} {% block event_content -%}
<div class="col-10">
<h1>{{ 'Event edit'|trans }}</h1>
<h1>{{ "Event edit" | trans }}</h1>
{{ form_start(edit_form) }}
{{ form_errors(edit_form) }}
@@ -12,7 +16,7 @@
{{ form_row(edit_form.name) }}
{{ form_row(edit_form.date) }}
{{ form_row(edit_form.type, { 'label': 'Event type' }) }}
{{ form_row(edit_form.type, { label: "Event type" }) }}
{{ form_row(edit_form.moderator) }}
{{ form_row(edit_form.location) }}
{{ form_row(edit_form.organizationCost) }}
@@ -22,16 +26,22 @@
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel">
{{ 'List of events'|trans|chill_return_path_label }}
<a
href="{{ chill_return_path_or('chill_event_event_list') }}"
class="btn btn-cancel"
>
{{ "List of events" | trans | chill_return_path_label }}
</a>
</li>
<li>
{{ form_widget(edit_form.submit, { 'attr' : { 'class' : 'btn btn-update' } }) }}
{{
form_widget(edit_form.submit, {
attr: { class: "btn btn-update" }
})
}}
</li>
</ul>
{{ form_end(edit_form) }}
</div>
{% endblock %}

View File

@@ -1,41 +1,41 @@
{% extends '@ChillEvent/layout.html.twig' %}
{% extends '@ChillEvent/layout.html.twig' %} {% block js %}
{{ encore_entry_script_tags("mod_async_upload") }}
{{ encore_entry_script_tags("mod_pickentity_type") }}
{% block js %}
{{ encore_entry_script_tags('mod_async_upload') }}
{% endblock %}
{% endblock %} {% block css %}
{{ encore_entry_link_tags("mod_async_upload") }}
{{ encore_entry_link_tags("mod_pickentity_type") }}
{% block css %}
{{ encore_entry_link_tags('mod_async_upload') }}
{% endblock %}
{% block title 'Event creation'|trans %}
{% block event_content -%}
{% endblock %} {% block title 'Event creation'|trans %} {% block event_content
-%}
<div class="col-10">
<h1>{{ 'Event creation'|trans }}</h1>
<h1>{{ "Event creation" | trans }}</h1>
{{ form_start(form) }}
{{ form_errors(form) }}
{{ form_row(form.circle) }}
{{ form_row(form.name) }}
{{ form_row(form.date) }}
{{ form_row(form.type, { 'label': 'Event type' }) }}
{{ form_row(form.type, { label: "Event type" }) }}
{{ form_row(form.moderator) }}
{{ form_row(form.location) }}
{{ form_row(form.organizationCost) }}
{{ form_row(form.comment) }}
{{ form_row(form.documents) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_event_list_most_recent') }}" class="btn btn-cancel">
{{ 'Back to the most recent events'|trans }}
<a
href="{{ path('chill_event_list_most_recent') }}"
class="btn btn-cancel"
>
{{ "Back to the most recent events" | trans }}
</a>
</li>
<li>
{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-create' } }) }}
{{
form_widget(form.submit, { attr: { class: "btn btn-create" } })
}}
</li>
</ul>

View File

@@ -1,92 +1,126 @@
{% extends '@ChillEvent/layout.html.twig' %}
{% extends '@ChillEvent/layout.html.twig' %} {% block title 'Events'|trans %} {%
block js %}
{{ parent() }}
{{ encore_entry_script_tags("mod_pickentity_type") }}
{% endblock %} {% block css %}
{{ parent() }}
{{ encore_entry_link_tags("mod_pickentity_type") }}
{% endblock %} {% block content %}
<div class="col-10">
<h1>{{ block("title") }}</h1>
{% block title 'Events'|trans %}
{{ filter | chill_render_filter_order_helper }}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block content %}
<h1>{{ block('title') }}</h1>
{{ filter|chill_render_filter_order_helper }}
{# {% if is_granted('CHILL_EVENT_CREATE') %} #}
<ul class="record_actions">
<li><a class="btn btn-create" href="{{ chill_path_add_return_path('chill_event__event_new_pickcenter') }}">{{ 'Add an event'|trans }}</a></li>
</ul>
{# {% endif %} #}
{% if events|length > 0 %}
<div class="flex-table">
{% for e in events %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col">
<div class="denomination h2">
{{ e.name }}
</div>
<p>{{ e.type.name|localize_translatable_string }}</p>
{% if e.moderator is not null %}
<p>{{ 'Moderator'|trans }}: {{ e.moderator|chill_entity_render_box }}</p>
{% endif %}
</div>
<div class="item-col">
<div class="container" style="text-align: right;">
<p>{{ e.date|format_datetime('medium', 'medium') }}</p>
<p>{{ 'count participations to this event'|trans({'count': e.participations|length}) }}</p>
</div>
</div>
{# {% if is_granted('CHILL_EVENT_CREATE') %} #}
<ul class="record_actions">
<li>
<a
class="btn btn-create"
href="{{
chill_path_add_return_path(
'chill_event__event_new_pickcenter'
)
}}"
>{{ "Add an event" | trans }}</a
>
</li>
</ul>
{# {% endif %} #} {% if events|length > 0 %}
<div class="flex-table">
{% for e in events %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col">
<div class="denomination h2">
{{ e.name }}
</div>
{% if e.participations|length > 0 %}
<div class="item-row separator">
<strong>{{ 'Participations'|trans }}&nbsp;: </strong>
{% for part in e.participations|slice(0, 20) %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: part.person.id },
action: 'show',
displayBadge: true,
buttonText: part.person|chill_entity_render_string,
isDead: part.person.deathdate is not null
} %}
{% endfor %}
{% if e.participations|length > 20 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
{% endif %}
</div>
<p>{{ e.type.name | localize_translatable_string }}</p>
{% if e.moderator is not null %}
<p>
{{ "Moderator" | trans }}:
{{ e.moderator | chill_entity_render_box }}
</p>
{% endif %}
<div class="item-row">
<div class="item-col">
{{ form_start(eventForms[e.id]) }}
{{ form_widget(eventForms[e.id].person_id) }}
{{ form_end(eventForms[e.id]) }}
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta">
</div>
<div class="item-col">
<ul class="record_actions">
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li><a href="{{ chill_path_add_return_path('chill_event__event_delete', {'event_id': e.id}) }}" class="btn btn-delete"></a></li>
{% endif %}
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li><a href="{{ chill_path_add_return_path('chill_event__event_edit', {'event_id': e.id}) }}" class="btn btn-edit"></a></li>
{% endif %}
<li><a href="{{ chill_path_add_return_path('chill_event__event_show', {'event_id': e.id}) }}" class="btn btn-show"></a></li>
</ul>
</div>
</div>
<div class="item-col">
<div class="container" style="text-align: right">
<p>{{ e.date|format_datetime('medium', 'medium') }}</p>
<p>
{{ 'count participations to this event'|trans({'count': e.participations|length}) }}
</p>
</div>
</div>
{% endfor %}
</div>
{% if e.participations|length > 0 %}
<div class="item-row separator">
<strong>{{ "Participations" | trans }}&nbsp;: </strong>
{% for part in e.participations|slice(0, 20) %} {% include
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: part.person.id }, action:
'show', displayBadge: true, buttonText:
part.person|chill_entity_render_string, isDead:
part.person.deathdate is not null } %} {% endfor %} {% if
e.participations|length > 20 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
{% endif %}
</div>
{% endif %}
<div class="item-row">
<div class="item-col">
{{ form_start(eventForms[e.id]) }}
{{ form_widget(eventForms[e.id].person_id) }}
{{ form_end(eventForms[e.id]) }}
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta"></div>
<div class="item-col">
<ul class="record_actions">
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li>
<a
href="{{
chill_path_add_return_path(
'chill_event__event_delete',
{ event_id: e.id }
)
}}"
class="btn btn-delete"
></a>
</li>
{% endif %} {% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li>
<a
href="{{
chill_path_add_return_path(
'chill_event__event_edit',
{ event_id: e.id }
)
}}"
class="btn btn-edit"
></a>
</li>
{% endif %}
<li>
<a
href="{{
chill_path_add_return_path(
'chill_event__event_show',
{ event_id: e.id }
)
}}"
class="btn btn-show"
></a>
</li>
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{{ chill_pagination(pagination) }}
{{ chill_pagination(pagination) }}
{% endblock %}

View File

@@ -725,7 +725,6 @@ class CRUDController extends AbstractController
* and view.
*
* @param string $action
* @param mixed $entity the entity for the current request, or an array of entities
*
* @return string the path to the template
*

View File

@@ -442,8 +442,6 @@ final class PermissionsGroupController extends AbstractController
/**
* Creates a form to delete a link to roleScope.
*
* @param mixed $permissionsGroup The entity id
*/
private function createDeleteRoleScopeForm(
PermissionsGroup $permissionsGroup,

View File

@@ -211,7 +211,7 @@ class SearchController extends AbstractController
$builder = $this
->get('form.factory')
->createNamedBuilder(
null,
'',
FormType::class,
$data,
['method' => Request::METHOD_POST]

View File

@@ -71,6 +71,7 @@ final readonly class UserExportController
)
);
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
/* @phpstan-ignore-next-line as phpstan seem to ignore that we transform datetime into string */
$csv->insertAll($users);
return new StreamedResponse(

View File

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

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\DataFixtures\ORM;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
class LoadUserGroup extends Fixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return ['user-group'];
}
public function load(ObjectManager $manager)
{
$centerASocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_social']);
$centerBSocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_social']);
$multiCenter = $manager->getRepository(User::class)->findOneBy(['username' => 'multi_center']);
$administrativeA = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_administrative']);
$administrativeB = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_administrative']);
$level1 = $this->generateLevelGroup('Niveau 1', '#eec84aff', '#000000ff', 'level');
$level1->addUser($centerASocial)->addUser($centerBSocial);
$manager->persist($level1);
$level2 = $this->generateLevelGroup('Niveau 2', ' #e2793dff', '#000000ff', 'level');
$level2->addUser($multiCenter);
$manager->persist($level2);
$level3 = $this->generateLevelGroup('Niveau 3', ' #df4949ff', '#000000ff', 'level');
$level3->addUser($multiCenter);
$manager->persist($level3);
$tss = $this->generateLevelGroup('Travailleur sociaux', '#43b29dff', '#000000ff', '');
$tss->addUser($multiCenter)->addUser($centerASocial)->addUser($centerBSocial);
$manager->persist($tss);
$admins = $this->generateLevelGroup('Administratif', '#334d5cff', '#000000ff', '');
$admins->addUser($administrativeA)->addUser($administrativeB);
$manager->persist($admins);
$manager->flush();
}
private function generateLevelGroup(string $title, string $backgroundColor, string $foregroundColor, string $excludeKey): UserGroup
{
$userGroup = new UserGroup();
return $userGroup
->setLabel(['fr' => $title])
->setBackgroundColor($backgroundColor)
->setForegroundColor($foregroundColor)
->setExcludeKey($excludeKey)
;
}
}

View File

@@ -24,6 +24,7 @@ use Chill\MainBundle\Controller\LocationTypeController;
use Chill\MainBundle\Controller\NewsItemController;
use Chill\MainBundle\Controller\RegroupmentController;
use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
@@ -59,6 +60,7 @@ use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType;
@@ -803,6 +805,21 @@ class ChillMainExtension extends Extension implements
],
],
],
[
'class' => UserGroup::class,
'controller' => UserGroupApiController::class,
'name' => 'user-group',
'base_path' => '/api/1.0/main/user-group',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
],
]);
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_user_group')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['user_group' => UserGroup::class])]
class UserGroup
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $label = [];
/**
* @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_user_group_user')]
private Collection $users;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#ffffffff'])]
#[Serializer\Groups(['read'])]
private string $backgroundColor = '#ffffffff';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#000000ff'])]
#[Serializer\Groups(['read'])]
private string $foregroundColor = '#000000ff';
/**
* Groups with same exclude key are mutually exclusive: adding one in a many-to-one relationship
* will exclude others.
*
* An empty string means "no exclusion"
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Serializer\Groups(['read'])]
private string $excludeKey = '';
public function __construct()
{
$this->users = new ArrayCollection();
}
public function addUser(User $user): self
{
if (!$this->users->contains($user)) {
$this->users[] = $user;
}
return $this;
}
public function removeUser(User $user): self
{
if ($this->users->contains($user)) {
$this->users->removeElement($user);
}
return $this;
}
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): array
{
return $this->label;
}
public function getUsers(): Collection
{
return $this->users;
}
public function getForegroundColor(): string
{
return $this->foregroundColor;
}
public function getExcludeKey(): string
{
return $this->excludeKey;
}
public function getBackgroundColor(): string
{
return $this->backgroundColor;
}
public function setForegroundColor(string $foregroundColor): self
{
$this->foregroundColor = $foregroundColor;
return $this;
}
public function setBackgroundColor(string $backgroundColor): self
{
$this->backgroundColor = $backgroundColor;
return $this;
}
public function setExcludeKey(string $excludeKey): self
{
$this->excludeKey = $excludeKey;
return $this;
}
public function setLabel(array $label): self
{
$this->label = $label;
return $this;
}
}

View File

@@ -85,7 +85,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
*/
#[Assert\Valid(traverse: true)]
#[ORM\OneToMany(targetEntity: EntityWorkflowStep::class, mappedBy: 'entityWorkflow', orphanRemoval: true, cascade: ['persist'])]
#[ORM\OrderBy(['transitionAt' => 'ASC', 'id' => 'ASC'])]
#[ORM\OrderBy(['transitionAt' => \Doctrine\Common\Collections\Criteria::ASC, 'id' => 'ASC'])]
private Collection $steps;
/**

View File

@@ -73,7 +73,6 @@ interface AggregatorInterface extends ModifierInterface
*
* @param string $key The column key, as added in the query
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
* @param mixed $data The data from the export's form (as defined in `buildForm`
*
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
*/

View File

@@ -30,8 +30,6 @@ interface ExportElementValidatedInterface
/**
* validate the form's data and, if required, build a contraint
* violation on the data.
*
* @param mixed $data the data, as returned by the user
*/
public function validateForm(mixed $data, ExecutionContextInterface $context);
}

View File

@@ -96,7 +96,6 @@ interface ExportInterface extends ExportElementInterface
*
* @param string $key The column key, as added in the query
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
* @param mixed $data The data from the export's form (as defined in `buildForm`)
*
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
*/

View File

@@ -552,7 +552,6 @@ class ExportManager
*
* This function check the acl.
*
* @param mixed $data the data under the initial 'filters' data
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
*
* @throw UnauthorizedHttpException if the user is not authorized
@@ -615,9 +614,6 @@ class ExportManager
return $usedTypes;
}
/**
* @param mixed $data the data from the filter key of the ExportType
*/
private function retrieveUsedFilters(mixed $data): iterable
{
if (null === $data) {
@@ -634,8 +630,6 @@ class ExportManager
/**
* Retrieve the filter used in this export.
*
* @param mixed $data the data from the `filters` key of the ExportType
*
* @return array an array with types
*/
private function retrieveUsedFiltersType(mixed $data): iterable

View File

@@ -35,6 +35,7 @@ class ChillCollectionType extends AbstractType
$view->vars['allow_add'] = (int) $options['allow_add'];
$view->vars['identifier'] = $options['identifier'];
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
$view->vars['js_caller'] = $options['js_caller'];
}
public function configureOptions(OptionsResolver $resolver)
@@ -45,6 +46,8 @@ class ChillCollectionType extends AbstractType
'button_remove_label' => 'Remove entry',
'identifier' => '',
'empty_collection_explain' => '',
'js_caller' => 'data-collection-regular',
'delete_empty' => true,
]);
}

View File

@@ -76,6 +76,24 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
->formatOutOfCountryCallingNumber($phoneNumber, $this->config['default_carrier_code']);
}
/**
* @throws NumberParseException
*/
public function parse(string $phoneNumber): PhoneNumber
{
$sanitizedPhoneNumber = $phoneNumber;
if (str_starts_with($sanitizedPhoneNumber, '00')) {
$sanitizedPhoneNumber = '+'.substr($sanitizedPhoneNumber, 2, null);
}
if (!str_starts_with($sanitizedPhoneNumber, '+') && !str_starts_with($sanitizedPhoneNumber, '0')) {
$sanitizedPhoneNumber = '+'.$sanitizedPhoneNumber;
}
return $this->phoneNumberUtil->parse($sanitizedPhoneNumber, $this->config['default_carrier_code']);
}
/**
* Get type (mobile, landline, ...) for phone number.
*/

View File

@@ -41,8 +41,6 @@ require('./img/logo-chill-outil-accompagnement_white.png');
* Some libs are only used in a few pages, they are loaded on a case by case basis
*/
require('../lib/collection/index.js');
require('../lib/breadcrumb/index.js');
require('../lib/download-report/index.js');
require('../lib/select_interactive_loading/index.js');

View File

@@ -1,120 +0,0 @@
/**
* Javascript file which handle ChillCollectionType
*
* Two events are emitted by this module, both on window and on collection / ul.
*
* Collection (an UL element) and entry (a li element) are associated with those
* events.
*
* ```
* window.addEventListener('collection-add-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
*
* window.addEventListener('collection-remove-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
*
* collection.addEventListener('collection-add-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
*
* collection.addEventListener('collection-remove-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
* ```
*/
require('./collection.scss');
class CollectionEvent {
constructor(collection, entry) {
this.collection = collection;
this.entry = entry;
}
}
/**
*
* @param {type} button
* @returns {handleAdd}
*/
var handleAdd = function(button) {
var
form_name = button.dataset.collectionAddTarget,
prototype = button.dataset.formPrototype,
collection = document.querySelector('ul[data-collection-name="'+form_name+'"]'),
empty_explain = collection.querySelector('li[data-collection-empty-explain]'),
entry = document.createElement('li'),
event = new CustomEvent('collection-add-entry', { detail: { collection: collection, entry: entry } }),
counter = collection.childNodes.length + parseInt(Math.random() * 1000000)
content
;
content = prototype.replace(new RegExp('__name__', 'g'), counter);
entry.innerHTML = content;
entry.classList.add('entry');
initializeRemove(collection, entry);
if (empty_explain !== null) {
empty_explain.remove();
}
collection.appendChild(entry);
collection.dispatchEvent(event);
window.dispatchEvent(event);
};
var initializeRemove = function(collection, entry) {
var
button = document.createElement('button'),
isPersisted = entry.dataset.collectionIsPersisted,
content = collection.dataset.collectionButtonRemoveLabel,
allowDelete = collection.dataset.collectionAllowDelete,
event = new CustomEvent('collection-remove-entry', { detail: { collection: collection, entry: entry } })
;
if (allowDelete === '0' && isPersisted === '1') {
return;
}
button.classList.add('btn', 'btn-delete', 'remove-entry');
button.textContent = content;
button.addEventListener('click', function(e) {
e.preventDefault();
entry.remove();
collection.dispatchEvent(event);
window.dispatchEvent(event);
});
entry.appendChild(button);
};
window.addEventListener('load', function() {
var
addButtons = document.querySelectorAll("button[data-collection-add-target]"),
collections = document.querySelectorAll("ul[data-collection-name]")
;
for (let i = 0; i < addButtons.length; i ++) {
let addButton = addButtons[i];
addButton.addEventListener('click', function(e) {
e.preventDefault();
handleAdd(e.target);
});
}
for (let i = 0; i < collections.length; i ++) {
let entries = collections[i].querySelectorAll(':scope > li');
for (let j = 0; j < entries.length; j ++) {
console.log(entries[j].dataset);
if (entries[j].dataset.collectionEmptyExplain === "1") {
continue;
}
initializeRemove(collections[i], entries[j]);
}
}
});

View File

@@ -0,0 +1,128 @@
/**
* Javascript file which handle ChillCollectionType
*
* Two events are emitted by this module, both on window and on collection / ul.
*
* Collection (an UL element) and entry (a li element) are associated with those
* events.
*
* ```
* window.addEventListener('collection-add-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
*
* window.addEventListener('collection-remove-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
*
* collection.addEventListener('collection-add-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
*
* collection.addEventListener('collection-remove-entry', function(e) {
* console.log(e.detail.collection);
* console.log(e.detail.entry);
* });
* ```
*/
import './collection.scss';
export class CollectionEventPayload {
collection: HTMLUListElement;
entry: HTMLLIElement;
constructor(collection: HTMLUListElement, entry: HTMLLIElement) {
this.collection = collection;
this.entry = entry;
}
}
export const handleAdd = (button: any): void => {
let
form_name = button.dataset.collectionAddTarget,
prototype = button.dataset.formPrototype,
collection: HTMLUListElement | null = document.querySelector('ul[data-collection-name="' + form_name + '"]');
if (collection === null) {
return;
}
let
empty_explain: HTMLLIElement | null = collection.querySelector('li[data-collection-empty-explain]'),
entry = document.createElement('li'),
counter = collection.childNodes.length + 1,
content = prototype.replace(new RegExp('__name__', 'g'), counter.toString()),
event = new CustomEvent('collection-add-entry', {detail: new CollectionEventPayload(collection, entry)});
entry.innerHTML = content;
entry.classList.add('entry');
if ("dataCollectionRegular" in collection.dataset) {
initializeRemove(collection, entry);
if (empty_explain !== null) {
empty_explain.remove();
}
}
collection.appendChild(entry);
collection.dispatchEvent(event);
window.dispatchEvent(event);
};
const initializeRemove = (collection: HTMLUListElement, entry: HTMLLIElement): void => {
const button = buildRemoveButton(collection, entry);
if (null === button) {
return;
}
entry.appendChild(button);
};
export const buildRemoveButton = (collection: HTMLUListElement, entry: HTMLLIElement): HTMLButtonElement|null => {
let
button = document.createElement('button'),
isPersisted = entry.dataset.collectionIsPersisted || '',
content = collection.dataset.collectionButtonRemoveLabel || '',
allowDelete = collection.dataset.collectionAllowDelete || '',
event = new CustomEvent('collection-remove-entry', {detail: new CollectionEventPayload(collection, entry)});
if (allowDelete === '0' && isPersisted === '1') {
return null;
}
button.classList.add('btn', 'btn-delete', 'remove-entry');
button.textContent = content;
button.addEventListener('click', (e: Event) => {
e.preventDefault();
entry.remove();
collection.dispatchEvent(event);
window.dispatchEvent(event);
});
return button;
}
window.addEventListener('load', () => {
let
addButtons: NodeListOf<HTMLButtonElement> = document.querySelectorAll("button[data-collection-add-target]"),
collections: NodeListOf<HTMLUListElement> = document.querySelectorAll("ul[data-collection-regular]");
for (let i = 0; i < addButtons.length; i++) {
let addButton = addButtons[i];
addButton.addEventListener('click', (e: Event) => {
e.preventDefault();
handleAdd(e.target);
});
}
for (let i = 0; i < collections.length; i++) {
let entries: NodeListOf<HTMLLIElement> = collections[i].querySelectorAll(':scope > li');
for (let j = 0; j < entries.length; j++) {
if (entries[j].dataset.collectionEmptyExplain === "1") {
continue;
}
initializeRemove(collections[i], entries[j]);
}
}
});

View File

@@ -1,164 +1,175 @@
export interface DateTime {
datetime: string;
datetime8601: string
datetime: string;
datetime8601: string;
}
export interface Civility {
id: number;
// TODO
id: number;
// TODO
}
export interface Job {
id: number;
type: "user_job";
label: {
"fr": string; // could have other key. How to do that in ts ?
}
id: number;
type: "user_job";
label: {
fr: string; // could have other key. How to do that in ts ?
};
}
export interface Center {
id: number;
type: "center";
name: string;
id: number;
type: "center";
name: string;
}
export interface Scope {
id: number;
type: "scope";
name: {
"fr": string
}
id: number;
type: "scope";
name: {
fr: string;
};
}
export interface User {
type: "user";
id: number;
username: string;
text: string;
text_without_absence: string;
email: string;
user_job: Job;
label: string;
// todo: mainCenter; mainJob; etc..
type: "user";
id: number;
username: string;
text: string;
text_without_absence: string;
email: string;
user_job: Job;
label: string;
// todo: mainCenter; mainJob; etc..
}
export interface UserGroup {
type: "chill_main_user_group" | "user_group";
id: number;
label: TranslatableString;
backgroundColor: string;
foregroundColor: string;
excludeKey: string;
}
export type UserGroupOrUser = User | UserGroup;
export interface UserAssociatedInterface {
type: "user";
id: number;
};
export type TranslatableString = {
fr?: string;
nl?: string;
type: "user";
id: number;
}
export type TranslatableString = {
fr?: string;
nl?: string;
};
export interface Postcode {
id: number;
name: string;
code: string;
center: Point;
id: number;
name: string;
code: string;
center: Point;
}
export type Point = {
type: "Point";
coordinates: [lat: number, lon: number];
}
type: "Point";
coordinates: [lat: number, lon: number];
};
export interface Country {
id: number;
name: TranslatableString;
code: string;
id: number;
name: TranslatableString;
code: string;
}
export type AddressRefStatus = 'match'|'to_review'|'reviewed';
export type AddressRefStatus = "match" | "to_review" | "reviewed";
export interface Address {
type: "address";
address_id: number;
text: string;
street: string;
streetNumber: string;
postcode: Postcode;
country: Country;
floor: string | null;
corridor: string | null;
steps: string | null;
flat: string | null;
buildingName: string | null;
distribution: string | null;
extra: string | null;
confidential: boolean;
lines: string[];
addressReference: AddressReference | null;
validFrom: DateTime;
validTo: DateTime | null;
point: Point | null;
refStatus: AddressRefStatus;
isNoAddress: boolean;
type: "address";
address_id: number;
text: string;
street: string;
streetNumber: string;
postcode: Postcode;
country: Country;
floor: string | null;
corridor: string | null;
steps: string | null;
flat: string | null;
buildingName: string | null;
distribution: string | null;
extra: string | null;
confidential: boolean;
lines: string[];
addressReference: AddressReference | null;
validFrom: DateTime;
validTo: DateTime | null;
point: Point | null;
refStatus: AddressRefStatus;
isNoAddress: boolean;
}
export interface AddressWithPoint extends Address {
point: Point
point: Point;
}
export interface AddressReference {
id: number;
createdAt: DateTime | null;
deletedAt: DateTime | null;
municipalityCode: string;
point: Point;
postcode: Postcode;
refId: string;
source: string;
street: string;
streetNumber: string;
updatedAt: DateTime | null;
id: number;
createdAt: DateTime | null;
deletedAt: DateTime | null;
municipalityCode: string;
point: Point;
postcode: Postcode;
refId: string;
source: string;
street: string;
streetNumber: string;
updatedAt: DateTime | null;
}
export interface SimpleGeographicalUnit {
id: number;
layerId: number;
unitName: string;
unitRefId: string;
id: number;
layerId: number;
unitName: string;
unitRefId: string;
}
export interface GeographicalUnitLayer {
id: number;
name: TranslatableString;
refId: string;
id: number;
name: TranslatableString;
refId: string;
}
export interface Location {
type: "location";
id: number;
active: boolean;
address: Address | null;
availableForUsers: boolean;
createdAt: DateTime | null;
createdBy: User | null;
updatedAt: DateTime | null;
updatedBy: User | null;
email: string | null
name: string;
phonenumber1: string | null;
phonenumber2: string | null;
locationType: LocationType;
type: "location";
id: number;
active: boolean;
address: Address | null;
availableForUsers: boolean;
createdAt: DateTime | null;
createdBy: User | null;
updatedAt: DateTime | null;
updatedBy: User | null;
email: string | null;
name: string;
phonenumber1: string | null;
phonenumber2: string | null;
locationType: LocationType;
}
export interface LocationAssociated {
type: "location";
id: number;
type: "location";
id: number;
}
export interface LocationType {
type: "location-type";
id: number;
active: boolean;
addressRequired: "optional" | "required";
availableForUsers: boolean;
editableByUsers: boolean;
contactData: "optional" | "required";
title: TranslatableString;
type: "location-type";
id: number;
active: boolean;
addressRequired: "optional" | "required";
availableForUsers: boolean;
editableByUsers: boolean;
contactData: "optional" | "required";
title: TranslatableString;
}
export interface NewsItemType {

View File

@@ -1,13 +1,24 @@
<template>
<span class="chill-entity entity-user">
{{ user.label }}
<span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> <span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span> <span v-if="user.isAbsent" class="badge bg-danger rounded-pill" :title="Absent">A</span>
</span>
<span class="chill-entity entity-user">
{{ user.label }}
<span class="user-job" v-if="user.user_job !== null"
>({{ user.user_job.label.fr }})</span
>
<span class="main-scope" v-if="user.main_scope !== null"
>({{ user.main_scope.name.fr }})</span
>
<span
v-if="user.isAbsent"
class="badge bg-danger rounded-pill"
:title="Absent"
>A</span
>
</span>
</template>
<script>
export default {
name: "UserRenderBoxBadge",
props: ['user'],
}
props: ["user"],
};
</script>

View File

@@ -162,6 +162,7 @@
{% block chill_collection_widget %}
<div class="chill-collection">
<ul class="list-entry"
{{ form.vars.js_caller }}="{{ form.vars.js_caller }}"
data-collection-name="{{ form.vars.name|escape('html_attr') }}"
data-collection-identifier="{{ form.vars.identifier|escape('html_attr') }}"
data-collection-button-remove-label="{{ form.vars.button_remove_label|trans|e }}"
@@ -176,7 +177,7 @@
</li>
{% else %}
<li data-collection-empty-explain="1">
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No item')|trans }}</span>
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No entities')|trans }}</span>
</li>
{% endfor %}
</ul>

View File

@@ -14,6 +14,7 @@
window.addaddress = {{ add_address|json_encode|raw }};
</script>
{{ encore_entry_link_tags('mod_collection') }}
{{ encore_entry_link_tags('mod_bootstrap') }}
{{ encore_entry_link_tags('mod_forkawesome') }}
{{ encore_entry_link_tags('mod_ckeditor5') }}
@@ -68,35 +69,37 @@
</div>
{% endif %}
{% block content %}
<div class="col-8 main_search">
{% if app.user.isAbsent %}
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}
<h2>{{ 'Search'|trans }}</h2>
{% block wrapping_content %}
{% block content %}
<div class="col-8 main_search">
{% if app.user.isAbsent %}
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}
<h2>{{ 'Search'|trans }}</h2>
<form action="{{ path('chill_main_search') }}" method="get">
<input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" />
<div class="text-center">
<button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a>
</div>
</form>
</div>
<form action="{{ path('chill_main_search') }}" method="get">
<input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" />
<div class="text-center">
<button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a>
</div>
</form>
</div>
{# DISABLED {{ chill_widget('homepage', {} ) }} #}
{# DISABLED {{ chill_widget('homepage', {} ) }} #}
{% include '@ChillMain/Homepage/index.html.twig' %}
{% include '@ChillMain/Homepage/index.html.twig' %}
{% endblock %}
{% endblock %}
</div>
@@ -107,6 +110,7 @@
{{ include('@ChillMain/Layout/_footer.html.twig') }}
{{ encore_entry_script_tags('mod_collection') }}
{{ encore_entry_script_tags('mod_bootstrap') }}
{{ encore_entry_script_tags('mod_forkawesome') }}
{{ encore_entry_script_tags('mod_ckeditor5') }}

View File

@@ -257,10 +257,10 @@ class SearchProvider
$this->mustBeExtracted[] = $matches[0][$key];
// strip parenthesis
if (
'"' === mb_substr((string) $match, 0, 1)
&& '"' === mb_substr((string) $match, mb_strlen((string) $match) - 1)
'"' === mb_substr($match, 0, 1)
&& '"' === mb_substr($match, mb_strlen($match) - 1)
) {
$match = trim(mb_substr((string) $match, 1, mb_strlen((string) $match) - 2));
$match = trim(mb_substr($match, 1, mb_strlen($match) - 2));
}
$terms[$matches[1][$key]] = $match;
}

View File

@@ -198,8 +198,6 @@ class AuthorizationHelper implements AuthorizationHelperInterface
* if the entity implements Chill\MainBundle\Entity\HasScopeInterface,
* the scope is taken into account.
*
* @param mixed $entity the entity may also implement HasScopeInterface
*
* @return bool true if the user has access
*/
public function userHasAccess(User $user, mixed $entity, string $attribute)

View File

@@ -23,7 +23,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
class PostalCodeFRFromOpenData
{
private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/data-files/019HexaSmal.csv';
private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/metadata-attachments/base-officielle-codes-postaux.csv';
public function __construct(private readonly PostalCodeBaseImporter $baseImporter, private readonly HttpClientInterface $client, private readonly LoggerInterface $logger) {}
@@ -48,7 +48,7 @@ class PostalCodeFRFromOpenData
fseek($tmpfile, 0);
$csv = Reader::createFromStream($tmpfile);
$csv->setDelimiter(';');
$csv->setDelimiter(',');
$csv->setHeaderOffset(0);
foreach ($csv as $offset => $record) {
@@ -63,23 +63,23 @@ class PostalCodeFRFromOpenData
private function handleRecord(array $record): void
{
if ('' !== trim($record['coordonnees_geographiques'] ?? $record['coordonnees_gps'])) {
[$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', $record['coordonnees_geographiques'] ?? $record['coordonnees_gps']));
if ('' !== trim((string) $record['_geopoint'])) {
[$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', (string) $record['_geopoint']));
} else {
$lat = $lon = 0.0;
}
$ref = trim((string) $record['Code_commune_INSEE']);
$ref = trim((string) $record['code_commune_insee']);
if (str_starts_with($ref, '987')) {
// some differences in French Polynesia
$ref .= '.'.trim((string) $record['Libellé_d_acheminement']);
$ref .= '.'.trim((string) $record['libelle_d_acheminement']);
}
$this->baseImporter->importCode(
'FR',
trim((string) $record['Libellé_d_acheminement']),
trim((string) $record['Code_postal']),
trim((string) $record['libelle_d_acheminement']),
trim((string) $record['code_postal']),
$ref,
'INSEE',
$lat,

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Phonenumber;
use Chill\MainBundle\Phonenumber\PhonenumberHelper;
use libphonenumber\PhoneNumber;
use libphonenumber\PhoneNumberUtil;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -52,12 +53,36 @@ final class PhonenumberHelperTest extends KernelTestCase
];
}
public static function providePhoneNumbersToParse(): iterable
{
$util = PhoneNumberUtil::getInstance();
yield [
'FR',
'+32486544999',
$util->parse('+32486544999', 'FR'),
];
yield [
'FR',
'32486544999',
$util->parse('+32486544999', 'FR'),
];
yield [
'FR',
'0228858040',
$util->parse('+33228858040', 'FR'),
];
}
/**
* @dataProvider formatPhonenumbers
*/
public function testFormatPhonenumbers(string $defaultCarrierCode, string $phoneNumber, string $expected)
{
$util = PhoneNumberUtil::getInstance();
$subject = new PhonenumberHelper(
new ArrayAdapter(),
new ParameterBag([
@@ -70,4 +95,24 @@ final class PhonenumberHelperTest extends KernelTestCase
$this->assertEquals($expected, $subject->format($util->parse($phoneNumber)));
}
/**
* @dataProvider providePhoneNumbersToParse
*/
public function testParsePhonenumbers(string $defaultCarrierCode, string $phoneNumber, PhoneNumber $expected): void
{
$subject = new PhonenumberHelper(
new ArrayAdapter(),
new ParameterBag([
'chill_main.phone_helper' => [
'default_carrier_code' => $defaultCarrierCode,
],
]),
new NullLogger()
);
$actual = $subject->parse($phoneNumber);
self::assertTrue($expected->equals($actual));
}
}

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