Compare commits

...

183 Commits

Author SHA1 Message Date
cea44d1788 Release 2.23.0 2024-07-19 14:03:53 +02:00
84069e03dc Add filename display on file upload
This update introduces a new feature to the DropFile component; now filenames are displayed when they are uploaded. This provides a user-friendly way to view the file being managed. Additionally, some styling adjustments were made to accommodate this new addition.
2024-07-19 13:55:22 +02:00
ad5e780936 Do not update the createdAt column when importing postal code which does not change
The conflict resolution clause in the SQL command of the PostalCodeBaseImporter service has been updated. It now only changes the 'updatedAt' timestamp if either the 'center' position or the 'label' actually differs from the existing entry. This ensures that the 'updatedAt' field reflects when meaningful changes occurred.
2024-07-19 13:42:48 +02:00
19accc4d00 Handle duplicate addresses in AddressReferenceBaseImporter
Refactored the AddressReferenceBaseImporter to optimize address import and reconciliation. The code now identifies duplicate addresses in the temporary table and handles them according to the 'allowRemoveDoubleRefId' flag. This enhances data consistency during import operations.
2024-07-19 13:41:17 +02:00
6cb085f5f7 fix cs 2024-07-19 13:39:36 +02:00
97239ada84 More documentation for cronjob 2024-07-18 10:09:12 +02:00
643156f822 Merge branch 'issue271_account_acp_closing_date' into 'master'
#271 Account for acp closing date inn action filters (export)

See merge request Chill-Projet/chill-bundles!707
2024-07-05 13:42:06 +00:00
ff0b205591 Merge branch '273-notif-all-read' into 'master'
added unread and read all function with endpoints for notifications

See merge request Chill-Projet/chill-bundles!671
2024-07-05 13:36:31 +00:00
2d67843901 added unread and read all function with endpoints for notifications 2024-07-05 13:36:31 +00:00
2b09e1459c Merge branch '274-active-status-filter' into 'master'
Resolve "Add active/inactive filter to user list in admin"

Closes #274

See merge request Chill-Projet/chill-bundles!694
2024-07-05 08:52:46 +00:00
029524ba2c Resolve "Add active/inactive filter to user list in admin" 2024-07-05 08:52:46 +00:00
fa91e9494d Merge branch 'issue123_duplicate_calendar_range_by_week' into 'master'
Add a button to duplicate calendar ranges from a week to another one

See merge request Chill-Projet/chill-bundles!706
2024-07-05 08:07:49 +00:00
4e72d6fea1 Update slot duration in calendar
The slot duration in the 'MyCalendarRange' module has been updated to a new time. The previous duration was 5 minutes, but it has now been increased to 15 minutes to provide users with longer time slots.
2024-07-05 10:01:09 +02:00
5666b8b647 Expand range of calendar weeks in App2.vue to get weeks int the future and in the past
The code has been altered to increase the range of weeks computed from 15 to 30, with a modification to the 'getMonday' method accordingly. This enhances the user calendar experience by providing a wider time array to choose from.
2024-07-05 09:58:49 +02:00
702a5a27d2 Update version of bundles to 2.22.2 2024-07-03 12:22:53 +02:00
nobohan
0573f56782 copy week in my calendar - improve layout 2024-07-03 11:35:33 +02:00
nobohan
3bee18b0fa #271 Account for acp closing date inn action filters (export) 2024-07-02 16:31:18 +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
nobohan
843698a1d8 DX vuejs code style 2024-07-01 15:39:52 +02:00
nobohan
499640e48b Add a button to duplicate calendar ranges from a week to another one 2024-07-01 15:33:39 +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
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
253 changed files with 7660 additions and 1655 deletions

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

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

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

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

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

12
.changes/v2.23.0.md Normal file
View File

@@ -0,0 +1,12 @@
## v2.23.0 - 2024-07-19
### Feature
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter

View File

@@ -6,6 +6,119 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.23.0 - 2024-07-19
### Feature
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
## 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)

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

@@ -39,9 +39,12 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
use Chill\MainBundle\Entity\CronJobExecution;
use DateInterval;
use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
class MyCronJob implements CronJobInterface
{
function __construct(private ClockInterface $clock) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
// the parameter $cronJobExecution contains data about the last execution of the cronjob
@@ -56,7 +59,7 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
// this cron job should be executed if the last execution is greater than one day, but only during the night
$now = new DateTimeImmutable('now');
$now = $clock->now();
return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D'))
&& in_array($now->format('H'), self::ACCEPTED_HOURS, true)
@@ -69,10 +72,15 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
return 'arbitrary-and-unique-key';
}
public function run(): void
public function run(array $lastExecutionData): void
{
// here, we execute the command
}
// we return execution data, which will be served for next execution
// this data should be easily serializable in a json column: it should contains
// only int, string, etc. Avoid storing object
return ['last-execution-id' => 0];
}
}
How are cron job scheduled ?

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

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

