Compare commits

...

202 Commits

Author SHA1 Message Date
702a5a27d2 Update version of bundles to 2.22.2 2024-07-03 12:22:53 +02:00
nobohan
41dd4d89f7 Revert "#271 Account for acp closing date inn action filters (export)"
This reverts commit 3a7ed7ef8f.
2024-07-02 16:24:45 +02:00
nobohan
3a7ed7ef8f #271 Account for acp closing date inn action filters (export) 2024-07-02 16:22:07 +02:00
18df08e8c3 Do not require scope for event participation stats 2024-07-01 11:14:02 +02:00
db3961275b git release 2.22.1 2024-07-01 09:53:41 +02:00
cd488d7576 Merge branch 'master' of gitlab.com:Chill-Projet/chill-bundles 2024-07-01 09:19:29 +02:00
436661d952 Remove debug word from code 2024-07-01 09:19:22 +02:00
c3b8d42047 Merge branch 'import_lux_addresses_command' into 'master'
DX import Luxembourg address command

See merge request Chill-Projet/chill-bundles!704
2024-06-28 09:07:32 +00:00
nobohan
9c28df25a1 DX: Improve Lux adress import command + register in config 2024-06-28 10:45:58 +02:00
nobohan
c5a24e8ac5 DX: Improve Lux address import command - WIP 2024-06-28 09:27:45 +02:00
nobohan
d9c50cffb7 DX import Luxembourg address command - phpstan 2024-06-27 10:34:41 +02:00
nobohan
25ccb16308 DX import Luxembourg address command - csfixer 2024-06-27 10:17:08 +02:00
nobohan
ba25c181f5 DX import Luxembourg address command 2024-06-27 10:07:24 +02:00
145419a76b CHANGELOG entry added for exports in event bundle 2024-06-25 13:29:24 +02:00
b1ba5cc608 Merge branch '216-exports-event-bundle' into 'master'
Add eventBundle exports

Closes #216