View File

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

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

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

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

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

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

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

@@ -1,7 +1,7 @@
<template>
<div class="row">
<div class="col-sm">
<label class="form-label">{{ $t('created_availabilities') }}</label>
<label class="form-label">{{ $t("created_availabilities") }}</label>
<vue-multiselect
v-model="pickedLocation"
:options="locations"
@@ -14,10 +14,15 @@
></vue-multiselect>
</div>
</div>
<div class="display-options row justify-content-between" style="margin-top: 1rem;">
<div
class="display-options row justify-content-between"
style="margin-top: 1rem"
>
<div class="col-sm-9 col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration">Durée des créneaux</label>
<label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
@@ -58,13 +63,20 @@
</select>
</div>
</div>
<div class="col-sm-3 col-xs-12">
<div class="col-xs-12 col-sm-3">
<div class="float-end">
<div class="form-check input-group">
<span class="input-group-text">
<input id="showHideWE" class="mt-0" type="checkbox" v-model="showWeekends">
<input
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="showWeekends"
/>
</span>
<label for="showHideWE" class="form-check-label input-group-text">Week-ends</label>
<label for="showHideWE" class="form-check-label input-group-text"
>Week-ends</label
>
</div>
</div>
</div>
@@ -72,39 +84,86 @@
<FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="arg: EventApi">
<span :class="eventClasses(arg.event)">
<b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b>
<b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b>
<b v-else >no 'is'</b>
<a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)">
</a>
</span>
<b v-if="arg.event.extendedProps.is === 'remote'">{{
arg.event.title
}}</b>
<b v-else-if="arg.event.extendedProps.is === 'range'"
>{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b
>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
arg.event.title
}}</b>
<b v-else>no 'is'</b>
<a
v-if="arg.event.extendedProps.is === 'range'"
class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)"
>
</a>
</span>
</template>
</FullCalendar>
<div id="copy-widget">
<div class="container">
<div class="row align-items-center">
<div class="col-sm-4 col-xs-12">
<h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6>
</div>
<div class="col-sm-3 col-xs-12">
<input class="form-control" type="date" v-model="copyFrom" />
</div>
<div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-sm-3 col-xs-12" >
<input class="form-control" type="date" v-model="copyTo" />
</div>
<div class="col-sm-1">
<button class="btn btn-action" @click="copyDay">
{{ $t('copy_range') }}
</button>
<div class="container mt-2 mb-2">
<div class="row justify-content-between align-items-center mb-4">
<div class="col-xs-12 col-sm-3 col-md-2">
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
</div>
<div class="col-xs-12 col-sm-9 col-md-2">
<select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
<option value="day">{{ $t("from_day_to_day") }}</option>
<option value="week">{{ $t("from_week_to_week") }}</option>
</select>
</div>
<template v-if="dayOrWeek === 'day'">
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyFrom" />
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyTo" />
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyDay">
{{ $t("copy_range") }}
</button>
</div>
</template>
<template v-else>
<div class="col-xs-12 col-sm-3 col-md-3">
<select
v-model="copyFromWeek"
id="copyFromWeek"
class="form-select"
>
<option v-for="w in lastWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<select v-model="copyToWeek" id="copyToWeek" class="form-select">
<option v-for="w in nextWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyWeek">
{{ $t("copy_range") }}
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- not directly seen, but include in a modal -->
@@ -112,42 +171,95 @@
</template>
<script setup lang="ts">
import type {
CalendarOptions,
DatesSetArg,
EventInput
} from '@fullcalendar/core';
import {reactive, computed, ref} from "vue";
import {useStore} from "vuex";
import {key} from './store';
import FullCalendar from '@fullcalendar/vue3';
import frLocale from '@fullcalendar/core/locales/fr';
import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction";
EventInput,
} from "@fullcalendar/core";
import { reactive, computed, ref, onMounted } from "vue";
import { useStore } from "vuex";
import { key } from "./store";
import FullCalendar from "@fullcalendar/vue3";
import frLocale from "@fullcalendar/core/locales/fr";
import interactionPlugin, {
DropArg,
EventResizeDoneArg,
} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core";
import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import {
EventApi,
DateSelectArg,
EventDropArg,
EventClickArg,
} from "@fullcalendar/core";
import {
dateToISO,
ISOToDate,
} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import VueMultiselect from "vue-multiselect";
import {Location} from "../../../../../ChillMainBundle/Resources/public/types";
import { Location } from "../../../../../ChillMainBundle/Resources/public/types";
import EditLocation from "./Components/EditLocation.vue";
import {useI18n} from "vue-i18n";
import { useI18n } from "vue-i18n";
const store = useStore(key);
const {t} = useI18n();
const { t } = useI18n();
const showWeekends = ref(false);
const slotDuration = ref('00:05:00');
const slotMinTime = ref('09:00:00');
const slotMaxTime = ref('18:00:00');
const slotDuration = ref("00:15:00");
const slotMinTime = ref("09:00:00");
const slotMaxTime = ref("18:00:00");
const copyFrom = ref<string | null>(null);
const copyTo = ref<string | null>(null);
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null)
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null);
const dayOrWeek = ref("day");
const copyFromWeek = ref<string | null>(null);
const copyToWeek = ref<string | null>(null);
interface Weeks {
value: string | null;
text: string;
}
const getMonday = (week: number): Date => {
const lastMonday = new Date();
lastMonday.setDate(
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7
);
return lastMonday;
};
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const lastWeeks = computed((): Weeks[] =>
Array.from(Array(30).keys()).map((w) => {
const lastMonday = getMonday(15-w);
return {
value: dateToISO(lastMonday),
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
})
);
const nextWeeks = computed((): Weeks[] =>
Array.from(Array(52).keys()).map((w) => {
const nextMonday = getMonday(w + 1);
return {
value: dateToISO(nextMonday),
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
})
);
const baseOptions = ref<CalendarOptions>({
locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin],
initialView: 'timeGridWeek',
initialView: "timeGridWeek",
initialDate: new Date(),
scrollTimeReset: false,
selectable: true,
@@ -164,9 +276,9 @@ const baseOptions = ref<CalendarOptions>({
selectMirror: false,
editable: true,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay'
left: "prev,next today",
center: "title",
right: "timeGridWeek,timeGridDay",
},
});
@@ -180,20 +292,23 @@ const locations = computed<Location[]>(() => {
const pickedLocation = computed<Location | null>({
get(): Location | null {
return store.state.locations.locationPicked || store.state.locations.currentLocation;
return (
store.state.locations.locationPicked ||
store.state.locations.currentLocation
);
},
set(newLocation: Location | null): void {
store.commit('locations/setLocationPicked', newLocation, {root: true});
}
})
store.commit("locations/setLocationPicked", newLocation, { root: true });
},
});
/**
* return the show classes for the event
* @param arg
*/
const eventClasses = function(arg: EventApi): object {
return {'calendarRangeItems': true};
}
const eventClasses = function (arg: EventApi): object {
return { calendarRangeItems: true };
};
/*
// currently, all events are stored into calendarRanges, due to reactivity bug
@@ -230,51 +345,60 @@ const calendarOptions = computed((): CalendarOptions => {
* launched when the calendar range date change
*/
function onDatesSet(event: DatesSetArg): void {
store.dispatch('fullCalendar/setCurrentDatesView', {start: event.start, end: event.end});
store.dispatch("fullCalendar/setCurrentDatesView", {
start: event.start,
end: event.end,
});
}
function onDateSelect(event: DateSelectArg): void {
if (null === pickedLocation.value) {
window.alert("Indiquez une localisation avant de créer une période de disponibilité.");
window.alert(
"Indiquez une localisation avant de créer une période de disponibilité."
);
return;
}
store.dispatch('calendarRanges/createRange', {start: event.start, end: event.end, location: pickedLocation.value});
store.dispatch("calendarRanges/createRange", {
start: event.start,
end: event.end,
location: pickedLocation.value,
});
}
/**
* When a calendar range is deleted
*/
function onClickDelete(event: EventApi): void {
console.log('onClickDelete', event);
if (event.extendedProps.is !== 'range') {
if (event.extendedProps.is !== "range") {
return;
}
store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId);
store.dispatch(
"calendarRanges/deleteRange",
event.extendedProps.calendarRangeId
);
}
function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) {
if (payload.event.extendedProps.is !== 'range') {
if (payload.event.extendedProps.is !== "range") {
return;
}
const changedEvent = payload.event;
store.dispatch('calendarRanges/patchRangeTime', {
store.dispatch("calendarRanges/patchRangeTime", {
calendarRangeId: payload.event.extendedProps.calendarRangeId,
start: payload.event.start,
end: payload.event.end,
});
};
}
function onEventClick(payload: EventClickArg): void {
// @ts-ignore TS does not recognize the target. But it does exists.
if (payload.jsEvent.target.classList.contains('delete')) {
if (payload.jsEvent.target.classList.contains("delete")) {
return;
}
if (payload.event.extendedProps.is !== 'range') {
if (payload.event.extendedProps.is !== "range") {
return;
}
@@ -285,10 +409,26 @@ function copyDay() {
if (null === copyFrom.value || null === copyTo.value) {
return;
}
store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)})
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
from: ISOToDate(copyFrom.value),
to: ISOToDate(copyTo.value),
});
}
function copyWeek() {
if (null === copyFromWeek.value || null === copyToWeek.value) {
return;
}
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
fromMonday: ISOToDate(copyFromWeek.value),
toMonday: ISOToDate(copyToWeek.value),
});
}
onMounted(() => {
copyFromWeek.value = dateToISO(getMonday(0));
copyToWeek.value = dateToISO(getMonday(1));
});
</script>
<style scoped>
@@ -299,4 +439,9 @@ function copyDay() {
z-index: 9999999999;
padding: 0.25rem 0 0.25rem;
}
div.copy-chevron {
text-align: center;
font-size: x-large;
width: 2rem;
}
</style>

View File

@@ -5,11 +5,9 @@ const appMessages = {
show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends",
copy_range: "Copier",
copy_range_from_to: "Copier les plages d'un jour à l'autre",
copy_range_to_next_day: "Copier les plages du jour au jour suivant",
copy_range_from_day: "Copier les plages du ",
to_the_next_day: " au jour suivant",
copy_range_to_next_week: "Copier les plages de la semaine à la semaine suivante",
copy_range_from_to: "Copier les plages",
from_day_to_day: "d'un jour à l'autre",
from_week_to_week: "d'une semaine à l'autre",
copy_range_how_to: "Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.",
new_range_to_save: "Nouvelles plages à enregistrer",
update_range_to_save: "Plages à modifier",

View File

@@ -52,6 +52,23 @@ export default <Module<CalendarRangesState, State>>{
}
}
return founds;
},
getRangesOnWeek: (state: CalendarRangesState) => (mondayDate: Date): EventInputCalendarRange[] => {
const founds = [];
for (let d of Array.from(Array(7).keys())) {
const dateOfWeek = new Date(mondayDate);
dateOfWeek.setDate(mondayDate.getDate() + d);
const dateStr = <string>dateToISO(dateOfWeek);
for (let range of state.ranges) {
if (isEventInputCalendarRange(range)
&& range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
}
return founds;
},
},
@@ -238,7 +255,7 @@ export default <Module<CalendarRangesState, State>>{
for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start));
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate())
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let end = new Date(<Date>ISOToDatetime(r.end));
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
@@ -246,6 +263,23 @@ export default <Module<CalendarRangesState, State>>{
promises.push(ctx.dispatch('createRange', {start, end, location}));
}
return Promise.all(promises).then(_ => Promise.resolve(null));
},
copyFromWeekToAnotherWeek(ctx, {fromMonday, toMonday}: {fromMonday: Date, toMonday: Date}): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnWeek'](fromMonday);
const promises = [];
const diffTime = toMonday.getTime() - fromMonday.getTime();
for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start));
let end = new Date(<Date>ISOToDatetime(r.end));
start.setTime(start.getTime() + diffTime);
end.setTime(end.getTime() + diffTime);
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
promises.push(ctx.dispatch('createRange', {start, end, location}));
}
return Promise.all(promises).then(_ => Promise.resolve(null));
}
}

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

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

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