See merge request Chill-Projet/chill-bundles!688
2024-06-25 11:24:57 +00:00
87a6757e5e Add eventBundle exports 2024-06-25 11:24:57 +00:00
47f4cfddbb Prepare for 2.21.0 2024-06-18 10:16:39 +02:00
e95f9e9846 Merge branch '282-add-dates-job-filter' into 'master'
Add dates ranges to job and scope filter and aggregator (accompanying course's exports)

Closes #282

See merge request Chill-Projet/chill-bundles!699
2024-06-18 07:54:49 +00:00
1f4bef754d Refactor callback functions to arrow functions
The callback functions used in the addViewTransformer method in FilterType.php and AggregatorType.php were replaced with shorter arrow functions. This change was made to increase code readability and encourage consistency throughout the codebase.
2024-06-18 09:35:57 +02:00
19e34d5dc0 PHP CS Fixer updated (3.57.2 -> v3.59.3) 2024-06-17 17:28:29 +02:00
fab00f679c Add date range to UserJobAggregator
This update includes adding start_date and end_date to UserJobAggregator. This addition allows the selection of a date range in the export feature. Accompanying this change are associated translations and tests.
2024-06-17 17:16:02 +02:00
791b3776c5 Add date range filter to referrer scope aggregator
A date range filter was added to the 'ReferrerScopeAggregator' class. This new feature allows users to filter courses by their referrer's scope based on a specified date range. In addition, relevant unit tests and translations were updated to support this new functionality.
2024-06-17 17:15:53 +02:00
6bd38f1a58 Refactor assertions in AbstractAggregatorTest
The conditional checks in the AbstractAggregatorTest have been simplified. Instead of a complex inline condition with multiple checks, the test now uses straightforward assertions. This makes the code cleaner and easier to understand.
2024-06-17 15:31:33 +02:00
68d21c9267 Update ReferrerAggregator to specify a date range as parameter
The ReferrerAggregator in ChillPersonBundle has been updated to include start and end dates, replacing the previous single computation date. This provides greater flexibility in setting the timeframe for referrer data. The messages.fr.yml file has also been updated to reflect these changes. Relevant tests have been updated to match the new functionality.
2024-06-17 15:22:28 +02:00
e7ca89e0c1 Rename DataTransformerFilterInterface to DataTransformerInterface
The DataTransformerFilterInterface has been renamed to DataTransformerInterface to reflect expanded functionality. Now, this interface can be implemented not only by @see{FilterInterface}, but also by @see{AggregatorInterface}. This change allows transforming existing data in saved exports and replacing it with some default values, or new default values.
2024-06-17 15:20:54 +02:00
fc8bc33ba9 Add startDate and endDate on UserScopeFilter 2024-06-17 14:31:12 +02:00
cbd9489810 Add startDate and endDate on UserJobFilter 2024-06-17 14:10:32 +02:00
90b615c5b2 Add data transformation interface for filters
Introduced a new DataTransformerFilterInterface that allows transforming filter's form data before it is processed. Updated the FilterType file to add a view transformer if the filter implements this new interface. This new transformation process caters to transforming existing data in saved exports and replacing it with default values.
2024-06-14 14:38:10 +02:00
5ca222b501 Merge branch '122-improve-list-rendez-vous' into 'master'
Update calendar list display for the the next calendar in search results

Closes #122

See merge request Chill-Projet/chill-bundles!700
2024-06-13 16:12:57 +00:00
3e4495dd6e Refactor AccompanyingPeriod::getNextCalendarForPerson to enhance performance 2024-06-13 18:07:19 +02:00
bca0d04201 Update calendar list display for the the next calendar in search results
The calendar list display in ChillPersonBundle has been revamped, including a new view and style modifications. This update enables the display of calendars as a list for easy navigation with an added authorization check. Also, a new SCSS file named "calendar-list.scss" has been created and imported to enhance the UI/UX design.
2024-06-13 18:07:19 +02:00
f66ac50571 Merge branch '616_rapid-action' into 'master'
Flash menu rapid action in search results

See merge request Chill-Projet/chill-bundles!441
2024-06-13 10:32:30 +00:00
b454774836 add changie [ci-skip] 2024-06-13 12:21:19 +02:00
008f344e49 Update calendar and activity voters in security checks
This commit adjusts the conditions in CalendarVoter and ActivityVoter security checks. Now it takes into account both STEP_DRAFT and STEP_CLOSED statuses in determining permissions. This enhancement ensures tighter control over specific actions in these two scenarios, enhancing the overall application security.
2024-06-13 12:17:14 +02:00
90bfd87ec6 Implement security checks for menu options
The changes in this commit add security checks before displaying menu options for creating new objects on Accompanying Period.
2024-06-13 12:08:24 +02:00
cc0030c1cd Fix adding quick menus to list_with_period.html.twig
- update twig namespaces
- move twig file within Resources
2024-06-13 12:07:34 +02:00
d60ba3ecb2 Merge remote-tracking branch 'origin/master' into 616_rapid-action 2024-06-12 16:45:43 +02:00
cd5001ac74 Merge branch 'issue178_affichage_metiers' into 'master'
Display users and jobs at the date that they executed some task

See merge request Chill-Projet/chill-bundles!641
2024-06-12 14:41:40 +00:00
98f47ac512 fixes for normalising accompanying periods in docgen context 2024-06-12 16:35:53 +02:00
31b541d12f Update AccompanyingPeriodWorkNormalizer and related classes
Updated the AccompanyingPeriodWorkNormalizer, its test, and the related entity class. Now, the normalizer includes additional checks for different formats and conditions, and cleans the context accordingly before processing. AccompanyingPeriodWorkDocGenNormalizerTest now extends from a new abstract base class. Changes are made in AccompanyingPeriodWork entity for datetime handling and serialization.
2024-06-12 11:47:13 +02:00
72045ce082 Add DocGenNormalizerTestAbstract class
A new class, DocGenNormalizerTestAbstract, was added to the ChillDocGeneratorBundle. This abstract class tests the normalization of null values and ensures they comply with expected formats and behaviors. It implements key methods that allow for providing non-null objects, expectation setting, and normalization.
2024-06-12 11:46:49 +02:00
0bfb3de465 fix cs 2024-06-11 16:58:33 +02:00
9ec4c77fb7 systematically at a parameter 'at_date' when displaying createdBy in twig template
The rendering of the 'createdBy' entity has been updated across various .twig files to include the 'at_date' property. This makes the date an entity was created more explicit, providing clearer information to the user.
2024-06-11 16:55:15 +02:00
77c53972c8 Add DateTimeImmutable support in UserNormalizer
This commit introduces the use of DateTimeImmutable in the UserNormalizer class to ensure immutability of datetime objects. A check is also added to convert DateTime instances to DateTimeImmutable when normalizing data. This enhances the safety and predictability of datetimes used in the application.
2024-06-11 16:33:54 +02:00
350d991a85 Update AccompanyingPeriod normalization with UserHistory
The AccompanyingPeriod normalization now includes 'createdBy' and 'ref' fields from the UserHistory. AccompanyingPeriodDocGenNormalizer has been modified adding UserHistory retrieval and subsequently normalization. A new method was also added to the AccompanyingPeriod entity to retrieve the current UserHistory.
2024-06-11 16:33:37 +02:00
0ce9cdd07a Add label to main user selection in Calendar App
A new attribute `label` has been added to the `pick-entity` component in the Chill Calendar Bundle's Vue.js App. This label, set as 'Utilisateur principal', enhances user interaction and clarity in the main user selection process.
2024-06-11 09:39:51 +02:00
1993fac1c4 Update button rendering in AddPersons.vue
This commit modifies the button rendering in AddPersons.vue component to ensure that it doesn't crash if 'buttonTitle' is undefined. It does so by providing an empty string as a fallback in case 'buttonTitle' is unavailable, improving the component's stability.
2024-06-11 09:39:32 +02:00
83883567a2 Upgrade node dependencies and add necessary fixes 2024-06-11 09:38:56 +02:00
29d57934a1 Update workflow rendering with date context
Code updates have been made in multiple files to ensure that when entities are rendered, it includes the appropriate context relating to the date. This adjustment has been primarily made in template files where the `chill_entity_render_box` function is used. These changes help to provide users with more accurate information regarding the state of an entity at a specific time.
2024-06-10 17:19:34 +02:00
f43d79c940 Add notification date to entity render strings
The notification date has been added to the render strings of entities involved in the notifications, specifically for the sender, addressees, and normalizer. This is done by passing it as a parameter to the 'chill_entity_render_string' function and the 'normalize' function in NotificationNormalizer. This will help provide more context regarding the time of the events in the notification.
2024-06-10 17:08:30 +02:00
be730679c8 Update rendering of user information in AccompanyingCourse/Comment: show user at the comment's date 2024-06-10 15:23:12 +02:00
f62f1891d8 Fix displaying calendar: reproduce same getAtDate method as Activity 2024-06-07 13:07:25 +02:00
ebb856fe85 fix rendering of accompanying course commen with at_date 2024-06-07 13:06:46 +02:00
61877e0157 Refactor UserRenderTest and remove unused methods
The UserRenderTest class has been refactored significantly. Redundant methods related to the booting kernel of Symfony have been removed. The approach of mocking objects has been changed, swapping from traditional mocking to prophecy mocking.
2024-06-07 13:06:11 +02:00
4c3f082163 Merge remote-tracking branch 'origin/master' into issue178_affichage_metiers 2024-06-07 12:03:30 +02:00
35109133f6 Release 2.20.1 2024-06-05 17:08:57 +02:00
a220dad83b Do not pass StoredObjectCreated on Convert and Edit buttons 2024-06-05 17:08:30 +02:00
9eb571549b Prepare for release 2.20.0 2024-06-05 16:21:11 +02:00
db8257d230 Merge branch '170-export-action-referrer' into 'master'
Resolve "Dans la liste des évaluations et la liste des actions, il n'y a pas le nom des référents de l'action"

Closes #170

See merge request Chill-Projet/chill-bundles!695
2024-06-05 14:08:05 +00:00
bce93efe83 Resolve "Dans la liste des évaluations et la liste des actions, il n'y a pas le nom des référents de l'action" 2024-06-05 14:08:05 +00:00
06401af801 Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into 'master'
Add history to storedObject, instead of creating new stored object instances

Closes #145

See merge request Chill-Projet/chill-bundles!698
2024-06-04 20:37:36 +00:00
ea1d4c48f2 Add history support to StoredObject entity
This commit adds a history saving feature to the StoredObject entity, which allows saving versions of the object's changes over time. This is achieved by implementing a saveHistory method that captures data attributes like filename, IV, key information, and type. The corresponding Automated tests were also created. Furthermore, adjustments were made to the StoredObject test to align with the new feature.
2024-06-04 22:31:50 +02:00
nobohan
33cba27dd4 Translations: Added translations for choices of durations (> 5 hours) 2024-06-04 21:24: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
20291026fb release 2.19.0 2024-05-14 16:05:51 +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
a7ec7c9f37 fix pipeline for branch affichage metiers 2024-05-07 16:32:12 +02: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
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
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
c9e13be736 pipeline fixes for phpstan, cs-fixer, rector 2024-04-16 20:16:45 +02:00
b9b342fe44 Set isActive property for userjob 2024-04-16 20:09:00 +02:00
31f29f0bc5 Resolve merge conflicts 2024-04-16 12:43:07 +02:00
0bc9fff825 test still failing with error saying column phonenumber of relation users does not exist 2024-04-16 12:01:40 +02:00
25f93e8a89 fix typing 2024-04-16 12:01:40 +02:00
4e0d8e4def fix userNormalizerTest by adding clock in the construct of UserNormalizer 2024-04-16 12:01:40 +02:00
1ecc825945 Correct 2 phpstan errors, condition is always true and wrong typing 2024-04-16 12:01:40 +02:00
addc623add php style fixer 2024-04-16 12:01:37 +02:00
1b96deb4ee try to fix userRenderTest: mock not working 2024-04-16 11:55:54 +02:00
f510acd170 add back the annotation to edit accompanying period work for referrers 2024-04-16 11:55:54 +02:00
835409cb94 work on userRenderTest 2024-04-16 11:55:54 +02:00
2121b3ef28 Add at_date to userRender for rendering the text 2024-04-16 11:55:54 +02:00
6c9101c167 Adapt the rendering of user in accompanyingPeriodWork list and item templates 2024-04-16 11:55:54 +02:00
b46883fe36 Implement clockInterface in renderString method 2024-04-16 11:55:54 +02:00
8d58805abd work on user render test 2024-04-16 11:55:54 +02:00
c3a799cb7d work on test logic 2024-04-16 11:55:54 +02:00
bc683b28d6 update normalizers to take into account referrerHistory logic for accompanying period work 2024-04-16 11:55:50 +02:00
d91b1a70bf work on userRender test 2024-04-16 11:52:58 +02:00
853014d8d2 remove attempt to adjust accompanyingperiod work for display of user job and service at specific date 2024-04-16 11:52:58 +02:00
ad6154a1e4 Implement 'at date' for concerned groups in activity 2024-04-16 11:52:58 +02:00
50c04382ef Adjust view template for aside activity 2024-04-16 11:52:58 +02:00
d62e9ce269 Implement 'at date' for display of service and user job in accompanying period work entities (for twig templates) -> vue component still to fix 2024-04-16 11:52:58 +02:00
2149ef1cb4 Implement 'at date' for display of service and user job in aside activities entities 2024-04-16 11:52:58 +02:00
d15fbadd27 Implement 'at date' for display of service and user job in calendar entities 2024-04-16 11:52:58 +02:00
fbbf421d8b Handle DateTime type for activity while DateTimeImmutable is expected 2024-04-16 11:52:58 +02:00
fe695f1a14 Implement 'at date' in user render component for activities 2024-04-16 11:52:58 +02:00
d0ec6f9819 Improve naming for 'at date' in user render component 2024-04-16 11:52:54 +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
8fd6986c47 Release 2.18.1 2024-03-26 22:10:18 +01:00
807f1b4aa1 Fix layout in admin document generation
A layout issue in the admin document generation has been fixed, particularly in the ChillDocGeneratorBundle. Unnecessary elements such as table headers and multiple entity data rows in DocGeneratorTemplate have been removed, simplifying the view page and improving its performance.
2024-03-26 22:10:01 +01:00
f3002631ea Release 2.18.0 2024-03-26 18:10:18 +01:00
9e667d4de4 Merge branch '268-improve-ux-when-configuring-documents' into 'master'
Improve admin UX for configuration of document template (document generation)

Closes #268

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

Closes #267

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

Closes #238

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

Closes #258

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

Closes #259

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

Closes #264

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

Closes #237

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

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

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

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

Closes #232

See merge request Chill-Projet/chill-bundles!662
2024-02-26 13:51:25 +00:00
5b714f17be order scopes alphabetically 2024-02-26 14:40:41 +01:00
bbb167bb85 order user jobs alphabetically when returning all active user jobs 2024-02-26 13:36:44 +01:00
d713087dcb Changie and php style fixes 2024-02-26 13:30:26 +01:00
569aeeef87 Fix wrong translation of user job service -> métier 2024-02-26 12:23:11 +01:00
97f2c75de8 Change syntax of check on null for closing motive 2024-02-21 20:14:18 +01:00
4a2078dc65 upgrade to 2.16.2 2024-02-21 19:49:43 +01:00
00444e1e56 Add check on null value in template for closing motive 2024-02-20 10:10:44 +01:00
0b739fda34 test still failing with error saying column phonenumber of relation users does not exist 2024-02-12 18:56:05 +01:00
9b8e143855 fix typing 2024-02-12 18:55:00 +01:00
a533ab77ed fix userNormalizerTest by adding clock in the construct of UserNormalizer 2024-02-12 18:44:32 +01:00
087032881b Correct 2 phpstan errors, condition is always true and wrong typing 2024-02-12 14:50:26 +01:00
82667a1c0f php style fixer 2024-02-12 14:37:54 +01:00
db6408926b try to fix userRenderTest: mock not working 2024-02-12 14:36:41 +01:00
f5c7ab6ef0 add back the annotation to edit accompanying period work for referrers 2024-02-12 09:02:48 +01:00
a13ada2937 work on userRenderTest 2024-02-07 07:19:26 +01:00
3be8a39a1a Add at_date to userRender for rendering the text 2024-01-30 17:03:24 +01:00
d7eb1e01da Adapt the rendering of user in accompanyingPeriodWork list and item templates 2024-01-30 17:01:45 +01:00
bd62202d22 Implement clockInterface in renderString method 2024-01-30 16:31:29 +01:00
0e3de2ec8a work on user render test 2024-01-29 15:07:27 +01:00
aa2a398f9e work on test logic 2024-01-24 19:31:04 +01:00
33187448a0 update normalizers to take into account referrerHistory logic for accompanying period work 2024-01-24 19:30:50 +01:00
a4482ad28b work on userRender test 2024-01-23 18:13:33 +01:00
8ed5a023e8 remove attempt to adjust accompanyingperiod work for display of user job and service at specific date 2024-01-17 17:27:54 +01:00
653ac1d62b Implement 'at date' for concerned groups in activity 2024-01-08 16:51:06 +01:00
499009ac43 Adjust view template for aside activity 2024-01-08 16:38:50 +01:00
192b161e78 Implement 'at date' for display of service and user job in accompanying period work entities (for twig templates) -> vue component still to fix 2024-01-08 16:38:07 +01:00
1b1f355123 Implement 'at date' for display of service and user job in aside activities entities 2024-01-08 16:37:25 +01:00
39a863448c Implement 'at date' for display of service and user job in calendar entities 2024-01-08 12:35:41 +01:00
0c1a4a5f59 Handle DateTime type for activity while DateTimeImmutable is expected 2024-01-08 12:35:09 +01:00
6f358ee1a9 Implement 'at date' in user render component for activities 2024-01-08 11:25:33 +01:00
0f36b9349b Improve naming for 'at date' in user render component 2024-01-08 11:25:13 +01:00
d18cc29acf Revert "UX: [address details] improve button integration"
This reverts commit 89fb87f71f.
2023-07-12 17:45:06 +02:00
4220d1a2d3 Revert "UX: [vue][onTheFly] improve residential address position in modale"
This reverts commit 62d6106801.
2023-07-12 17:44:47 +02:00
1ae27152c2 Merge branch 'master' into 616_rapid-action 2023-07-12 15:38:51 +02:00
b946f8c10a Merge branch 'master' into 616_rapid-action 2023-05-24 19:56:24 +02:00
62d6106801 UX: [vue][onTheFly] improve residential address position in modale 2023-05-24 19:55:17 +02:00
89fb87f71f UX: [address details] improve button integration 2023-05-24 19:54:13 +02:00
1337360690 Fixed [search page] quickMenu: improve position and design, manage duplicate buttons 2023-05-24 18:25:35 +02:00
9324c33caf Merge branch 'master' into 616_rapid-action 2023-05-24 11:21:34 +02:00
c2dd9ef676 wip 2022-07-12 09:37:16 +02:00
a42d7231d9 not display flash menu if menu is empty 2022-07-11 17:23:34 +02:00
38deaf6f36 remove outdated deprecated message 2022-07-11 17:11:27 +02:00
04fc5b6614 flash menu rapid action built with chill_menu() 2022-07-11 17:11:21 +02:00
384b2be577 flash menu rapid action in search results: bootstrap dropdown integration 2022-07-11 12:35:00 +02:00
376 changed files with 10166 additions and 1857 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

21
.changes/v2.20.0.md Normal file
View File

@@ -0,0 +1,21 @@
## v2.20.0 - 2024-06-05
### Fixed
* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions.
* Added translations for choices of durations (> 5 hours)
### Feature
* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security);
This endpoint should be added to make the endpoint works properly:
```yaml
security:
firewalls:
dav:
pattern: ^/dav
provider: chain_provider
stateless: true
guard:
authenticators:
- Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator
```

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

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