@@ -48,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 = '';
@@ -66,7 +66,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*
* @ORM\Column(type="integer")
*
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"write"})
*/
private ?int $id = null;
@@ -75,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;
@@ -137,8 +137,6 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*/
public function __construct(/**
* @ORM\Column(type="text", options={"default": "ready"})
*
* @Serializer\Groups({"read"})
*/
private string $status = 'ready'
) {
@@ -356,4 +354,19 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
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

@@ -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,165 @@
<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 display_filename: Ref<string|null> = ref(null);
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;
display_filename.value = file.name;
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" class="file-icon">
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
<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>
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
<!-- todo i18n -->
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
</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%;
.file-icon {
font-size: xx-large;
}
.display-filename {
font-variant: small-caps;
font-weight: 200;
}
& > .area, & > .waiting {
width: 100%;
height: 10rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
& > .area {
border: 4px dashed #ccc;
&.dragging {
border: 4px dashed blue;
}
}
}
</style>

View File

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

View File

@@ -10,7 +10,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive} from "vue";
import {StoredObject} from "../../types";
import {StoredObject, StoredObjectCreated} from "../../types";
interface ConvertButtonConfig {
storedObject: StoredObject,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\Participation;
use Chill\EventBundle\Form\EventType;
use Chill\EventBundle\Form\Type\PickEventType;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\EventBundle\Security\EventVoter;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
@@ -433,7 +433,6 @@ final class EventController extends AbstractController
$builder->add('event_id', HiddenType::class, [
'data' => $event->getId(),
]);
dump($event->getId());
return $builder->getForm();
}

View File

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

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