31
.changes/v2.21.0.md Normal file
View File

@@ -0,0 +1,31 @@
## v2.21.0 - 2024-06-18
### Feature
* Add flash menu buttons in search results, to open directly a new calendar, or a new activity in an accompanying period
* ([#122](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/122)) Improve the list of calendar in the search results: make all calendar clicable, and display a list of calendars
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add start date and end date on filters "filter course by referrer job" and "filter course by referrer scope"
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] the aggregator "Group by referrer" now accept a date range.
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's scope"
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's jobs"
* ([#168](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/168) In the UX, display user job and service at the time when he performs an action:
now, the job and service is shown:
* at the activity's date,
* at the appointment's date,
* when the user is marked as referrer for an accompanying period work,
* when the user apply a transition in a workflow,
* when the user updates or creates "something" ("created/updated by ... at ..."),
* or when he wrote a comment,
*
### Traduction francophone
* Ajout d'un menu "flash" dans les résultats de recherche, pour créer un rendez-vous ou un échange dans un parcours depuis les résultats de recherche;
* Améliore la liste des rendez-vous dans les résultats de recherche: les rendez-vous sont cliquables;
* [exports] Ajout d'intervalles de dates pour des filtres et regroupements des parcours par référent, métier du référent, service du référent;
* Affiche le métier et le service des utilisateurs à la date à laquelle il a exécuté une action. Le métier et le service est affiché:
* à la date d'un échange,
* au jour d'un rendez-vous,
* quand l'utilisateur est devenu référent d'un parcours d'accompagnement,
* quand il a appliqué une transition sur un workflow,
* quand il a mise à jour ou créé une fiche, dans les mentions "créé / mise à jour par ..., le ...",
* quand il a mis à jour un commentaire,
*

6
.changes/v2.22.0.md Normal file
View File

@@ -0,0 +1,6 @@
## v2.22.0 - 2024-06-25
### Feature
* ([#216](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/216)) [event bundle] exports added for the event module
### Traduction francophone
* Exports sont ajoutés pour la module événement.

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

@@ -0,0 +1,5 @@
## v2.22.1 - 2024-07-01
### Fixed
* Remove debug word
### DX
* Add a command for reading official address DB from Luxembourg and update chill addresses

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

@@ -0,0 +1,3 @@
## v2.22.2 - 2024-07-03
### Fixed
* Remove scope required for event participation stats

View File

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

View File

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

View File

@@ -6,6 +6,136 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.22.2 - 2024-07-03
### Fixed
* Remove scope required for event participation stats
## v2.22.1 - 2024-07-01
### Fixed
* Remove debug word
### DX
* Add a command for reading official address DB from Luxembourg and update chill addresses
## v2.22.0 - 2024-06-25
### Feature
* ([#216](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/216)) [event bundle] exports added for the event module
### Traduction francophone
* Exports sont ajoutés pour la module événement.
## v2.21.0 - 2024-06-18
### Feature
* Add flash menu buttons in search results, to open directly a new calendar, or a new activity in an accompanying period
* ([#122](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/122)) Improve the list of calendar in the search results: make all calendar clicable, and display a list of calendars
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add start date and end date on filters "filter course by referrer job" and "filter course by referrer scope"
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] the aggregator "Group by referrer" now accept a date range.
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's scope"
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's jobs"
* ([#168](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/168) In the UX, display user job and service at the time when he performs an action:
now, the job and service is shown:
* at the activity's date,
* at the appointment's date,
* when the user is marked as referrer for an accompanying period work,
* when the user apply a transition in a workflow,
* when the user updates or creates "something" ("created/updated by ... at ..."),
* or when he wrote a comment,
*
### Traduction francophone
* Ajout d'un menu "flash" dans les résultats de recherche, pour créer un rendez-vous ou un échange dans un parcours depuis les résultats de recherche;
* Améliore la liste des rendez-vous dans les résultats de recherche: les rendez-vous sont cliquables;
* [exports] Ajout d'intervalles de dates pour des filtres et regroupements des parcours par référent, métier du référent, service du référent;
* Affiche le métier et le service des utilisateurs à la date à laquelle il a exécuté une action. Le métier et le service est affiché:
* à la date d'un échange,
* au jour d'un rendez-vous,
* quand l'utilisateur est devenu référent d'un parcours d'accompagnement,
* quand il a appliqué une transition sur un workflow,
* quand il a mise à jour ou créé une fiche, dans les mentions "créé / mise à jour par ..., le ...",
* quand il a mis à jour un commentaire,
*
## v2.20.1 - 2024-06-05
### Fixed
* Do not allow StoredObjectCreated for edit and convert buttons
## v2.20.0 - 2024-06-05
### Fixed
* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions.
* Added translations for choices of durations (> 5 hours)
### Feature
* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security);
This endpoint should be added to make the endpoint works properly:
```yaml
security:
firewalls:
dav:
pattern: ^/dav
provider: chain_provider
stateless: true
guard:
authenticators:
- Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator
```
## v2.19.0 - 2024-05-14
### Feature
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
### Fixed
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
### UX
* Form for document generation moved to the top of document list page
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
### Traduction francophone des principaux changements
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
- correction du filtre "filtrer par zone géographique"
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
- module événement: améliorerations graphiques
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
## v2.18.1 - 2024-03-26
### Fixed
* Fix layout issue in document generation for admin (minor)
## v2.18.0 - 2024-03-26
### Feature
* ([#268](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/268)) Improve admin UX to configure document templates for document generation
### Fixed
* ([#267](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/267)) Fix the join between job and user in the user list (admin): show only the current user job
## v2.17.0 - 2024-03-19
### Feature
* ([#237](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/237)) New export filter for social actions with an evaluation created between two dates
* ([#258](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/258)) In the list of accompangying period, add the list of person's centers and the duration of the course
* ([#238](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/238)) Allow to customize list person with new fields
* ([#159](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/159)) Admin can publish news on the homepage
### Fixed
* ([#264](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/264)) Fix languages: load the languages in all availables languages configured for Chill
* ([#259](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/259)) Keep a consistent behaviour between the filtering of activities within the document generation (model "accompanying period with activities"), and the same filter in the list of activities for an accompanying period
## v2.16.3 - 2024-02-26
### Fixed
* ([#236](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/236)) Fix translation of user job -> 'service' must be 'métier'
### UX
* ([#232](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/232)) Order user jobs and services alphabetically in export filters
## v2.16.2 - 2024-02-21
### Fixed
* Check for null values in closing motive of parcours d'accompagnement for correct rendering of template
## v2.16.1 - 2024-02-09
### Fixed
* Force bootstrap version to avoid error in builds with newer version

View File

@@ -9,6 +9,7 @@
],
"require": {
"php": "^8.2",
"ext-dom": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",
@@ -75,7 +76,7 @@
"phpunit/phpunit": ">= 7.5",
"psalm/plugin-phpunit": "^0.18.4",
"psalm/plugin-symfony": "^4.0.2",
"rector/rector": "^0.17.7",
"rector/rector": "1.1.1",
"symfony/debug-bundle": "^5.1",
"symfony/dotenv": "^4.4",
"symfony/maker-bundle": "^1.20",

View File

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

View File

@@ -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

@@ -42,11 +42,13 @@
"@fullcalendar/vue3": "^6.1.4",
"@popperjs/core": "^2.9.2",
"@types/leaflet": "^1.9.3",
"@types/dompurify": "^3.0.5",
"dropzone": "^5.7.6",
"es6-promise": "^4.2.8",
"leaflet": "^1.7.1",
"marked": "^12.0.2",
"masonry-layout": "^4.2.2",
"mime": "^3.0.0",
"mime": "^4.0.0",
"swagger-ui": "^4.15.5",
"vis-network": "^9.1.0",
"vue": "^3.2.37",

View File

@@ -0,0 +1,6 @@
parameters:
ignoreErrors:
-
message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\<array\\<float\\|int\\|string\\|Stringable\\|null\\>\\>, iterable\\<array\\<string, bool\\|int\\|string\\>\\> given\\.$#"
count: 1
path: src/Bundle/ChillMainBundle/Controller/UserExportController.php

View File

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

View File

@@ -32,9 +32,13 @@ return static function (RectorConfig $rectorConfig): void {
//define sets of rules
$rectorConfig->sets([
LevelSetList::UP_TO_PHP_82,
\Rector\Symfony\Set\SymfonyLevelSetList::UP_TO_SYMFONY_44,
\Rector\Symfony\Set\SymfonySetList::SYMFONY_40,
\Rector\Symfony\Set\SymfonySetList::SYMFONY_41,
\Rector\Symfony\Set\SymfonySetList::SYMFONY_42,
\Rector\Symfony\Set\SymfonySetList::SYMFONY_43,
\Rector\Symfony\Set\SymfonySetList::SYMFONY_44,
\Rector\Doctrine\Set\DoctrineSetList::DOCTRINE_CODE_QUALITY,
\Rector\PHPUnit\Set\PHPUnitLevelSetList::UP_TO_PHPUNIT_90,
\Rector\PHPUnit\Set\PHPUnitSetList::PHPUNIT_90,
]);
// some routes are added twice if it remains activated
@@ -45,9 +49,6 @@ return static function (RectorConfig $rectorConfig): void {
// skip some path...
$rectorConfig->skip([
// we need to discuss this: are we going to have FALSE in tests instead of an error ?
\Rector\Php71\Rector\FuncCall\CountOnNullRector::class,
// we must adapt service definition
\Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class,
\Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class,

View File

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

View File

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

View File

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

View File

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

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\ActivityBundle\Menu;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Security;
final readonly class AccompanyingCourseQuickMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(private Security $security)
{
}
public static function getMenuIds(): array
{
return ['accompanying_course_quick_menu'];
}
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod $accompanyingCourse */
$accompanyingCourse = $parameters['accompanying-course'];
if ($this->security->isGranted(ActivityVoter::CREATE, $accompanyingCourse)) {
$menu
->addChild('Create a new activity in accompanying course', [
'route' => 'chill_activity_activity_new',
'routeParameters' => [
// 'activityType_id' => '',
'accompanying_period_id' => $accompanyingCourse->getId(),
],
])
->setExtras([
'order' => 10,
'icon' => 'plus',
])
;
}
}
}

View File

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

View File

@@ -68,7 +68,7 @@
<div class="wl-col title"><h3>{{ 'Referrer'|trans }}</h3></div>
<div class="wl-col list">
<p class="wl-item">
<span class="badge-user">{{ activity.user|chill_entity_render_box }}</span>
<span class="badge-user">{{ activity.user|chill_entity_render_box({'at_date': activity.date}) }}</span>
</p>
</div>
</div>

View File

@@ -87,7 +87,7 @@
<li>
{% if bloc.type == 'user' %}
<span class="badge-user">
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
</span>
{% else %}
{{ _self.insert_onthefly(bloc.type, item) }}
@@ -114,7 +114,7 @@
<li>
{% if bloc.type == 'user' %}
<span class="badge-user">
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
</span>
{% else %}
{{ _self.insert_onthefly(bloc.type, item) }}
@@ -142,7 +142,7 @@
<span class="wl-item">
{% if bloc.type == 'user' %}
<span class="badge-user">
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
{%- if context == 'calendar_accompanyingCourse' or context == 'calendar_person' %}
{% set invite = entity.inviteForUser(item) %}
{% if invite is not null %}

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

@@ -41,7 +41,7 @@
{% if activity.user and t.userVisible %}
<li>
<span class="item-key">{{ 'Referrer'|trans ~ ': ' }}</span>
<span class="badge-user">{{ activity.user|chill_entity_render_box }}</span>
<span class="badge-user">{{ activity.user|chill_entity_render_box({'at_date': activity.date}) }}</span>
</li>
{% endif %}

View File

@@ -37,7 +37,7 @@
{%- if entity.user is not null %}
<dt class="inline">{{ 'Referrer'|trans|capitalize }}</dt>
<dd>
<span class="badge-user">{{ entity.user|chill_entity_render_box }}</span>
<span class="badge-user">{{ entity.user|chill_entity_render_box({'at_date': entity.date}) }}</span>
</dd>
{% endif %}

View File

@@ -145,7 +145,7 @@ class ActivityVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn
throw new \RuntimeException('Could not determine context of activity.');
}
} elseif ($subject instanceof AccompanyingPeriod) {
if (AccompanyingPeriod::STEP_CLOSED === $subject->getStep()) {
if (AccompanyingPeriod::STEP_CLOSED === $subject->getStep() || AccompanyingPeriod::STEP_DRAFT === $subject->getStep()) {
if (\in_array($attribute, [self::UPDATE, self::CREATE, self::DELETE], true)) {
return false;
}

View File

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

View File

@@ -60,7 +60,7 @@ final class TranslatableActivityTypeTest extends KernelTestCase
$this->assertInstanceOf(
ActivityType::class,
$form->getData()['type'],
'The data is an instance of Chill\\ActivityBundle\\Entity\\ActivityType'
'The data is an instance of Chill\ActivityBundle\Entity\ActivityType'
);
$this->assertEquals($type->getId(), $form->getData()['type']->getId());

View File

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

View File

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

View File

@@ -77,6 +77,18 @@ Choose a type: Choisir un type
4 hours: 4 heures
4 hours 30: 4 heures 30
5 hours: 5 heures
5 hours 30: 5 heure 30
6 hours: 6 heures
6 hours 30: 6 heure 30
7 hours: 7 heures
7 hours 30: 7 heure 30
8 hours: 8 heures
8 hours 30: 8 heure 30
9 hours: 9 heures
9 hours 30: 9 heure 30
10 hours: 10 heures
11 hours: 11 heures
12 hours: 12 heures
Concerned groups: Parties concernées par l'échange
Persons in accompanying course: Usagers du parcours
Third persons: Tiers non-pro.
@@ -210,6 +222,7 @@ Documents label: Libellé du champ Documents
# activity type category admin
ActivityTypeCategory list: Liste des catégories des types d'échange
Create a new activity type category: Créer une nouvelle catégorie de type d'échange
Create a new activity in accompanying course: Créer un échange dans le parcours
# activity delete
Remove activity: Supprimer un échange
@@ -396,7 +409,7 @@ export:
by_creator_job:
job_form_label: Métiers
Filter activity by user job: Filtrer les échanges par métier du créateur de l'échange
'Filtered activity by user job: only %jobs%': "Filtré par service du créateur de l'échange: uniquement %jobs%"
'Filtered activity by user job: only %jobs%': "Filtré par métier du créateur de l'échange: uniquement %jobs%"
by_persons:
Filter activity by persons: Filtrer les échanges par usager participant
'Filtered activity by persons: only %persons%': 'Échanges filtrés par usagers participants: seulement %persons%'

View File

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

View File

@@ -49,13 +49,13 @@
<li>
<span>
<abbr class="referrer" title={{ 'Created by'|trans }}>{{ 'By'|trans }}:</abbr>
<b>{{ entity.createdBy|chill_entity_render_box }}</b>
<b>{{ entity.createdBy|chill_entity_render_box({'at_date': entity.date}) }}</b>
</span>
</li>
<li>
<span>
<abbr class="referrer" title={{ 'Created for'|trans }}>{{ 'For'|trans }}:</abbr>
<b>{{ entity.agent|chill_entity_render_box }}</b>
<b>{{ entity.agent|chill_entity_render_box({'at_date': entity.date}) }}</b>
</span>
</li>

View File

@@ -18,11 +18,11 @@
<dd>{{ entity.type|chill_entity_render_box }}</dd>
<dt class="inline">{{ 'Created by'|trans }}</dt>
<dd>{{ entity.createdBy }}</dd>
<dd>{{ entity.createdBy|chill_entity_render_box({'at_date': entity.date}) }}</dd>
<dt class="inline">{{ 'Created for'|trans }}</dt>
<dd>{{ entity.agent }}</dd>
<dd>{{ entity.agent|chill_entity_render_box({'at_date': entity.date}) }}</dd>
<dt class="inline">{{ 'Asideactivity location'|trans }}</dt>
{%- if entity.location.name is defined -%}
<dd>{{ entity.location.name }}</dd>

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

@@ -72,21 +72,21 @@ days: jours
1 hour 30: 1 heure 30
1 hour 45: 1 heure 45
2 hours: 2 heures
2 hours 30: 2 heure 30
2 hours 30: 2 heures 30
3 hours: 3 heures
3 hours 30: 3 heure 30
3 hours 30: 3 heures 30
4 hours: 4 heures
4 hours 30: 4 heure 30
4 hours 30: 4 heures 30
5 hours: 5 heures
5 hours 30: 5 heure 30
5 hours 30: 5 heures 30
6 hours: 6 heures
6 hours 30: 6 heure 30
6 hours 30: 6 heures 30
7 hours: 7 heures
7 hours 30: 7 heure 30
7 hours 30: 7 heures 30
8 hours: 8 heures
8 hours 30: 8 heure 30
8 hours 30: 8 heures 30
9 hours: 9 heures
9 hours 30: 9 heure 30
9 hours 30: 9 heures 30
10 hours: 10 heures
1/2 day: 1/2 jour
1 day: 1 jour

View File

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

View File

@@ -524,6 +524,16 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
return $this->startDate;
}
/**
* get the date of the calendar.
*
* Useful for showing the date of the calendar event, required by twig in some places.
*/
public function getDate(): ?\DateTimeImmutable
{
return $this->getStartDate();
}
public function getStatus(): ?string
{
return $this->status;

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Menu;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Security;
final readonly class AccompanyingCourseQuickMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(private Security $security)
{
}
public static function getMenuIds(): array
{
return ['accompanying_course_quick_menu'];
}
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod $accompanyingCourse */
$accompanyingCourse = $parameters['accompanying-course'];
if ($this->security->isGranted(CalendarVoter::CREATE, $accompanyingCourse)) {
$menu
->addChild('Create a new calendar in accompanying course', [
'route' => 'chill_calendar_calendar_new',
'routeParameters' => [
'accompanying_period_id' => $accompanyingCourse->getId(),
],
])
->setExtras([
'order' => 20,
'icon' => 'plus',
])
;
}
}
}

View File

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

View File

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

View File

@@ -37,12 +37,12 @@ class RemoteEventConverter
* valid when the remote string contains also a timezone, like in
* lastModifiedDate.
*/
final public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\\TH:i:s.u?P';
final public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\TH:i:s.u?P';
/**
* Same as above, but sometimes the date is expressed with only 6 milliseconds.
*/
final public const REMOTE_DATETIMEZONE_FORMAT_ALT = 'Y-m-d\\TH:i:s.uP';
final public const REMOTE_DATETIMEZONE_FORMAT_ALT = 'Y-m-d\TH:i:s.uP';
private const REMOTE_DATE_FORMAT = 'Y-m-d\TH:i:s.u0';

View File

@@ -1 +1,2 @@
import './scss/badge.scss';
import './scss/calendar-list.scss';

View File

@@ -0,0 +1,26 @@
ul.calendar-list {
list-style-type: none;
padding: 0;
& > li {
display: inline-block;
}
& > li:nth-child(n+2) {
margin-left: 0.25rem;
}
}
div.calendar-list {
ul.calendar-list {
display: inline-block;
}
& > a.calendar-list__global {
display: inline-block;;
padding: 0.2rem;
min-width: 2rem;
border: 1px solid var(--bs-chill-blue);
border-radius: 0.25rem;
text-align: center;
}
}

View File

@@ -16,6 +16,7 @@
:removableIfSet="false"
:displayPicked="false"
:suggested="this.suggestedUsers"
:label="'Utilisateur principal'"
@addNewEntity="setMainUser"
></pick-entity>
</div>

View File

@@ -55,7 +55,7 @@
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box }}</span>
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
{% endif %}
</ul>
</div>
@@ -132,7 +132,7 @@
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}

View File

@@ -89,7 +89,7 @@ class CalendarVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn
switch ($attribute) {
case self::SEE:
case self::CREATE:
if (AccompanyingPeriod::STEP_DRAFT === $subject->getStep()) {
if (AccompanyingPeriod::STEP_DRAFT === $subject->getStep() || AccompanyingPeriod::STEP_CLOSED === $subject->getStep()) {
return false;
}

View File

@@ -26,6 +26,7 @@ The calendar item has been successfully removed.: Le rendez-vous a été supprim
From the day: Du
to the day: au
Transform to activity: Transformer en échange
Create a new calendar in accompanying course: Créer un rendez-vous dans le parcours
Will send SMS: Un SMS de rappel sera envoyé
Will not send SMS: Aucun SMS de rappel ne sera envoyé
SMS already sent: Un SMS a été envoyé

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocGeneratorBundle\Test;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @template T of object
*/
abstract class DocGenNormalizerTestAbstract extends KernelTestCase
{
public function testNullValueHasSameKeysAsNull(): void
{
$normalizedObject = $this->getNormalizer()->normalize($this->provideNotNullObject(), 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => $this->provideDocGenExpectClass(),
]);
$nullNormalizedObject = $this->getNormalizer()->normalize(null, 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => $this->provideDocGenExpectClass(),
]);
self::assertEqualsCanonicalizing(array_keys($normalizedObject), array_keys($nullNormalizedObject));
self::assertArrayHasKey('isNull', $nullNormalizedObject, 'each object must have an "isNull" key');
self::assertTrue($nullNormalizedObject['isNull'], 'isNull key must be true for null objects');
self::assertFalse($normalizedObject['isNull'], 'isNull key must be false for null objects');
foreach ($normalizedObject as $key => $value) {
if (in_array($key, ['isNull', 'type'])) {
continue;
}
if (is_array($value)) {
if (array_is_list($value)) {
self::assertEquals([], $nullNormalizedObject[$key], "list must be serialized as an empty array, in {$key}");
} else {
self::assertEqualsCanonicalizing(array_keys($value), array_keys($nullNormalizedObject[$key]), "sub-object must have the same keys, in {$key}");
}
} elseif (is_string($value)) {
self::assertEquals('', $nullNormalizedObject[$key], 'strings must be ');
}
}
}
/**
* @return T
*/
abstract public function provideNotNullObject(): object;
/**
* @return class-string<T>
*/
abstract public function provideDocGenExpectClass(): string;
abstract public function getNormalizer(): NormalizerInterface;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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\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

@@ -25,6 +25,11 @@ use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Represent a document stored in an object store.
*
* StoredObjects 's content should be read and written using the @see{StoredObjectManagerInterface}.
*
* The property `$deleteAt` allow a deletion of the document after the given date. But this property should
* be set before the document is actually written by the StoredObjectManager.
*
* @ORM\Entity
*
* @ORM\Table("chill_doc.stored_object")
@@ -43,14 +48,14 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
/**
* @ORM\Column(type="json", name="datas")
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private array $datas = [];
/**
* @ORM\Column(type="text")
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private string $filename = '';
@@ -61,7 +66,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*
* @ORM\Column(type="integer")
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private ?int $id = null;
@@ -70,35 +75,35 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*
* @ORM\Column(type="json", name="iv")
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private array $iv = [];
/**
* @ORM\Column(type="json", name="key")
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private array $keyInfos = [];
/**
* @ORM\Column(type="text", name="title")
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private string $title = '';
/**
* @ORM\Column(type="text", name="type", options={"default": ""})
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private string $type = '';
/**
* @ORM\Column(type="uuid", unique=true)
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private UuidInterface $uuid;
@@ -117,13 +122,21 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*/
private int $generationTrialsCounter = 0;
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": null})
*/
private ?\DateTimeImmutable $deleteAt = null;
/**
* @ORM\Column(type="text", nullable=false, options={"default": ""})
*/
private string $generationErrors = '';
/**
* @param StoredObject::STATUS_* $status
*/
public function __construct(/**
* @ORM\Column(type="text", options={"default": "ready"})
*
* @Serializer\Groups({"read"})
*/
private string $status = 'ready'
) {
@@ -144,6 +157,11 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*/
public function getCreationDate(): \DateTime
{
if (null === $this->createdAt) {
// this scenario will quite never happens
return new \DateTime('now');
}
return \DateTime::createFromImmutable($this->createdAt);
}
@@ -303,4 +321,52 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
{
return self::STATUS_FAILURE === $this->getStatus();
}
public function getDeleteAt(): ?\DateTimeImmutable
{
return $this->deleteAt;
}
public function setDeleteAt(?\DateTimeImmutable $deleteAt): StoredObject
{
$this->deleteAt = $deleteAt;
return $this;
}
public function getGenerationErrors(): string
{
return $this->generationErrors;
}
/**
* Adds generation errors to the stored object.
*
* The existing generation errors are not removed
*
* @param string $generationErrors the generation errors to be added
*
* @return StoredObject the modified StoredObject instance
*/
public function addGenerationErrors(string $generationErrors): StoredObject
{
$this->generationErrors = $this->generationErrors.$generationErrors."\n";
return $this;
}
public function saveHistory(): void
{
if ('' === $this->getFilename()) {
return;
}
$this->datas['history'][] = [
'filename' => $this->getFilename(),
'iv' => $this->getIv(),
'key_infos' => $this->getKeyInfos(),
'type' => $this->getType(),
'before' => (new \DateTimeImmutable('now'))->getTimestamp(),
];
}
}

View File

@@ -14,47 +14,21 @@ namespace Chill\DocStoreBundle\Form;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\Document;
use Chill\DocStoreBundle\Entity\DocumentCategory;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectManager;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AccompanyingCourseDocumentType extends AbstractType
final class AccompanyingCourseDocumentType extends AbstractType
{
/**
* @var AuthorizationHelper
*/
protected $authorizationHelper;
/**
* @var ObjectManager
*/
protected $om;
/**
* @var TranslatableStringHelper
*/
protected $translatableStringHelper;
/**
* the user running this form.
*
* @var User
*/
protected $user;
public function __construct(
TranslatableStringHelper $translatableStringHelper
private readonly TranslatableStringHelperInterface $translatableStringHelper
) {
$this->translatableStringHelper = $translatableStringHelper;
}
public function buildForm(FormBuilderInterface $builder, array $options)

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,13 +1,16 @@
<template>
<div v-if="'ready' === props.storedObject.status" class="btn-group">
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf && props.storedObject.status !== 'stored_object_created'">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
<li v-if="props.canDownload">
@@ -32,13 +35,14 @@ import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
import {
StoredObject,
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>

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