Compare commits

...

177 Commits

Author SHA1 Message Date
fd69568842 Add workflow guard to block external send without public view
Introduce `EntityWorkflowGuardSendExternalIfNoPublicView` class to prevent workflows from transitioning to an external send state if the entity lacks a public view. Included unit tests to verify functionality for entities both with and without public views.
2024-10-23 15:26:25 +02:00
71aaf01687 Merge branch 'signature-app/OP762-fix-create-workflow-action' into 'signature-app-master'
Fix the triggering of the vent goToGenerateWorkflow when relatedentityId is not know in PickWorkflow vue component

See merge request Chill-Projet/chill-bundles!747
2024-10-23 11:51:05 +00:00
a256307b82 Fix the triggering of the event goToGenerateWorkflow when relatedEntityId is not known
The component PickWorkflow emitted the event "goToGenerateWorkflow" when the normal behaviour is intercepted. But that event generated the link to create the workflow to pass it in the payload's event. That generation failed, causing the whole event to fail.

Now, if the link could not been generated, the link is a blank string. There is a supplementary parameter `isLinkValid`, boolean, inform if the link is valid or not.
2024-10-23 13:44:31 +02:00
a6480191e5 Merge branch 'signature-app/OP753-suggest-users-persons' into 'signature-app-master'
Add suggestion for users, persons and thirdparties in workflow

See merge request Chill-Projet/chill-bundles!746
2024-10-23 09:47:43 +00:00
19eb6f7ebb Add suggested persons and third parties in form
Integrated suggested persons and third parties into the WorkflowStepType form. This enhancement auto-populates suggestion fields for better user experience. It ensures that the suggestion data for persons and third parties is readily available in the form configuration.
2024-10-23 11:41:20 +02:00
261bc88b5e Add suggested persons and third parties methods
Introduced getSuggestedPersons and getSuggestedThirdParties methods across various WorkflowHandlers. These methods integrate with ProvidePersonsAssociated and ProvideThirdPartiesAssociated services to fetch related entities, enhancing the workflow handling capabilities.
2024-10-23 11:41:19 +02:00
4f18b1d2b2 Add services and tests for associated entities management
Implemented services to provide associated persons and third parties for accompanying periods and their works. Included comprehensive tests to ensure proper functionality and associations.
2024-10-23 00:51:37 +02:00
968835a262 Refactor user suggestion logic in workflow
Removed duplicate user suggestion handling from `WorkflowController` and centralized it in `WorkflowStepType`. This change simplifies the controller and makes user suggestion logic more maintainable.
2024-10-22 23:51:48 +02:00
85dc9bdb2f Add getSuggestedUsers method in EntityWorkflowManager
Implemented the method to retrieve a list of suggested users for an entity workflow, filtering out duplicates. Added corresponding unit tests to verify the method's functionality and ensure its correctness in various scenarios.
2024-10-22 23:35:13 +02:00
c877076429 Add and update test handlers for suggested users retrieval
Introduced new test files for workflow handlers and adjusted existing `getSuggestedUsers` methods to handle related entity checks and duplicates removal. Also, modified repos to align with test dependencies.
2024-10-22 23:24:10 +02:00
418794e586 Merge branch 'signature-app/OP730-create-entities-sending' into 'signature-app-master'
Implement feature to send document to an external

See merge request Chill-Projet/chill-bundles!745
2024-10-21 15:56:12 +00:00
fd66dbf26e Merge remote-tracking branch 'origin/signature-app-master' into signature-app/OP730-create-entities-sending 2024-10-21 17:50:03 +02:00
fde74b190d Add mock for TempUrlGeneratorInterface in StoredObjectTypeTest
Updated the StoredObjectNormalizer initialization to include a mock for TempUrlGeneratorInterface. This ensures tests handle all dependencies of StoredObjectNormalizer correctly.
2024-10-21 17:45:12 +02:00
527cf23d4f Fix Canceling of stale workflow cronjob
Refactor workflow cancellation logic to encapsulate transition checks in a dedicated method, and update CronJob handling to use entity workflows instead of IDs. Enhance test coverage to ensure proper handling and instantiate mocks for EntityManagerInterface.
2024-10-21 17:42:00 +02:00
1d708a481d Fix the display of old documents in the storedobject's history list modal
OP#737

https://champs-libres.openproject.com/work_packages/737
2024-10-21 17:42:00 +02:00
ff5640e193 Allow to edit storedObject associated with workflow which are canceled
OP#753
2024-10-21 17:41:59 +02:00
d45de5405b Use the twig function chill_entity_render_string to render the person's name 2024-10-21 17:41:59 +02:00
7b322d7bab Rename signature templates by removing underscores
Standardized template names in WorkflowController and WorkflowAddSignatureController for better consistency. Updated references and renamed template files accordingly.
2024-10-21 17:41:59 +02:00
daef18408a fix the return type of the EntityWorkflowCreation constraint 2024-10-18 19:26:09 +02:00
91a4b45607 Add notification to user groups on workflow transition
Implemented NotificationToUserGroupsOnTransition to send group emails upon workflow completion. Also updated NotificationOnTransition to prevent double notifications and created a unit test for the new functionality.
2024-10-18 19:25:03 +02:00
29fa086fde Set return types to MetadataExtractor 2024-10-18 19:14:23 +02:00
508c4cd674 Add an email address ot UserGroup entity 2024-10-18 19:14:13 +02:00
9fe20b5e81 Show pending signatures in the person's search results 2024-10-15 15:15:32 +02:00
d8ded80582 Add an option "suggest_myself" on PickUserDynamicType and PickUserGroupOrUserDynamicType, and use this option in WorkflowStepType
This will suggest the current user in PickUserDynamicType
2024-10-15 11:31:25 +02:00
d283d62049 !fixup Automatically execute body renderer when posting an 2024-10-15 11:10:31 +02:00
6cd336922f Fix error when denormalizing empty array for pick third party dynampic type 2024-10-15 11:09:17 +02:00
13dbbb6741 Fix race condition in ChillCollectionType
In some dcase, the collection is not initialized when a showHide is launched before the collection is fully initialized.
2024-10-15 09:08:29 +02:00
1313b6f138 Automatically execute body renderer when posting an email in Chill
+ adaptation of services which already uses body renderer
2024-10-10 14:06:00 +02:00
3d53e7da65 Show the list of pending views in the workflows index page 2024-10-10 13:40:25 +02:00
8589bada3f Layout for the email to external 2024-10-10 13:40:07 +02:00
292034d64d Add public view rendering to workflow handler for AccompanyingPeriodWorkEvaluationDocument
Implemented the `EntityWorkflowWithPublicViewInterface` in `AccompanyingPeriodWorkEvaluationDocumentWorkflowHandler`. Included the `renderPublicView` method using `WorkflowWithPublicViewDocumentHelper` for enhanced document handling.
2024-10-10 11:42:04 +02:00
3f7c5d23dc Add return type hint to getTargets method
The getTargets method now explicitly returns an array, enhancing type safety and readability. This change ensures that the return type is clear to any developers interacting with this method.
2024-10-10 11:41:25 +02:00
78445f0d65 fixup! Add message handling for public view creation 2024-10-10 11:39:04 +02:00
c329a1f1f8 fixup! Add message handling for public view creation 2024-10-10 09:39:45 +02:00
9d722110a6 fixup! Add direct download link feature with new button implementation 2024-10-09 21:39:55 +02:00
82e2b9a0f6 Add message handling for public view creation
Introduce `PostPublicViewMessage` and `PostPublicViewMessageHandler` to handle external user views on public links by applying workflow transitions. Integrate with `WorkflowViewSendPublicController` and add relevant tests.
2024-10-09 21:39:27 +02:00
40b8fae8ba Add vizualisation of public views in the workflow history 2024-10-08 16:17:11 +02:00
b99ea3b17a !fixup Add direct download link feature 2024-10-08 16:16:10 +02:00
3f80d62ca2 Add public workflow view functionality
Introduced the ability to render public views for workflows, including new templates, handlers, and metadata support. Updated entity interfaces and translations to enhance the public sharing of workflow documents.
2024-10-08 15:15:58 +02:00
118ae291e2 Add direct download link feature with new button implementation
Introduce a new feature that allows for direct download links by integrating TempUrlGeneratorInterface. Added new DOWNLOAD_LINK_ONLY group and corresponding logic to generate download links in StoredObjectNormalizer. Implement a new Twig filter and Vue component for rendering the download button. Updated tests to cover the new functionality.
2024-10-08 15:15:38 +02:00
5c0f3cb317 Implement the controller action to view the EntityworkflowSend 2024-10-07 15:35:36 +02:00
a0b5c208eb Send an email when a workflow is send to an external
- create an event subscriber to catch the workflow which arrive to a "sentExternal" step;
- add a messenger's message to handle the generation of the email;
- add a simple message, and a simple controller for viewing the document
- add dedicated tests
2024-10-04 13:52:18 +02:00
7913a377c8 Move the logic to check if dest users are required to a dedicated constraint
- Create a dedicated constraint to check if the destUsers are required by the applied transition.
- Apply on WorkflowTransitionContextDTO and, if required, use the built-in constraints
- create tests
2024-10-04 13:41:20 +02:00
7cd638c5fc Add TransitionHasDestineeIfIsSentExternal validator
This commit introduces a new validator to ensure that transitions marked as 'sent' have a designated external recipient. It includes related tests for scenarios with and without recipients and covers integration with the workflow context.
2024-10-04 13:41:20 +02:00
071c5e3c55 Update the form to allow sending a workflow to an external destinee
OP#734 Modification du formulaire pour permettre l'envoi d'un workflow

https://champs-libres.openproject.com/work_packages/734
2024-10-04 13:41:20 +02:00
da6589ba87 Add ThirdPartyHasEmail validator
Introduce a new validator that ensures a third party has an email address, including the corresponding translation for error messaging and unit tests to verify its functionality.
2024-10-04 13:41:19 +02:00
a563ba644e clean the file from code in error 2024-10-04 13:41:19 +02:00
2213f6f429 Add EntityWorkflowSend and EntityWorkflowSendView entities
Introduced EntityWorkflowSend and EntityWorkflowSendView entities to enable tracking of workflow content sent to external parties. Updated EntityWorkflowStep to associate with these entities and added a corresponding database migration script.
2024-10-04 13:41:19 +02:00
9a9d14eb5a Enhance behaviour of duplicating storedObject to keep only the last "kept before conversion" version if any
Enhance the duplication service to selectively handle versions tagged with "KEEP_BEFORE_CONVERSION". Modify StoredObject to support retrieval and checking of such versions. Add relevant test cases to validate this behavior.
2024-10-04 13:41:18 +02:00
4cc001a070 Enhance behaviour of duplicating storedObject to keep only the last "kept before conversion" version if any
Enhance the duplication service to selectively handle versions tagged with "KEEP_BEFORE_CONVERSION". Modify StoredObject to support retrieval and checking of such versions. Add relevant test cases to validate this behavior.
2024-10-02 13:13:26 +02:00
6c52ff84a8 Merge branch 'signature-app/OP630-user-group-in-workflows' into 'signature-app-master'
Implements feature to send a workfllow to a group of users

See merge request Chill-Projet/chill-bundles!744
2024-10-01 19:46:50 +00:00
818d800384 In workflow index page, show signature or decision, not both 2024-10-01 18:49:52 +02:00
cef641ee24 Render the history for workflow, with signature and onHold, and destUserGroups 2024-10-01 18:49:52 +02:00
c4c7280b52 Fix Create a PickUserGroupOrUserDynamicType 2024-10-01 18:49:52 +02:00
d8ad8c3605 Add previous exception to the stack when denormlizing Discriminated Object 2024-10-01 18:49:52 +02:00
803332ba5f Remove the feature "send a workflow to an email address" 2024-10-01 18:49:51 +02:00
479651b31e Add a list of user groups in User menu, and implements the feature to add / remove users 2024-10-01 18:49:51 +02:00
7bedf1b5b8 Add some doc for PickUserDynamicType 2024-10-01 18:49:51 +02:00
6b764114e4 Add event for handling end of addNewEntity process
Added new event 'addNewEntityProcessEnded' to PickEntity component. This event triggers form submission when 'submit_on_adding_new_entity' is enabled, ensuring proper flow control.
2024-10-01 18:49:51 +02:00
03a150aa16 Fix ChillUrlGenerator: merge parameters correctly 2024-10-01 18:49:50 +02:00
81706a61ef Fix issue with duplicate a tag 2024-10-01 18:49:50 +02:00
debca1f474 Admin CRUD for user groups 2024-10-01 18:49:50 +02:00
2e71808be1 Add admin users and active status to UserGroup
Added a new table `chill_main_user_group_user_admin` for admin users and modified the UserGroup entity to include an `active` status column. Included methods for managing the admin users and the active status in the UserGroup entity.
2024-09-27 12:01:47 +02:00
0c1d9ee4be Add support for handling user groups in workflow counters and list workflows in "my workflows" controller
- rewrite queries in repositories;
- fix cache key cleaning for members of users when a workflow is transitionned
2024-09-27 10:35:57 +02:00
87599425df Send a notification to all User which are members of UserGroups, when a workflow is sent to them 2024-09-26 17:29:27 +02:00
86ec6f82da Do not block transition in EntityWorkflow when the user is member of a dest user group
- refactor EntityWorkflowGuardTransition + tests
- allow to find easily user within userGroup by adding a dedicated method to UserGroup::contains
2024-09-26 16:04:53 +02:00
17f4c85fa5 Add user group support in entity workflow steps
Enhanced the WorkflowTransitionContextDTO to include user groups alongside individual users for future steps. Updated the relevant entity and form classes to accommodate this change and included the necessary database migration script.
2024-09-26 15:39:13 +02:00
82cd77678b Create a PickUserGroupOrUserDynamicType
- add necessary vue component to render usergroup within the component AddPersons;
- add necessary normalization and denormalization process for matching the selected usergroup with entities in database
2024-09-26 15:39:12 +02:00
9e69c97250 Add search functionality for user groups
Implemented `SearchUserGroupApiProvider` to handle user group search requests. Added `UserGroupRepository` and its interface to support search queries. Updated API specs to include user group as a searchable type.
2024-09-26 15:39:11 +02:00
b4fa478177 Add UserGroup to chill (import from branch ticket-app-master)
Import the UserGrou feature from ticket-app-master branch. This includes:

- import all the entities and migrations, modification of typescript types, templating, and so on;
- apply some verification and formatting rules, like:
  - reformat file on chill.api.specs.yaml (MainBundle)
  - reformat file on types.ts (Main Bundle)

Migrations kept the same filename.
2024-09-26 11:17:50 +02:00
42438d5bb5 Merge branch 'signature-app/OP722-cancel-refuse-signature' into 'signature-app-master'
Adjust logic for removing the hold on a workflow only by user who owns the...

Closes #307

See merge request Chill-Projet/chill-bundles!738
2024-09-25 10:04:04 +00:00
5287824dbe Add async handling for signature state changes
Introduce MessageBus to handle post-signature operations asynchronously. This ensures that further steps are executed through dispatched messages, improving system scalability and performance. Implement new handlers and messages for the workflow state transitions.
2024-09-25 11:58:41 +02:00
cfce531754 Add reject functionality for workflow signatures
Implemented the ability to reject workflow signatures by adding necessary templates, routes, and authorization checks. Updated the `WorkflowSignatureCancelController` to handle rejection and modified existing templates and translations to support the new feature.
2024-09-25 11:31:27 +02:00
83121c2a83 Implement signature cancellation feature
Added functionality to cancel signatures in workflow, including controller, view, and tests. Updated translations and adjusted templates to support and display cancellation actions.
2024-09-25 10:58:53 +02:00
5a467ae38d Add ChillUrlGenerator and ChillUrlGeneratorInterface
Introduced a new interface ChillUrlGeneratorInterface for URL generation with return path handling. Implemented this interface in the ChillUrlGenerator class, which uses Symfony components to manage URL generation and request information.
2024-09-25 10:58:15 +02:00
75005b4ed6 Merge branch 'feature/704-un-related-entity-ne-peut-pas-faire-lobjet-de-deux-workflow-simultanés' into 'signature-app-master'
Prevent creation of a new workflow if one already exists

See merge request Chill-Projet/chill-bundles!737
2024-09-24 12:36:39 +00:00
90c5b0341a Handle null transitionPreviousBy in ListWorkflow.vue
Previously, the getPopContent method assumed transitionPreviousBy would always have a value, which led to errors when it was null. This update adds a conditional check to handle cases where transitionPreviousBy is null, ensuring the component renders correctly.
2024-09-24 14:36:01 +02:00
5a5d259d18 Add duplicate workflow prevention in MetadataExtractor
Integrate DuplicateEntityWorkflowFinder to prevent creating workflows for entities with existing opened or positive final workflows. Updated EntityWorkflowVoter to implement the same check before allowing creation. Removed unnecessary blank workflow parameter from Twig template.
2024-09-24 14:25:00 +02:00
758a14366e Add OpenedEntityWorkflowHelper and tests
Implemented OpenedEntityWorkflowHelper to handle final state checks for EntityWorkflow. This includes methods to determine if a workflow has reached positive or negative final steps. Added corresponding unit tests to ensure proper functionality.
2024-09-24 14:25:00 +02:00
e91fce524e Append workflow name in twig calls to workflow_metadata
Ensuring that the twig template works if there are more than one workflow available
2024-09-24 14:25:00 +02:00
7b06c80c2a Merge branch 'signature-app/wp-706-no-forward-unless-signed' into 'signature-app-master'
Block transition if there is a pending signature for the entityWorkflow

See merge request Chill-Projet/chill-bundles!736
2024-09-24 09:13:45 +00:00
cf2fe1bba7 Add guards and tests for entity workflow transitions
Introduced EntityWorkflowGuardUnsignedTransition to block transitions with pending signatures. Implemented a new center resolver and added comprehensive unit tests for verifying transition rules and permissions.
2024-09-24 11:08:22 +02:00
27df3b2c9b Add support for ManagerAwareCenterResolverInterface
Introduce ManagerAwareCenterResolverInterface to ensure resolvers can reference their manager. Added a trait for implementing the interface and updated the CenterResolverManager to initialize resolvers correctly.
2024-09-24 10:58:01 +02:00
b350c0cfe8 Merge branch 'signature-app/wp-625-duplicate-ro-element-in-workflows' into 'signature-app-master'
Make stored object read-only if a signature was added to the document and allow to duplicate the related entity in workflow

See merge request Chill-Projet/chill-bundles!731
2024-09-23 14:42:28 +00:00
611a968162 Duplicate and accompanying course evaluation document
- create a service which duplicate the accompanying course work evaluation document
- create a controller to duplicate this document
- update the vuejs component to use this duplicate action
2024-09-23 16:32:47 +02:00
ce80207d98 Create route for duplicating a accompanying course document 2024-09-23 14:36:02 +02:00
5fc5369db6 in constructor for AccompanyingCourseDocumentRepository.php, do not create a property 2024-09-23 14:32:59 +02:00
20e8b03588 Rewrite the Component PickWorkflow.vue into typescript 2024-09-23 14:32:59 +02:00
4b65ec9b54 Create a duplicator service for accompanying course document 2024-09-23 14:32:40 +02:00
a8c5d1f660 Remove unused constructor parameter 2024-09-19 17:48:34 +02:00
5f67a7aadc Create a service to duplicate a storedObject into another one 2024-09-19 17:48:03 +02:00
77d06d756a Block document editing if any signature associated to a workflow is signed
Add a check in `WorkflowStoredObjectPermissionHelper` to block document editing once any signature is signed. Accompanied by new tests to verify this behavior.
2024-09-19 16:18:16 +02:00
c4c5c860f0 Merge branch 'signature-app/wp-576-restorestored-object-version' into 'signature-app-master'
See the list of stored object and restore some versions

See merge request Chill-Projet/chill-bundles!733
2024-09-19 13:36:29 +00:00
47f575de92 Enhance version restoration and download features
Introduce a version restoration button and logic to track restored versions throughout the UI. Update download buttons to display action strings conditionally and implement toast notifications for version restoration.
2024-09-19 13:42:23 +02:00
5906171041 Add restoration functionality for stored object versions
Introduce a service to restore stored object versions along with relevant tests and an API endpoint. This includes database migrations for version relationships, enhancing stored object version tracking.
2024-09-19 13:42:23 +02:00
b0e2e65885 Add document history button with modal viewer
This commit introduces a History button to the DocumentActionButtonsGroup component to view document versions. It includes new components for the modal dialog and API integrations to fetch and display version histories. This feature allows users to view and restore previous versions of stored objects.
2024-09-19 13:42:22 +02:00
dd3f6fb0ab Enhance StoredObjectVersion normalization
Add UserNormalizer dependency and pass createdAt context for createdBy normalization, ensuring compatibility with nullable context groups. This improves the accuracy and completeness of the normalized data.
2024-09-19 13:42:21 +02:00
5fa5a2349e Add FileIcon.vue and refactor DropFile.vue to use it
Introduced `FileIcon.vue` to handle file type icons centrally. Refactored `DropFile.vue` to utilize the new `FileIcon` component, improving code clarity and maintainability.
2024-09-19 13:42:20 +02:00
48f727dcfd Update vue version to ^3.5.6 2024-09-19 13:42:20 +02:00
6a0e26ec31 Add point-in-time normalization to stored object versions
Introduced a new normalizer for StoredObjectPointInTime and updated the StoredObjectVersionNormalizer to include point-in-time data when specified in the context. Added corresponding test cases to ensure the new normalization logic works correctly.
2024-09-19 13:42:19 +02:00
943a42cd38 Add StoredObjectVersionApiController and corresponding test
Added a new class StoredObjectVersionApiController in ChillDocGeneratorBundle which lists versions of a specified stored object. Corresponding unit test has been added as well. Made modifications in `StoredObject.php` to make the versions selectable. Also updated the API specifications to include a new GET route for retrieving versions.
2024-09-19 13:42:18 +02:00
d9b36533a2 Fix the query to find staled entity workflows
Add a test to check that the query still works
2024-09-19 12:31:46 +02:00
3697aee584 Merge branch '307-permission-apply-all-transitions' into 'signature-app-master'
Create a permission to apply all transitions

Closes #307

See merge request Chill-Projet/chill-bundles!729
2024-09-17 07:52:43 +00:00
33cc308e1e Merge branch 'upgrade-sf5' into signature-app-master 2024-09-16 15:30:01 +02:00
7206e13984 Merge branch 'master' into upgrade-sf5 2024-09-16 15:29:43 +02:00
6f28d154c8 Fix referrers display to show only current referrers.
Updated the view to loop through current referrers in the accompanying period. Added new method `getReferrersHistoryCurrent` to the entity to filter and return only active referrers, ensuring correct display in the UI. Also included documentation for better code clarity.
2024-09-16 15:25:25 +02:00
4d8de46ac9 Apply the voter to allow all transition on EntityWorkflowGuardTransition
This allow to effectively check that a user is allowed to apply all transitions on a workflow and, if yes, enable the given transition.
2024-09-16 14:47:00 +02:00
4696332a46 Create a voter for applying all transitions on all workflow's steps
This voter checks that the related entity's centers is reachable by the user.
2024-09-16 14:42:37 +02:00
0d54637d35 Add missing constructor argument in SignatureStepStateChangerTest 2024-09-16 13:19:14 +02:00
7a7d1d5b16 remove blank file 2024-09-16 12:13:52 +02:00
e5737b0c49 remove temporary method 2024-09-16 11:51:58 +02:00
45323e9136 Merge remote-tracking branch 'origin/upgrade-sf5' into signature-app-master 2024-09-16 11:51:33 +02:00
9f1afb8423 Add access controls and permissions for signature steps
Implemented a Voter to enforce permissions on signature steps, ensuring only authorized users can sign steps. Updated relevant controllers and templates to reflect these permissions, and added corresponding tests to validate the changes.
2024-09-13 17:04:57 +02:00
1494c7ecd7 Merge branch 'signature-app/add-manual-zone' into 'signature-app-master'
Improve signature app

See merge request Chill-Projet/chill-bundles!725
2024-09-13 14:23:44 +00:00
911dfc2878 fix rector errors 2024-09-13 16:22:09 +02:00
8e984f2006 do not allow to sign if the signature is already applyied 2024-09-13 16:16:42 +02:00
f0e8df38af remove the left-arrow on the "retour" button, because it was placed on the right of the page 2024-09-13 16:03:06 +02:00
1c0d334b91 downgrade symfony/event-dispatcher-contracts to version 2.4
This is necessary for using some dependencies which only works with symfony 5.4
2024-09-12 17:34:28 +02:00
nobohan
59c34dabd7 Signature: allow for null index in signature zone 2024-09-12 17:18:13 +02:00
nobohan
119668e415 Signature: allow for null index in signature zone 2024-09-12 17:02:45 +02:00
nobohan
2b516629f6 Signature: refresh document after signature 2024-09-12 17:02:08 +02:00
nobohan
092b5c4f90 Signature: get the stored object back in the check signature API 2024-09-12 15:26:03 +02:00
nobohan
ae1459cf77 Signature: topbar UI 2024-09-12 14:22:58 +02:00
nobohan
57d2929ecd Signature: move buttons to a top toolbar + change behavior on a new zone 2024-09-12 14:03:43 +02:00
bc34d84d63 Merge remote-tracking branch 'origin/master' into upgrade-sf5 2024-09-12 13:36:50 +02:00
f0f651edea update cs after php-cs-fixer upgrade 2024-09-12 12:02:33 +02:00
nobohan
3c987e0b8d Signature: keep selected zone when turning pages 2024-09-12 11:31:31 +02:00
f8a986d59b Add user IP and authenticated user details to signature
Updated the SignatureRequest metadata to include the requester's IP address and currently authenticated user details. Also improved the rendering of signer information by leveraging the `ChillEntityRenderManagerInterface`.
2024-09-12 11:18:35 +02:00
09563979a2 Refactor entity rendering with manager pattern
Introduce ChillEntityRenderManager to centralize entity rendering logic, reducing redundancy and improving code organization. Update dependencies and service configuration to support the new manager pattern, enhancing maintainability and flexibility of entity rendering in templates.
2024-09-12 11:18:35 +02:00
nobohan
0ee91800ab Merge branch 'signature-app/add-manual-zone' of https://gitlab.com/Chill-Projet/chill-bundles into signature-app/add-manual-zone 2024-09-12 10:46:52 +02:00
nobohan
d08212df46 Signature: add and process returnPath in the signature app 2024-09-12 09:46:14 +02:00
nobohan
4933238f3f Signature - improve UI signature app 2024-09-12 09:46:14 +02:00
nobohan
c23568032c Signature: add a signature zone manually 2024-09-12 09:46:13 +02:00
18af2ca70b Handle null transitionBy and improve display logic
Added checks for null transitionBy cases in workflow templates to display "Automated transition" when applicable. Also improved conditional rendering for 'destUser' and 'ccUser' fields to avoid empty elements.
2024-09-11 21:52:34 +02:00
f1505a9d15 Add locale to workflow URLs in notification templates
This is required to send notification within a console command
2024-09-11 21:16:54 +02:00
4e588ed0e0 Add logging to SignatureStepStateChanger
Enhanced the SignatureStepStateChanger by integrating a LoggerInterface to provide detailed logging at key points in the state transition process. This includes informational messages when marking signatures or skipping transitions, as well as debug messages when determining the next steps.
2024-09-11 21:14:07 +02:00
70671dadac Refactor workflow guard logic and add internal methods
Removed guard logic from EntityWorkflowTransitionEventSubscriber and created a new EntityWorkflowGuardTransition class for separation of concerns. Marked several setter methods in EntityWorkflowStepSignature as internal to guide proper usage. Added comprehensive tests to ensure the new guard logic functions correctly.
2024-09-11 21:13:58 +02:00
5dfbdad13d Release 2.24.0 2024-09-11 14:31:52 +02:00
b3e2d4ff9f Merge branch '306-invalidate-downloaded-document' into 'master'
Remove documents from memory after download

Closes #306

See merge request Chill-Projet/chill-bundles!727
2024-09-11 12:29:37 +00:00
01c2848a83 Fix deprecated method in redis 2024-09-11 14:23:23 +02:00
d0ee381627 Upgrade of php-cs-fixer 2024-09-11 14:21:32 +02:00
8b1b255050 Remove documents from memory after download
Implemented functionality to remove documents from browser memory 45 seconds after they are converted or downloaded. This ensures that clicking the download button again re-downloads the document. The reset state function was added to both ConvertButton.vue and DownloadButton.vue components.
2024-09-11 13:22:49 +02:00
f0d581b7f8 Merge branch '291_workflow_pdfsignedmessagehandler' into 'signature-app-master'
Lorsque tous les usagers ont signé un workflow, le workflow retourne à l’envoyeur avec une étape « workflow signé »

See merge request Chill-Projet/chill-bundles!726
2024-09-11 07:23:51 +00:00
nobohan
1197a46f5f Refactor PDF signature handling and add signature state changer
Simplified PdfSignedMessageHandler by delegating signature state changes to a new SignatureStepStateChanger class. Added utility method to EntityWorkflowStepSignature for checking pending signatures and created new test cases for the SignatureStepStateChanger.
2024-09-10 21:27:55 +02:00
00e878892e Merge branch '305-convert-to-pdf-on-signature-step' into 'signature-app-master'
Convert a document to pdf when an entity workflow arrives in a signature step

See merge request Chill-Projet/chill-bundles!724
2024-09-10 18:42:10 +00:00
941444b7d5 Add event subscriber to convert docs to PDF before signature
Introduce ConvertToPdfBeforeSignatureStepEventSubscriber to convert documents to PDF when reaching a signature step in the workflow. Includes tests to ensure the conversion process only triggers when necessary.
2024-09-10 17:48:32 +02:00
a60ea0e066 Add StoredObjectToPdfConverter service and unit tests
Introduced the StoredObjectToPdfConverter service to handle conversion of stored objects to PDF format. Added unit tests to ensure proper functionality, including versioning and exception handling.
2024-09-10 17:48:32 +02:00
1ddd283f26 Add signer type differentiation for workflows
Added a method to determine if the signer is a 'person' or 'user'. Updated the signature template to handle both types accordingly, ensuring the correct entity type is displayed in workflow signatures.
2024-09-10 17:48:32 +02:00
669b967899 Enhance object version removal to exclude point-in-time versions
Add a check to exclude versions associated with points in time before deleting old object versions. This ensures that such versions are not mistakenly removed, providing greater data integrity. Updated tests and repository methods accordingly.
2024-09-10 17:48:31 +02:00
d33da6519a Add StoredObjectPointInTime entity and related functionality
Implemented a new StoredObjectPointInTime entity to manage snapshots of stored objects. This includes related migrations, enum for reasons, repository, and integration with StoredObjectVersion.
2024-09-10 17:48:31 +02:00
f5ba5d574b Add WopiConverter service and update Collabora integration tests
Introduce the WopiConverter service to handle document-to-PDF conversion using Collabora Online. Extend and update related tests in WopiConvertToPdfTest and ConvertControllerTest for better coverage and reliability. Enhance the GitLab CI configuration to exclude new test category "collabora-integration".
2024-09-10 17:48:31 +02:00
ccc11b1c1d Merge branch '295-cancel-workflow-after-90-days' into 'signature-app-master'
Create CancelStaleWorkflow message, handler and cronjob

See merge request Chill-Projet/chill-bundles!720
2024-09-09 13:27:12 +00:00
nobohan
479a02bbc7 Signature: add and process returnPath in the signature app 2024-09-05 14:11:57 +02:00
nobohan
0d62d8d1c6 Signature - improve UI signature app 2024-09-05 11:52:11 +02:00
nobohan
5b90632231 Signature: add a signature zone manually 2024-09-05 11:52:08 +02:00
5d0b531820 Upgrade chill-bundles to v3.1.0 2024-08-30 10:59:04 +02:00
5be3cae288 Merge branch 'add_household_to_activity_list_export' into 'upgrade-sf5'
Add household info to activity exports

See merge request Chill-Projet/chill-bundles!721
2024-08-30 08:57:27 +00:00
4587f66402 Add household info to activity exports 2024-08-30 08:57:27 +00:00
2bef3c3878 french translation for the version 2.23.0 [ci-skip] 2024-07-19 15:32:45 +02:00
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
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
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
324 changed files with 14145 additions and 1539 deletions

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: Show only the current referrer in the page "show" for an accompanying period
workf
time: 2024-09-16T15:18:43.017401122+02:00
custom:
Issue: "308"

View File

@@ -1,4 +1,4 @@
## v2.23.0 - 2024-07-23
## v2.23.0 - 2024-07-23 & 2024-07-19
### Feature
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
@@ -6,6 +6,25 @@
* Upgrade CKEditor and refactor configuration with use of typescript
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed
* Fix resolving of centers for an household, which will fix in turn the access control
* Resolved type hinting error in activity list export
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.

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

@@ -0,0 +1,3 @@
## v2.24.0 - 2024-09-11
### Feature
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.

3
.changes/v3.1.0.md Normal file
View File

@@ -0,0 +1,3 @@
## v3.1.0 - 2024-08-30
### Feature
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.

2
.env
View File

@@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$'
###< symfony/framework-bundle ###
## Wopi server for editing documents online
WOPI_SERVER=http://collabora:9980
EDITOR_SERVER=http://collabora:9980
# must be manually set in .env.local
# ADMIN_PASSWORD=

View File

@@ -41,3 +41,5 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars
ASYNC_UPLOAD_TEMP_URL_KEY=
ASYNC_UPLOAD_TEMP_URL_BASE_PATH=
ASYNC_UPLOAD_TEMP_URL_CONTAINER=
EDITOR_SERVER=https://localhost:9980

View File

@@ -122,7 +122,7 @@ unit_tests:
- php tests/console chill:db:sync-views --env=test
- php -d memory_limit=2G tests/console cache:clear --env=test
- php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration
artifacts:
expire_in: 1 day
paths:

View File

@@ -6,14 +6,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v3.1.0 - 2024-08-30
### Feature
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.
## v3.0.0 - 2024-08-26
### Fixed
* Fix delete action for accompanying periods in draft state
* Fix connection to azure when making an calendar event in chill
* CollectionType js fixes for remove button and adding multiple entries
## v2.23.0 - 2024-07-23
## v2.24.0 - 2024-09-11
### Feature
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
## v2.23.0 - 2024-07-19 & 2024-07-23
### Feature
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address

View File

@@ -43,6 +43,7 @@
"symfony/dom-crawler": "^5.4",
"symfony/error-handler": "^5.4",
"symfony/event-dispatcher": "^5.4",
"symfony/event-dispatcher-contracts": "^2.4",
"symfony/expression-language": "^5.4",
"symfony/filesystem": "^5.4",
"symfony/finder": "^5.4",

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,9 +72,14 @@ 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];
}
}

View File

@@ -55,7 +55,7 @@
"mime": "^4.0.0",
"pdfjs-dist": "^4.3.136",
"vis-network": "^9.1.0",
"vue": "^3.2.37",
"vue": "^3.5.6",
"vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2",

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Export\Aggregator\PersonAggregators;
use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Repository\Household\HouseholdRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class HouseholdAggregator implements AggregatorInterface
{
public function __construct(private HouseholdRepository $householdRepository) {}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, mixed $data)
{
return function (int|string|null $value): string|int {
if ('_header' === $value) {
return 'export.aggregator.person.by_household.household';
}
if ('' === $value || null === $value || null === $household = $this->householdRepository->find($value)) {
return '';
}
return $household->getId();
};
}
public function getQueryKeys($data)
{
return ['activity_household_agg'];
}
public function getTitle()
{
return 'export.aggregator.person.by_household.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$qb->join(
HouseholdMember::class,
'activity_household_agg_household_member',
Join::WITH,
$qb->expr()->andX(
$qb->expr()->eq('activity_household_agg_household_member.person', 'activity.person'),
$qb->expr()->lte('activity_household_agg_household_member.startDate', 'activity.date'),
$qb->expr()->orX(
$qb->expr()->gte('activity_household_agg_household_member.endDate', 'activity.date'),
$qb->expr()->isNull('activity_household_agg_household_member.endDate')
)
)
);
$qb->join(
Household::class,
'activity_household_agg_household',
Join::WITH,
$qb->expr()->eq('activity_household_agg_household_member.household', 'activity_household_agg_household')
);
$qb
->addSelect('activity_household_agg_household.id AS activity_household_agg')
->addGroupBy('activity_household_agg');
}
public function applyOn()
{
return Declarations::ACTIVITY_PERSON;
}
}

View File

@@ -19,6 +19,7 @@ use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Export\ListInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Export\Declarations as PersonDeclarations;
use Doctrine\DBAL\Exception\InvalidArgumentException;
use Doctrine\ORM\EntityManagerInterface;
@@ -44,6 +45,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
'person_firstname',
'person_lastname',
'person_id',
'household_id',
];
private readonly bool $filterStatsByCenters;
@@ -189,19 +191,26 @@ class ListActivity implements ListInterface, GroupedExportInterface
{
$centers = array_map(static fn ($el) => $el['center'], $acl);
// throw an error if any fields are present
// throw an error if no fields are present
if (!\array_key_exists('fields', $data)) {
throw new InvalidArgumentException('Any fields have been checked.');
throw new InvalidArgumentException('No fields have been checked.');
}
$qb = $this->entityManager->createQueryBuilder();
$qb
->from('ChillActivityBundle:Activity', 'activity')
->join('activity.person', 'actperson');
->join('activity.person', 'person')
->join(
HouseholdMember::class,
'householdmember',
Query\Expr\Join::WITH,
'person = householdmember.person AND householdmember.startDate <= activity.date AND (householdmember.endDate IS NULL OR householdmember.endDate > activity.date)'
)
->join('householdmember.household', 'household');
if ($this->filterStatsByCenters) {
$qb->join('actperson.centerHistory', 'centerHistory');
$qb->join('person.centerHistory', 'centerHistory');
$qb->where(
$qb->expr()->andX(
$qb->expr()->lte('centerHistory.startDate', 'activity.date'),
@@ -224,17 +233,22 @@ class ListActivity implements ListInterface, GroupedExportInterface
break;
case 'person_firstname':
$qb->addSelect('actperson.firstName AS person_firstname');
$qb->addSelect('person.firstName AS person_firstname');
break;
case 'person_lastname':
$qb->addSelect('actperson.lastName AS person_lastname');
$qb->addSelect('person.lastName AS person_lastname');
break;
case 'person_id':
$qb->addSelect('actperson.id AS person_id');
$qb->addSelect('person.id AS person_id');
break;
case 'household_id':
$qb->addSelect('household.id AS household_id');
break;
@@ -284,7 +298,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
return ActivityStatsVoter::LISTS;
}
public function supportsModifiers()
public function supportsModifiers(): array
{
return [
Declarations::ACTIVITY,

View File

@@ -73,7 +73,7 @@ final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInt
$qb->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod"
'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = acp"
)
);

View File

@@ -39,7 +39,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
public function alterQuery(QueryBuilder $qb, $data): void
{
// create a subquery for activity
$sqb = $qb->getEntityManager()->createQueryBuilder();
@@ -121,7 +121,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
];
}
public function describeAction($data, $format = 'string')
public function describeAction($data, $format = 'string'): array
{
return [
[] === $data['reasons'] ?
@@ -141,7 +141,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
];
}
public function getTitle()
public function getTitle(): string
{
return 'export.filter.activity.person_between_dates.title';
}

View File

@@ -243,3 +243,7 @@ services:
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator:
tags:
- { name: chill.export_aggregator, alias: activity_person_agg }
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\HouseholdAggregator:
tags:
- { name: chill.export_aggregator, alias: activity_household_agg }

View File

@@ -428,6 +428,9 @@ export:
by_person:
title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré)
person: Usager
by_household:
title: Grouper les échanges par ménage
household: Identifiant ménage
acp:
by_activity_type:
title: Grouper les parcours par type d'échange

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)">
<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 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-sm-3 col-xs-12">
<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-sm-1 col-xs-12" style="text-align: center; font-size: x-large;">
<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-sm-3 col-xs-12" >
<div class="col-xs-12 col-sm-3 col-md-3">
<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') }}
<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>
<!-- 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

@@ -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\Controller;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
final readonly class DocumentAccompanyingCourseDuplicateController
{
public function __construct(
private Security $security,
private AccompanyingCourseDocumentDuplicator $documentWorkflowDuplicator,
private EntityManagerInterface $entityManager,
private UrlGeneratorInterface $urlGenerator,
) {}
#[Route('/{_locale}/doc-store/accompanying-course-document/{id}/duplicate', name: 'chill_doc_store_accompanying_course_document_duplicate')]
public function __invoke(AccompanyingCourseDocument $document, Request $request, Session $session): Response
{
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $document)) {
throw new AccessDeniedHttpException('not allowed to see this document');
}
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::CREATE, $document->getCourse())) {
throw new AccessDeniedHttpException('not allowed to create this document');
}
$duplicated = $this->documentWorkflowDuplicator->duplicate($document);
$this->entityManager->persist($duplicated);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate('accompanying_course_document_edit', ['id' => $duplicated->getId(), 'course' => $duplicated->getCourse()->getId()])
);
}
}

View File

@@ -201,36 +201,4 @@ class DocumentPersonController extends AbstractController
['document' => $document, 'person' => $person]
);
}
#[Route(path: '/{id}/signature', name: 'person_document_signature', methods: 'GET')]
public function signature(Person $person, PersonDocument $document): Response
{
$this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person);
$this->denyAccessUnlessGranted('CHILL_PERSON_DOCUMENT_SEE', $document);
$event = new PrivacyEvent($person, [
'element_class' => PersonDocument::class,
'element_id' => $document->getId(),
'action' => 'show',
]);
$this->eventDispatcher->dispatch($event, PrivacyEvent::PERSON_PRIVACY_EVENT);
$storedObject = $document->getObject();
$content = $this->storedObjectManagerInterface->read($storedObject);
$zones = $this->PDFSignatureZoneParser->findSignatureZones($content);
$signature = [];
$signature['id'] = 1;
$signature['storedObject'] = [ // TEMP
'filename' => $storedObject->getFilename(),
'iv' => $storedObject->getIv(),
'keyInfos' => $storedObject->getKeyInfos(),
];
$signature['zones'] = $zones;
return $this->render(
'@ChillDocStore/PersonDocument/signature.html.twig',
['document' => $document, 'person' => $person, 'signature' => $signature]
);
}
}

View File

@@ -15,12 +15,19 @@ use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessa
use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SignatureRequestController
{
@@ -28,12 +35,24 @@ class SignatureRequestController
private readonly MessageBusInterface $messageBus,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly ChillEntityRenderManagerInterface $entityRender,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
) {}
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse
{
if (!$this->security->isGranted(EntityWorkflowStepSignatureVoter::SIGN, $signature)) {
throw new AccessDeniedHttpException('not authorized to sign this step');
}
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
return new JsonResponse([], status: Response::HTTP_CONFLICT);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
$content = $this->storedObjectManager->read($storedObject);
@@ -51,8 +70,14 @@ class SignatureRequestController
$signature->getId(),
$zone,
$data['zone']['index'],
'test signature', // reason (string)
'Mme Caroline Diallo', // signerText (string)
'Signed by IP: '.(string) $request->getClientIp().', authenticated user: '.$this->entityRender->renderString($this->security->getUser(), []),
$this->entityRender->renderString($signature->getSigner(), [
// options for user render
'absence' => false,
'main_scope' => false,
// options for person render
'addAge' => false,
]),
$content
));
@@ -62,6 +87,16 @@ class SignatureRequestController
#[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')]
public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse
{
return new JsonResponse($signature->getState(), JsonResponse::HTTP_OK, []);
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
return new JsonResponse(
[
'state' => $signature->getState(),
'storedObject' => $this->normalizer->normalize($storedObject, 'json'),
],
JsonResponse::HTTP_OK,
[]
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class StoredObjectRestoreVersionApiController
{
public function __construct(private Security $security, private StoredObjectRestoreInterface $storedObjectRestore, private EntityManagerInterface $entityManager, private SerializerInterface $serializer) {}
#[Route('/api/1.0/doc-store/stored-object/restore-from-version/{id}', methods: ['POST'])]
public function restoreStoredObjectVersion(StoredObjectVersion $storedObjectVersion): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObjectVersion->getStoredObject())) {
throw new AccessDeniedHttpException('not allowed to edit the stored object');
}
$newVersion = $this->storedObjectRestore->restore($storedObjectVersion);
$this->entityManager->persist($newVersion);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]),
json: true
);
}
}

View File

@@ -0,0 +1,69 @@
<?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\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class StoredObjectVersionApiController
{
public function __construct(
private PaginatorFactoryInterface $paginatorFactory,
private SerializerInterface $serializer,
private Security $security,
) {}
/**
* Lists the versions of the specified stored object.
*
* @param StoredObject $storedObject the stored object whose versions are to be listed
*
* @return JsonResponse a JSON response containing the serialized versions of the stored object, encapsulated in a collection
*
* @throws AccessDeniedHttpException if the user is not allowed to see the stored object
*/
#[Route('/api/1.0/doc-store/stored-object/{uuid}/versions', name: 'chill_doc_store_stored_object_versions_list')]
public function listVersions(StoredObject $storedObject): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException('not allowed to see this stored object');
}
$total = $storedObject->getVersions()->count();
$paginator = $this->paginatorFactory->create($total);
$criteria = Criteria::create();
$criteria->orderBy(['id' => Order::Ascending]);
$criteria->setMaxResults($paginator->getItemsPerPage())->setFirstResult($paginator->getCurrentPageFirstItemNumber());
$items = $storedObject->getVersions()->matching($criteria);
return new JsonResponse(
$this->serializer->serialize(
new Collection($items, $paginator),
'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
),
json: true
);
}
}

View File

@@ -18,6 +18,9 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
@@ -89,10 +92,10 @@ class StoredObject implements Document, TrackCreationInterface
private string $generationErrors = '';
/**
* @var Collection<int, StoredObjectVersion>
* @var Collection<int, StoredObjectVersion>&Selectable<int, StoredObjectVersion>
*/
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
private Collection $versions;
private Collection&Selectable $versions;
/**
* @param StoredObject::STATUS_* $status
@@ -256,11 +259,33 @@ class StoredObject implements Document, TrackCreationInterface
return $this->template;
}
public function getVersions(): Collection
/**
* @return Selectable<int, StoredObjectVersion>&Collection<int, StoredObjectVersion>
*/
public function getVersions(): Collection&Selectable
{
return $this->versions;
}
/**
* Retrieves versions sorted by a given order.
*
* @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending
*
* @return readableCollection&Selectable The ordered collection of versions
*/
public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable
{
$versions = $this->getVersions()->toArray();
match ($order) {
'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()),
'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()),
};
return new ArrayCollection($versions);
}
public function hasCurrentVersion(): bool
{
return null !== $this->getCurrentVersion();
@@ -271,6 +296,47 @@ class StoredObject implements Document, TrackCreationInterface
return null !== $this->template;
}
/**
* Checks if there is a version kept before conversion.
*
* @return bool true if a version is kept before conversion, false otherwise
*/
public function hasKeptBeforeConversionVersion(): bool
{
foreach ($this->getVersions() as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return true;
}
}
}
return false;
}
/**
* Retrieves the last version of the stored object that was kept before conversion.
*
* This method iterates through the ordered versions and their respective points
* in time to find the most recent version that has a point in time with the reason
* 'KEEP_BEFORE_CONVERSION'.
*
* @return StoredObjectVersion|null the version that was kept before conversion,
* or null if not found
*/
public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion
{
foreach ($this->getVersionsOrdered('DESC') as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return $version;
}
}
}
return null;
}
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
{
$this->template = $template;

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Mapping as ORM;
/**
* Represents a snapshot of a stored object at a specific point in time.
*
* This entity tracks versions of stored objects, reasons for the snapshot,
* and the user who initiated the action.
*/
#[ORM\Entity]
#[ORM\Table(name: 'stored_object_point_in_time', schema: 'chill_doc')]
class StoredObjectPointInTime implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')]
#[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)]
private StoredObjectVersion $objectVersion,
#[ORM\Column(name: 'reason', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, enumType: StoredObjectPointInTimeReasonEnum::class)]
private StoredObjectPointInTimeReasonEnum $reason,
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $byUser = null,
) {
$this->objectVersion->addPointInTime($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getByUser(): ?User
{
return $this->byUser;
}
public function getObjectVersion(): StoredObjectVersion
{
return $this->objectVersion;
}
public function getReason(): StoredObjectPointInTimeReasonEnum
{
return $this->reason;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Entity;
enum StoredObjectPointInTimeReasonEnum: string
{
case KEEP_BEFORE_CONVERSION = 'keep-before-conversion';
case KEEP_BY_USER = 'keep-by-user';
}

View File

@@ -13,6 +13,9 @@ namespace Chill\DocStoreBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Random\RandomException;
@@ -39,6 +42,31 @@ class StoredObjectVersion implements TrackCreationInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $filename = '';
/**
* @var Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
*/
#[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection&Selectable $pointInTimes;
/**
* Previous storedObjectVersion, from which the current stored object version is created.
*
* If null, the current stored object version is generated by other means.
*
* Those version may be associated with the same storedObject, or not. In this last case, that means that
* the stored object's current version is created from another stored object version.
*/
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class)]
private ?StoredObjectVersion $createdFrom = null;
/**
* List of stored object versions created from the current version.
*
* @var Collection<int, StoredObjectVersion>
*/
#[ORM\OneToMany(mappedBy: 'createdFrom', targetEntity: StoredObjectVersion::class)]
private Collection $children;
public function __construct(
/**
* The stored object associated with this version.
@@ -77,6 +105,8 @@ class StoredObjectVersion implements TrackCreationInterface
?string $filename = null,
) {
$this->filename = $filename ?? self::generateFilename($this);
$this->pointInTimes = new ArrayCollection();
$this->children = new ArrayCollection();
}
public static function generateFilename(StoredObjectVersion $storedObjectVersion): string
@@ -124,4 +154,76 @@ class StoredObjectVersion implements TrackCreationInterface
{
return $this->version;
}
/**
* @return Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
*/
public function getPointInTimes(): Selectable&Collection
{
return $this->pointInTimes;
}
public function hasPointInTimes(): bool
{
return $this->pointInTimes->count() > 0;
}
/**
* @internal use @see{StoredObjectPointInTime} constructor instead
*/
public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
{
if (!$this->pointInTimes->contains($storedObjectPointInTime)) {
$this->pointInTimes->add($storedObjectPointInTime);
}
return $this;
}
public function removePointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
{
if ($this->pointInTimes->contains($storedObjectPointInTime)) {
$this->pointInTimes->removeElement($storedObjectPointInTime);
}
return $this;
}
public function getCreatedFrom(): ?StoredObjectVersion
{
return $this->createdFrom;
}
public function setCreatedFrom(?StoredObjectVersion $createdFrom): StoredObjectVersion
{
if (null === $createdFrom && null !== $this->createdFrom) {
$this->createdFrom->removeChild($this);
}
$createdFrom?->addChild($this);
$this->createdFrom = $createdFrom;
return $this;
}
public function addChild(StoredObjectVersion $child): self
{
if (!$this->children->contains($child)) {
$this->children->add($child);
}
return $this;
}
public function removeChild(StoredObjectVersion $child): self
{
$result = $this->children->removeElement($child);
if (false === $result) {
throw new \UnexpectedValueException('the child is not associated with the current stored object version');
}
return $this;
}
}

View File

@@ -23,7 +23,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
{
private readonly EntityRepository $repository;
public function __construct(private readonly EntityManagerInterface $em)
public function __construct(EntityManagerInterface $em)
{
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<StoredObjectPointInTime>
*/
class StoredObjectPointInTimeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StoredObjectPointInTime::class);
}
}

View File

@@ -62,7 +62,7 @@ class StoredObjectVersionRepository implements ObjectRepository
*
* @return iterable returns an iterable with the IDs of the versions
*/
public function findIdsByVersionsOlderThanDateAndNotLastVersion(\DateTimeImmutable $beforeDate): iterable
public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable
{
$results = $this->connection->executeQuery(
self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION,
@@ -83,6 +83,8 @@ class StoredObjectVersionRepository implements ObjectRepository
sov.createdat < ?::timestamp
AND
sov.version < (SELECT MAX(sub_sov.version) FROM chill_doc.stored_object_version sub_sov WHERE sub_sov.stored_object_id = sov.stored_object_id)
AND
NOT EXISTS (SELECT 1 FROM chill_doc.stored_object_point_in_time sub_poi WHERE sub_poi.stored_object_version_id = sov.id)
SQL;
public function getClassName(): string

View File

@@ -0,0 +1,27 @@
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import {createApp} from "vue";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {defineComponent} from "vue";
import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll<HTMLDivElement>('div[data-download-button-single]').forEach((el) => {
const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject;
const title = el.dataset.title as string;
const app = createApp({
components: {DownloadButton},
data() {
return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}};
},
template: '<download-button :stored-object="storedObject" :at-version="storedObject.currentVersion" :classes="classes" :filename="title" :direct-download="true"></download-button>',
});
app.use(i18n).use(ToastPlugin).mount(el);
});
});

View File

@@ -3,6 +3,7 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v
import {createApp} from "vue";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({});
@@ -48,6 +49,6 @@ window.addEventListener('DOMContentLoaded', function (e) {
}
});
app.use(i18n).mount(el);
app.use(i18n).use(ToastPlugin).mount(el);
})
});

View File

@@ -1,100 +1,132 @@
import {DateTime, User} from "../../../ChillMainBundle/Resources/public/types";
import {
DateTime,
User,
} from "../../../ChillMainBundle/Resources/public/types";
import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers";
export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending";
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
export interface StoredObject {
id: number,
title: string|null,
uuid: string,
prefix: string,
status: StoredObjectStatus,
currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted,
totalVersions: number,
datas: object,
id: number;
title: string | null;
uuid: string;
prefix: string;
status: StoredObjectStatus;
currentVersion:
| null
| StoredObjectVersionCreated
| StoredObjectVersionPersisted;
totalVersions: number;
datas: object;
/** @deprecated */
creationDate: DateTime,
createdAt: DateTime|null,
createdBy: User|null,
creationDate: DateTime;
createdAt: DateTime | null;
createdBy: User | null;
_permissions: {
canEdit: boolean,
canSee: boolean,
},
canEdit: boolean;
canSee: boolean;
};
_links?: {
dav_link?: {
href: string
expiration: number
},
},
href: string;
expiration: number;
};
downloadLink?: SignedUrlGet;
};
}
export interface StoredObjectVersion {
/**
* filename of the object in the object storage
*/
filename: string,
iv: number[],
keyInfos: JsonWebKey,
type: string,
filename: string;
iv: number[];
keyInfos: JsonWebKey;
type: string;
}
export interface StoredObjectVersionCreated extends StoredObjectVersion {
persisted: false,
persisted: false;
}
export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated {
version: number,
id: number,
createdAt: DateTime|null,
createdBy: User|null,
export interface StoredObjectVersionPersisted
extends StoredObjectVersionCreated {
version: number;
id: number;
createdAt: DateTime | null;
createdBy: User | null;
}
export interface StoredObjectStatusChange {
id: number,
filename: string,
status: StoredObjectStatus,
type: string,
id: number;
filename: string;
status: StoredObjectStatus;
type: string;
}
export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted {
"point-in-times": StoredObjectPointInTime[];
"from-restored": StoredObjectVersionPersisted|null;
}
export interface StoredObjectPointInTime {
id: number;
byUser: User | null;
reason: 'keep-before-conversion'|'keep-by-user';
}
/**
* Function executed by the WopiEditButton component.
*/
export type WopiEditButtonExecutableBeforeLeaveFunction = {
(): Promise<void>
}
(): 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,
method: "POST";
max_file_size: number;
max_file_count: 1;
expires: number;
submit_delay: 180;
redirect: string;
prefix: string;
url: string;
signature: string;
}
export interface PDFPage {
index: number,
width: number,
height: number,
index: number;
width: number;
height: number;
}
export interface SignatureZone {
index: number,
x: number,
y: number,
width: number,
height: number,
PDFPage: PDFPage,
index: number | null;
x: number;
y: number;
width: number;
height: number;
PDFPage: PDFPage;
}
export interface Signature {
id: number,
storedObject: StoredObject,
zones: SignatureZone[],
id: number;
storedObject: StoredObject;
zones: SignatureZone[];
}
export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error';
export type SignedState =
| "pending"
| "signed"
| "rejected"
| "canceled"
| "error";
export interface CheckSignature {
state: SignedState;
storedObject: StoredObject;
}
export type CanvasEvent = "select" | "add";

View File

@@ -14,7 +14,10 @@
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
<li v-if="isDownloadable">
<download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
<download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}" :display-action-string-in-button="true"></download-button>
</li>
<li v-if="isHistoryViewable">
<history-button :stored-object="props.storedObject" :can-edit="canEdit && props.storedObject._permissions.canEdit"></history-button>
</li>
</ul>
</div>
@@ -40,6 +43,7 @@ import {
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
@@ -126,7 +130,11 @@ const isConvertibleToPdf = computed<boolean>(() => {
&& is_extension_viewable(props.storedObject.currentVersion.type)
&& props.storedObject.currentVersion.type !== 'application/pdf'
&& props.storedObject.currentVersion.persisted !== false;
})
});
const isHistoryViewable = computed<boolean>(() => {
return props.storedObject.status === 'ready';
});
const checkForReady = function(): void {
if (

View File

@@ -26,37 +26,9 @@
</template>
</modal>
</teleport>
<div class="col-12">
<div
class="row justify-content-center mb-2"
v-if="signature.zones.length > 1"
>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_sign_zone") }}
</button>
</div>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_sign_zone") }}
</button>
</div>
</div>
<div
id="turn-page"
class="row justify-content-center mb-2"
v-if="pageCount > 1"
>
<div class="col-6-sm col-3-md text-center">
<div class="col-12 m-auto">
<div class="row justify-content-center border-bottom pdf-tools d-md-none">
<div v-if="pageCount > 1" class="col text-center turn-page">
<button
class="btn btn-light btn-sm"
:disabled="page <= 1"
@@ -64,7 +36,7 @@
>
</button>
<span>page {{ page }} / {{ pageCount }}</span>
<span>{{ page }}/{{ pageCount }}</span>
<button
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@@ -73,15 +45,161 @@
</button>
</div>
<div v-if="signature.zones.length > 1" class="col-3 p-0">
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
</div>
<div v-if="signature.zones.length > 1" class="col-3 p-0">
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div class="col text-end p-0">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
:title="$t('choose_another_signature')"
>
{{ $t("another_zone") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
</div>
<div class="col-1" v-if="signedState !== 'signed'">
<button
class="btn btn-create btn-sm"
:class="{ active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
></button>
</div>
</div>
<div class="col-12 text-center">
<div
class="row justify-content-center border-bottom pdf-tools d-none d-md-flex"
>
<div v-if="pageCount > 1" class="col-2 text-center turn-page p-0">
<button
class="btn btn-light btn-sm"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-end d-xl-none"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-start d-xl-none"
>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-end d-none d-xl-flex p-0"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_sign_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-start d-none d-xl-flex p-0"
>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_sign_zone") }}
</button>
</div>
<div class="col text-end p-0" v-if="signedState !== 'signed'">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
>
{{ $t("choose_another_signature") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
</div>
<div
class="col text-end p-0 pe-2 pe-xxl-4"
v-if="signedState !== 'signed'"
>
<button
class="btn btn-create btn-sm"
:class="{ active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
{{ $t("add_zone") }}
</button>
</div>
</div>
</div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center">
<canvas class="m-auto" id="canvas"></canvas>
</div>
<div class="col-12 p-4" id="action-buttons" v-if="signedState !== 'signed'">
<div class="col-xs-12 col-md-12 col-lg-9 m-auto p-4" id="action-buttons">
<div class="row">
<div class="col-6">
<div class="col-4" v-if="signedState !== 'signed'">
<button
class="btn btn-action me-2"
:disabled="!userSignatureZone"
@@ -90,26 +208,18 @@
{{ $t("sign") }}
</button>
</div>
<div class="col-6 d-flex justify-content-end">
<button
class="btn btn-misc me-2"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
<div class="col-4" v-else></div>
<div class="col-8 d-flex justify-content-end">
<a
class="btn btn-delete"
v-if="signedState !== 'signed'"
:href="getReturnPath()"
>
{{ $t("choose_another_signature") }}
</button>
<button
class="btn btn-misc me-2"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
<button class="btn btn-delete" @click="undoSign">
{{ $t("cancel_signing") }}
</button>
</a>
<a class="btn btn-misc" v-else :href="getReturnPath()">
{{ $t("return") }}
</a>
</div>
</div>
</div>
@@ -119,7 +229,13 @@
import { ref, Ref, reactive } from "vue";
import { useToast } from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css";
import { Signature, SignatureZone, SignedState } from "../../types";
import {
CanvasEvent,
CheckSignature,
Signature,
SignatureZone,
SignedState,
} from "../../types";
import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import * as pdfjsLib from "pdfjs-dist";
import {
@@ -135,19 +251,18 @@ console.log(PdfWorker); // incredible but this is needed
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {
download_and_decrypt_doc,
} from "../StoredObjectButton/helpers";
import { download_and_decrypt_doc } from "../StoredObjectButton/helpers";
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
const modalOpen: Ref<boolean> = ref(false);
const loading: Ref<boolean> = ref(false);
const adding: Ref<boolean> = ref(false);
const canvasEvent: Ref<CanvasEvent> = ref("select");
const signedState: Ref<SignedState> = ref("pending");
const page: Ref<number> = ref(1);
const pageCount: Ref<number> = ref(0);
let userSignatureZone: Ref<null | SignatureZone> = ref(null);
let pdfSource: Ref<string> = ref("");
let pdf = {} as PDFDocumentProxy;
declare global {
@@ -160,11 +275,13 @@ const $toast = useToast();
const signature = window.signature;
console.log(signature);
const mountPdf = async (url: string) => {
const loadingTask = pdfjsLib.getDocument(url);
pdf = await loadingTask.promise;
pageCount.value = pdf.numPages;
await setPage(1);
await setPage(page.value);
};
const getRenderContext = (pdfPage: PDFPageProxy) => {
@@ -187,59 +304,61 @@ const setPage = async (page: number) => {
await pdfPage.render(renderContext);
};
const init = () => downloadAndOpen().then(initPdf);
async function downloadAndOpen(): Promise<Blob> {
let raw;
try {
raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion);
raw = await download_and_decrypt_doc(
signature.storedObject,
signature.storedObject.currentVersion
);
} catch (e) {
console.error("error while downloading and decrypting document", e);
throw e;
}
await mountPdf(URL.createObjectURL(raw));
initPdf();
return raw;
}
const initPdf = () => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener(
"pointerup",
(e: PointerEvent) => canvasClick(e, canvas),
false
);
setTimeout(() => addZones(page.value), 800);
canvas.addEventListener("pointerup", canvasClick, false);
setTimeout(() => drawAllZones(page.value), 800);
};
const scaleXToCanvas = (x: number, canvasWidth: number, PDFWidth: number) =>
Math.round((x * canvasWidth) / PDFWidth);
const scaleYToCanvas = (h: number, canvasHeight: number, PDFHeight: number) =>
Math.round((h * canvasHeight) / PDFHeight);
const hitSignature = (
zone: SignatureZone,
xy: number[],
canvasWidth: number,
canvasHeight: number
) => {
const scaleXToCanvas = (x: number) =>
Math.round((x * canvasWidth) / zone.PDFPage.width);
const scaleHeightToCanvas = (h: number) =>
Math.round((h * canvasHeight) / zone.PDFPage.height);
const scaleYToCanvas = (y: number) =>
Math.round(zone.PDFPage.height - scaleHeightToCanvas(y));
return (
scaleXToCanvas(zone.x) < xy[0] &&
xy[0] < scaleXToCanvas(zone.x + zone.width) &&
scaleYToCanvas(zone.y) < xy[1] &&
xy[1] < scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height)
);
};
) =>
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] &&
xy[0] <
scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) &&
zone.PDFPage.height -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) <
xy[1] &&
xy[1] <
scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) +
zone.PDFPage.height;
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
userSignatureZone.value = z;
const ctx = canvas.getContext("2d");
if (ctx) {
setPage(page.value);
setTimeout(() => drawZone(z, ctx, canvas.width, canvas.height), 200);
setTimeout(() => drawAllZones(page.value), 200);
}
};
const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) =>
const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) =>
signature.zones
.filter((z) => z.PDFPage.index + 1 === page.value)
.map((z) => {
@@ -256,11 +375,18 @@ const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) =>
}
});
const canvasClick = (e: PointerEvent) => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvasEvent.value === "select"
? selectZoneEvent(e, canvas)
: addZoneEvent(e, canvas);
};
const turnPage = async (upOrDown: number) => {
userSignatureZone.value = null;
//userSignatureZone.value = null; // desactivate the reset of the zone when turning page
page.value = page.value + upOrDown;
await setPage(page.value);
setTimeout(() => addZones(page.value), 200);
setTimeout(() => drawAllZones(page.value), 200);
};
const turnSignature = async (upOrDown: number) => {
@@ -290,12 +416,6 @@ const drawZone = (
) => {
const unselectedBlue = "#007bff";
const selectedBlue = "#034286";
const scaleXToCanvas = (x: number) =>
Math.round((x * canvasWidth) / zone.PDFPage.width);
const scaleHeightToCanvas = (h: number) =>
Math.round((h * canvasHeight) / zone.PDFPage.height);
const scaleYToCanvas = (y: number) =>
Math.round(zone.PDFPage.height - scaleHeightToCanvas(y));
ctx.strokeStyle =
userSignatureZone.value?.index === zone.index
? selectedBlue
@@ -303,16 +423,22 @@ const drawZone = (
ctx.lineWidth = 2;
ctx.lineJoin = "bevel";
ctx.strokeRect(
scaleXToCanvas(zone.x),
scaleYToCanvas(zone.y),
scaleXToCanvas(zone.width),
scaleHeightToCanvas(zone.height)
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width),
zone.PDFPage.height -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height),
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width),
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height)
);
ctx.font = "bold 16px serif";
ctx.textAlign = "center";
ctx.fillStyle = "black";
const xText = scaleXToCanvas(zone.x) + scaleXToCanvas(zone.width) / 2;
const yText = scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height) / 2;
const xText =
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) +
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width) / 2;
const yText =
zone.PDFPage.height -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) +
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height) / 2;
if (userSignatureZone.value?.index === zone.index) {
ctx.fillStyle = selectedBlue;
ctx.fillText("Signer ici", xText, yText);
@@ -320,27 +446,33 @@ const drawZone = (
ctx.fillStyle = unselectedBlue;
ctx.fillText("Choisir cette", xText, yText - 12);
ctx.fillText("zone de signature", xText, yText + 12);
// ctx.strokeStyle = "#c6c6c6"; // halo
// ctx.strokeText("Choisir cette", xText, yText - 12);
// ctx.strokeText("zone de signature", xText, yText + 12);
}
};
const addZones = (page: number) => {
const drawAllZones = (page: number) => {
const canvas = document.querySelectorAll("canvas")[0];
const ctx = canvas.getContext("2d");
if (ctx) {
if (ctx && signedState.value !== "signed") {
signature.zones
.filter((z) => z.PDFPage.index + 1 === page)
.map((z) => drawZone(z, ctx, canvas.width, canvas.height));
.map((z) => {
if (userSignatureZone.value) {
if (userSignatureZone.value?.index === z.index) {
drawZone(z, ctx, canvas.width, canvas.height);
}
} else {
drawZone(z, ctx, canvas.width, canvas.height);
}
});
}
};
const checkSignature = () => {
const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
return makeFetch("GET", url)
return makeFetch<null, CheckSignature>("GET", url)
.then((r) => {
signedState.value = r as SignedState;
signedState.value = r.state;
signature.storedObject = r.storedObject;
checkForReady();
})
.catch((error) => {
@@ -414,22 +546,66 @@ const confirmSign = () => {
};
const undoSign = async () => {
// const canvas = document.querySelectorAll("canvas")[0];
// const ctx = canvas.getContext("2d");
// if (ctx && userSignatureZone.value) {
// //drawZone(userSignatureZone.value, ctx, canvas.width, canvas.height);
// }
signature.zones = signature.zones.filter((z) => z.index !== null);
await setPage(page.value);
setTimeout(() => addZones(page.value), 200);
setTimeout(() => drawAllZones(page.value), 200);
userSignatureZone.value = null;
adding.value = false;
canvasEvent.value = "select";
};
downloadAndOpen();
const toggleAddZone = () => {
canvasEvent.value === "select"
? (canvasEvent.value = "add")
: (canvasEvent.value = "select");
};
const addZoneEvent = async (e: PointerEvent, canvas: HTMLCanvasElement) => {
const BOX_WIDTH = 180;
const BOX_HEIGHT = 90;
const PDFPageHeight = canvas.height;
const PDFPageWidth = canvas.width;
const x = e.offsetX;
const y = e.offsetY;
const newZone: SignatureZone = {
index: null,
x:
scaleXToCanvas(x, canvas.width, PDFPageWidth) -
scaleXToCanvas(BOX_WIDTH / 2, canvas.width, PDFPageWidth),
y:
PDFPageHeight -
scaleYToCanvas(y, canvas.height, PDFPageHeight) +
scaleYToCanvas(BOX_HEIGHT / 2, canvas.height, PDFPageHeight),
width: BOX_WIDTH,
height: BOX_HEIGHT,
PDFPage: {
index: page.value - 1,
width: PDFPageWidth,
height: PDFPageHeight,
},
};
signature.zones.push(newZone);
userSignatureZone.value = newZone;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
canvasEvent.value = "select";
adding.value = true;
};
const getReturnPath = () =>
window.location.search
? window.location.search.split("?returnPath=")[1] ??
window.location.pathname
: window.location.pathname;
init();
</script>
<style scoped lang="scss">
#canvas {
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1);
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
}
div#action-buttons {
position: sticky;
@@ -437,7 +613,15 @@ div#action-buttons {
background-color: white;
z-index: 100;
}
div#turn-page {
div.pdf-tools {
background-color: #f3f3f3;
font-size: 0.8rem;
@media (min-width: 1400px) {
// background: none;
// border: none !important;
}
}
div.turn-page {
span {
font-size: 0.8rem;
margin: 0 0.4rem;

View File

@@ -10,13 +10,20 @@ const appMessages = {
you_are_going_to_sign: 'Vous allez signer le document',
signature_confirmation: 'Confirmation de la signature',
sign: 'Signer',
choose_another_signature: 'Choisir une autre zone de signature',
choose_another_signature: 'Choisir une autre zone',
cancel: 'Annuler',
cancel_signing: 'Refuser de signer',
last_sign_zone: 'Zone de signature précédente',
next_sign_zone: 'Zone de signature suivante',
add_sign_zone: 'Ajouter une zone de signature',
last_zone: 'Zone précédente',
next_zone: 'Zone suivante',
add_zone: 'Ajouter une zone',
another_zone: 'Autre zone',
electronic_signature_in_progress: 'Signature électronique en cours...',
loading: 'Chargement...'
loading: 'Chargement...',
remove_sign_zone: 'Enlever la zone',
return: 'Retour',
}
}

View File

@@ -3,6 +3,7 @@
import {StoredObject, StoredObjectVersionCreated} from "../../types";
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
import {computed, ref, Ref} from "vue";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
interface DropFileConfig {
existingDoc?: StoredObject,
@@ -16,6 +17,7 @@ const emit = defineEmits<{
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;
@@ -77,6 +79,7 @@ const onFileChange = async (event: Event): Promise<void> => {
const handleFile = async (file: File): Promise<void> => {
uploading.value = true;
display_filename.value = file.name;
const type = file.type;
// create a stored_object if not exists
@@ -108,18 +111,11 @@ const handleFile = async (file: File): Promise<void> => {
<template>
<div class="drop-file">
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<p v-if="has_existing_doc">
<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 v-if="has_existing_doc" class="file-icon">
<file-icon :type="props.existingDoc?.type"></file-icon>
</p>
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
<!-- todo i18n -->
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
@@ -135,9 +131,18 @@ const handleFile = async (file: File): Promise<void> => {
.drop-file {
width: 100%;
.file-icon {
font-size: xx-large;
}
.display-filename {
font-variant: small-caps;
font-weight: 200;
}
& > .area, & > .waiting {
width: 100%;
height: 8rem;
height: 10rem;
display: flex;
flex-direction: column;
@@ -158,4 +163,5 @@ const handleFile = async (file: File): Promise<void> => {
}
}
}
</style>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
interface FileIconConfig {
type: string;
}
const props = defineProps<FileIconConfig>();
</script>
<template>
<i class="fa fa-file-pdf-o" v-if="props.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/msword'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.ms-excel'"></i>
<i class="fa fa-file-image-o" v-else-if="props.type === 'image/jpeg'"></i>
<i class="fa fa-file-image-o" v-else-if="props.type === 'image/png'"></i>
<i class="fa fa-file-archive-o" v-else-if="props.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,5 +1,5 @@
<template>
<a :class="props.classes" @click="download_and_open($event)">
<a :class="props.classes" @click="download_and_open($event)" ref="btn">
<i class="fa fa-file-pdf-o"></i>
Télécharger en pdf
</a>
@@ -9,7 +9,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive} from "vue";
import {reactive, ref} from "vue";
import {StoredObject} from "../../types";
interface ConvertButtonConfig {
@@ -24,6 +24,7 @@ interface DownloadButtonState {
const props = defineProps<ConvertButtonConfig>();
const state: DownloadButtonState = reactive({content: null});
const btn = ref<HTMLAnchorElement | null>(null);
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
@@ -41,6 +42,14 @@ async function download_and_open(event: Event): Promise<void> {
}
button.click();
const reset_pending = setTimeout(reset_state, 45000);
}
function reset_state(): void {
state.content = null;
btn.value?.removeAttribute('download');
btn.value?.removeAttribute('href');
btn.value?.removeAttribute('type');
}
</script>

View File

@@ -1,11 +1,11 @@
<template>
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)">
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open()" title="T&#233;l&#233;charger">
<i class="fa fa-download"></i>
Télécharger
<template v-if="displayActionStringInButton">Télécharger</template>
</a>
<a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button">
<a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button" title="Ouvrir">
<i class="fa fa-external-link"></i>
Ouvrir
<template v-if="displayActionStringInButton">Ouvrir</template>
</a>
</template>
@@ -20,6 +20,15 @@ interface DownloadButtonConfig {
atVersion: StoredObjectVersion,
classes: { [k: string]: boolean },
filename?: string,
/**
* if true, display the action string into the button. If false, displays only
* the icon
*/
displayActionStringInButton?: boolean,
/**
* if true, will download directly the file on load
*/
directDownload?: boolean,
}
interface DownloadButtonState {
@@ -28,13 +37,17 @@ interface DownloadButtonState {
href_url: string,
}
const props = defineProps<DownloadButtonConfig>();
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true, directDownload: false});
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
const open_button = ref<HTMLAnchorElement | null>(null);
function buildDocumentName(): string {
const document_name = props.filename ?? props.storedObject.title ?? 'document';
let document_name = props.filename ?? props.storedObject.title;
if ('' === document_name) {
document_name = 'document';
}
const ext = mime.getExtension(props.atVersion.type);
@@ -45,9 +58,7 @@ function buildDocumentName(): string {
return document_name;
}
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
async function download_and_open(): Promise<void> {
if (state.is_running) {
console.log('state is running, aborting');
return;
@@ -74,13 +85,33 @@ async function download_and_open(event: Event): Promise<void> {
state.is_running = false;
state.is_ready = true;
if (!props.directDownload) {
await nextTick();
open_button.value?.click();
console.log('open button should have been clicked');
setTimeout(reset_state, 45000);
}
}
function reset_state(): void {
state.href_url = '#';
state.is_ready = false;
state.is_running = false;
}
onMounted(() => {
if (props.directDownload) {
download_and_open();
}
});
</script>
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
i.fa {
margin-right: 0.5rem;
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import HistoryButtonModal from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue";
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../types";
import {computed, reactive, ref, useTemplateRef} from "vue";
import {get_versions} from "./HistoryButton/api";
interface HistoryButtonConfig {
storedObject: StoredObject;
canEdit: boolean;
}
interface HistoryButtonState {
versions: StoredObjectVersionWithPointInTime[];
loaded: boolean;
}
const props = defineProps<HistoryButtonConfig>();
const state = reactive<HistoryButtonState>({versions: [], loaded: false});
const modal = useTemplateRef<typeof HistoryButtonModal>('modal');
const download_version_and_open_modal = async function (): Promise<void> {
if (null !== modal.value) {
modal.value.open();
} else {
console.log("modal is null");
}
if (!state.loaded) {
const versions = await get_versions(props.storedObject);
for (const version of versions) {
state.versions.push(version);
}
state.loaded = true;
}
}
const onRestoreVersion = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
state.versions.unshift(newVersion);
}
</script>
<template>
<a @click="download_version_and_open_modal" class="dropdown-item">
<history-button-modal ref="modal" :versions="state.versions" :stored-object="storedObject" :can-edit="canEdit" @restore-version="onRestoreVersion"></history-button-modal>
<i class="fa fa-history"></i>
Historique
</a>
</template>
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
import HistoryButtonListItem from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue";
import {computed, reactive} from "vue";
interface HistoryButtonListConfig {
versions: StoredObjectVersionWithPointInTime[];
storedObject: StoredObject;
canEdit: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
interface HistoryButtonListState {
/**
* Contains the number of the newly created version when a version is restored.
*/
restored: number;
}
const props = defineProps<HistoryButtonListConfig>();
const state = reactive<HistoryButtonListState>({restored: -1})
const higher_version = computed<number>(() => props.versions.reduce(
(accumulator: number, version: StoredObjectVersionWithPointInTime) => Math.max(accumulator, version.version),
-1
)
);
/**
* Executed when a version in child component is restored.
*
* internally, keep track of the newly restored version
*/
const onRestored = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
state.restored = newVersion.version;
emit('restoreVersion', {newVersion});
}
</script>
<template>
<template v-if="props.versions.length > 0">
<div class="container">
<template v-for="v in props.versions">
<history-button-list-item
:version="v"
:can-edit="canEdit"
:is-current="higher_version === v.version"
:stored-object="storedObject"
@restore-version="onRestored"
></history-button-list-item>
</template>
</div>
</template>
<template v-else>
<p>Chargement des versions</p>
</template>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import {StoredObject, StoredObjectPointInTime, StoredObjectVersionWithPointInTime} from "./../../../types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import {ISOToDatetime} from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
import {computed} from "vue";
interface HistoryButtonListItemConfig {
version: StoredObjectVersionWithPointInTime;
storedObject: StoredObject;
canEdit: boolean;
isCurrent: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
const props = defineProps<HistoryButtonListItemConfig>();
const onRestore = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
emit('restoreVersion', {newVersion});
}
const isKeptBeforeConversion = computed<boolean>(() => props.version["point-in-times"].reduce(
(accumulator: boolean, pit: StoredObjectPointInTime) => accumulator || "keep-before-conversion" === pit.reason,
false
),
);
const isRestored = computed<boolean>(() => props.version.version > 0 && null !== props.version["from-restored"]);
const isDuplicated = computed<boolean>(() => props.version.version === 0 && null !== props.version["from-restored"]);
const classes = computed<{row: true, 'row-hover': true, 'blinking-1': boolean, 'blinking-2': boolean}>(() => ({row: true, 'row-hover': true, 'blinking-1': props.isRestored && 0 === props.version.version % 2, 'blinking-2': props.isRestored && 1 === props.version.version % 2}));
</script>
<template>
<div :class="classes">
<div class="col-12 tags" v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated">
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion">Conservée avant conversion dans un autre format</span>
<span class="badge bg-info" v-if="isRestored">Restaurée depuis la version {{ version["from-restored"]?.version + 1 }}</span>
<span class="badge bg-info" v-if="isDuplicated">Dupliqué depuis un autre document</span>
</div>
<div class="col-12">
<file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <template v-if="version.createdBy !== null && version.createdAt !== null"><strong v-if="version.version == 0">Créé par</strong><strong v-else>modifié par</strong> <span class="badge-user"><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge></span> <strong>à</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template><template v-if="version.createdBy === null && version.createdAt !== null"><strong v-if="version.version == 0">Créé le</strong><strong v-else>modifié le</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template>
</div>
<div class="col-12">
<ul class="record_actions small slim on-version-actions">
<li v-if="canEdit && !isCurrent">
<restore-version-button :stored-object-version="props.version" @restore-version="onRestore"></restore-version-button>
</li>
<li>
<download-button :stored-object="storedObject" :at-version="version" :classes="{btn: true, 'btn-outline-primary': true}" :display-action-string-in-button="false"></download-button>
</li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
div.tags {
span.badge:not(:last-child) {
margin-right: 0.5rem;
}
}
// to make the animation restart, we have the same animation twice,
// and alternate between both
.blinking-1 {
animation-name: backgroundColorPalette-1;
animation-duration: 8s;
animation-iteration-count: 1;
animation-direction: normal;
animation-timing-function: linear;
}
@keyframes backgroundColorPalette-1 {
0% {
background: var(--bs-chill-green-dark);
}
25% {
background: var(--bs-chill-green);
}
65% {
background: var(--bs-chill-beige);
}
100% {
background: unset;
}
}
.blinking-2 {
animation-name: backgroundColorPalette-2;
animation-duration: 8s;
animation-iteration-count: 1;
animation-direction: normal;
animation-timing-function: linear;
}
@keyframes backgroundColorPalette-2 {
0% {
background: var(--bs-chill-green-dark);
}
25% {
background: var(--bs-chill-green);
}
65% {
background: var(--bs-chill-beige);
}
100% {
background: unset;
}
}
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {reactive} from "vue";
import HistoryButtonList from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue";
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
interface HistoryButtonListConfig {
versions: StoredObjectVersionWithPointInTime[];
storedObject: StoredObject;
canEdit: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
interface HistoryButtonModalState {
opened: boolean;
}
const props = defineProps<HistoryButtonListConfig>();
const state = reactive<HistoryButtonModalState>({opened: false});
const open = () => {
state.opened = true;
}
defineExpose({open});
</script>
<template>
<Teleport to="body">
<modal v-if="state.opened" @close="state.opened = false">
<template v-slot:header>
<h3>Historique des versions du document</h3>
</template>
<template v-slot:body>
<p>Les versions sont conservées pendant 90 jours.</p>
<history-button-list :versions="props.versions" :can-edit="canEdit" :stored-object="storedObject" @restore-version="(payload) => emit('restoreVersion', payload)"></history-button-list>
</template>
</modal>
</Teleport>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import {StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
import {useToast} from "vue-toast-notification";
import {restore_version} from "./api";
interface RestoreVersionButtonProps {
storedObjectVersion: StoredObjectVersionPersisted,
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
const props = defineProps<RestoreVersionButtonProps>()
const $toast = useToast();
const restore_version_fn = async () => {
const newVersion = await restore_version(props.storedObjectVersion);
$toast.success("Version restaurée");
emit('restoreVersion', {newVersion});
}
</script>
<template>
<button class="btn btn-outline-action" @click="restore_version_fn" title="Restaurer"><i class="fa fa-rotate-left"></i> Restaurer</button>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,12 @@
import {StoredObject, StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
import {fetchResults, makeFetch} from "../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
export const get_versions = async (storedObject: StoredObject): Promise<StoredObjectVersionWithPointInTime[]> => {
const versions = await fetchResults<StoredObjectVersionWithPointInTime>(`/api/1.0/doc-store/stored-object/${storedObject.uuid}/versions`);
return versions.sort((a: StoredObjectVersionWithPointInTime, b: StoredObjectVersionWithPointInTime) => b.version - a.version);
}
export const restore_version = async (version: StoredObjectVersionPersisted): Promise<StoredObjectVersionWithPointInTime> => {
return await makeFetch<null, StoredObjectVersionWithPointInTime>("POST", `/api/1.0/doc-store/stored-object/restore-from-version/${version.id}`);
}

View File

@@ -161,7 +161,14 @@ async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: n
throw new Error("no version associated to stored object");
}
const downloadInfo= await download_info_link(storedObject, atVersionToDownload);
// sometimes, the downloadInfo may be embedded into the storedObject
console.log('storedObject', storedObject);
let downloadInfo;
if (typeof storedObject._links !== 'undefined' && typeof storedObject._links.downloadLink !== 'undefined') {
downloadInfo = storedObject._links.downloadLink;
} else {
downloadInfo = await download_info_link(storedObject, atVersionToDownload);
}
const rawResponse = await window.fetch(downloadInfo.url);

View File

@@ -0,0 +1 @@
<div data-download-button-single="data-download-button-single" data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-title="{{ title|escape('html_attr') }}"></div>

View File

@@ -73,8 +73,15 @@
<li>
{{ document.object|chill_document_button_group(document.title) }}
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', document.course) %}
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
<a href="{{ chill_path_add_return_path('chill_doc_store_accompanying_course_document_duplicate', {'id': document.id}) }}" class="btn btn-duplicate" title="{{ 'Duplicate'|trans|e('html_attr') }}"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
@@ -82,9 +89,9 @@
<a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% else %}

View File

@@ -0,0 +1,43 @@
{% extends '@ChillMain/Workflow/workflow_view_send_public_layout.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_document_download_button') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_document_download_button') }}
{% endblock %}
{% block title %}{{ 'workflow.public_link.title'|trans }} - {{ title }}{% endblock %}
{% block public_content %}
<h1>{{ 'workflow.public_link.shared_doc'|trans }}</h1>
{% set previous = send.entityWorkflowStepChained.previous %}
{% if previous is not null %}
{% if previous.transitionBy is not null %}
<p>{{ 'workflow.public_link.doc_shared_by_at_explanation'|trans({'byUser': previous.transitionBy|chill_entity_render_string( { 'at_date': previous.transitionAt } ), 'at': previous.transitionAt }) }}</p>
{% else %}
<p>{{ 'workflow.public_link.doc_shared_automatically_at_explanation'|trans({'at': previous.transitionAt}) }}</p>
{% endif %}
{% endif %}
<div class="row">
<div class="col-xs-12 col-sm-6 col-md-4">
<div class="card"">
<div class="card-body">
<h2 class="card-title">{{ title }}</h2>
<h3>{{ 'workflow.public_link.main_document'|trans }}</h3>
<ul class="record_actions slim small">
<li>
{{ storedObject|chill_document_download_only_button(storedObject.title(), false) }}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
@@ -30,10 +31,17 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
{
use NormalizerAwareTrait;
/**
* when added to the groups, a download link is included in the normalization,
* and no webdav links are generated.
*/
public const DOWNLOAD_LINK_ONLY = 'read:download-link-only';
public function __construct(
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
) {}
public function normalize($object, ?string $format = null, array $context = [])
@@ -55,6 +63,24 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
// deprecated property
$datas['creationDate'] = $datas['createdAt'];
if (array_key_exists(AbstractNormalizer::GROUPS, $context)) {
$groupsNormalized = is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]];
} else {
$groupsNormalized = [];
}
if (in_array(self::DOWNLOAD_LINK_ONLY, $groupsNormalized, true)) {
$datas['_permissions'] = [
'canSee' => true,
'canEdit' => false,
];
$datas['_links'] = [
'downloadLink' => $this->normalizer->normalize($this->tempUrlGenerator->generate('GET', $object->getCurrentVersion()->getFilename(), 180), $format, [AbstractNormalizer::GROUPS => ['read']]),
];
return $datas;
}
$canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);

View File

@@ -0,0 +1,38 @@
<?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\StoredObjectPointInTime;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class StoredObjectPointInTimeNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, ?string $format = null, array $context = [])
{
/* @var StoredObjectPointInTime $object */
return [
'id' => $object->getId(),
'reason' => $object->getReason()->value,
'byUser' => $this->normalizer->normalize($object->getByUser(), $format, [AbstractNormalizer::GROUPS => 'read']),
];
}
public function supportsNormalization($data, ?string $format = null)
{
return $data instanceof StoredObjectPointInTime;
}
}

View File

@@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -20,13 +22,17 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw
{
use NormalizerAwareTrait;
final public const WITH_POINT_IN_TIMES_CONTEXT = 'with-point-in-times';
final public const WITH_RESTORED_CONTEXT = 'with-restored';
public function normalize($object, ?string $format = null, array $context = [])
{
if (!$object instanceof StoredObjectVersion) {
throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class);
}
return [
$data = [
'id' => $object->getId(),
'filename' => $object->getFilename(),
'version' => $object->getVersion(),
@@ -34,8 +40,18 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw
'keyInfos' => $object->getKeyInfos(),
'type' => $object->getType(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, [...$context, UserNormalizer::AT_DATE => $object->getCreatedAt()]),
];
if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
$data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context);
}
if (in_array(self::WITH_RESTORED_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
$data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]);
}
return $data;
}
public function supportsNormalization($data, ?string $format = null, array $context = [])

View File

@@ -18,7 +18,7 @@ final readonly class PdfSignedMessage
{
public function __construct(
public readonly int $signatureId,
public readonly int $signatureZoneIndex,
public readonly ?int $signatureZoneIndex,
public readonly string $content,
) {}
}

View File

@@ -12,12 +12,11 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
@@ -33,7 +32,7 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
private StoredObjectManagerInterface $storedObjectManager,
private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private SignatureStepStateChanger $signatureStepStateChanger,
) {}
public function __invoke(PdfSignedMessage $message): void
@@ -54,8 +53,8 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
$this->storedObjectManager->write($storedObject, $message->content);
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now());
$signature->setZoneSignatureIndex($message->signatureZoneIndex);
$this->signatureStepStateChanger->markSignatureAsSigned($signature, $message->signatureZoneIndex);
$this->entityManager->flush();
$this->entityManager->clear();
}

View File

@@ -21,7 +21,7 @@ final readonly class RequestPdfSignMessage
public function __construct(
public int $signatureId,
public PDFSignatureZone $PDFSignatureZone,
public int $signatureZoneIndex,
public ?int $signatureZoneIndex,
public string $reason,
public string $signerText,
public string $content,

View File

@@ -17,7 +17,7 @@ final readonly class PDFSignatureZone
{
public function __construct(
#[Groups(['read'])]
public int $index,
public ?int $index,
#[Groups(['read'])]
public float $x,
#[Groups(['read'])]

View File

@@ -50,7 +50,7 @@ final readonly class RemoveOldVersionCronJob implements CronJobInterface
$deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL));
$maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0;
foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersion($deleteBeforeDate) as $id) {
foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime($deleteBeforeDate) as $id) {
$this->messageBus->dispatch(new RemoveOldVersionMessage($id));
$maxDeleted = max($maxDeleted, $id);
}

View File

@@ -18,6 +18,7 @@ use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
@@ -49,13 +50,18 @@ final readonly class RemoveOldVersionMessageHandler implements MessageHandlerInt
$this->logger->info(self::LOG_PREFIX.'Received one message', ['storedObjectVersionId' => $message->storedObjectVersionId]);
$storedObjectVersion = $this->storedObjectVersionRepository->find($message->storedObjectVersionId);
$storedObject = $storedObjectVersion->getStoredObject();
if (null === $storedObjectVersion) {
$this->logger->error(self::LOG_PREFIX.'StoredObjectVersion not found in database', ['storedObjectVersionId' => $message->storedObjectVersionId]);
throw new \RuntimeException('StoredObjectVersion not found with id '.$message->storedObjectVersionId);
}
if ($storedObjectVersion->hasPointInTimes()) {
throw new UnrecoverableMessageHandlingException('the stored object version is now associated with a point in time');
}
$storedObject = $storedObjectVersion->getStoredObject();
$this->storedObjectManager->delete($storedObjectVersion);
// to ensure an immediate deletion
$this->entityManager->remove($storedObjectVersion);

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
/**
* Class which duplicate a stored object into a new one, recreating a stored object.
*/
class StoredObjectDuplicate
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
public function duplicate(StoredObject|StoredObjectVersion $from, bool $onlyLastKeptBeforeConversionVersion = true): StoredObject
{
$storedObject = $from instanceof StoredObjectVersion ? $from->getStoredObject() : $from;
$fromVersion = match ($storedObject->hasKeptBeforeConversionVersion() && $onlyLastKeptBeforeConversionVersion) {
true => $from->getLastKeptBeforeConversionVersion(),
false => $storedObject->getCurrentVersion(),
};
if (null === $fromVersion) {
throw new \UnexpectedValueException('could not find a version to restore');
}
$oldContent = $this->storedObjectManager->read($fromVersion);
$storedObject = new StoredObject();
$newVersion = $this->storedObjectManager->write($storedObject, $oldContent, $fromVersion->getType());
$newVersion->setCreatedFrom($fromVersion);
$this->logger->info('[StoredObjectDuplicate] Duplicated stored object from a version of a previous stored object', [
'from_stored_object_uuid' => $fromVersion->getStoredObject()->getUuid(),
'to_stored_object_uuid' => $storedObject->getUuid(),
'old_version_id' => $fromVersion->getId(),
'old_version_version' => $fromVersion->getVersion(),
'new_version_id' => $newVersion->getVersion(),
]);
return $storedObject;
}
}

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\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
/**
* Class responsible for restoring stored object versions into the same stored object.
*/
final readonly class StoredObjectRestore implements StoredObjectRestoreInterface
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion
{
$oldContent = $this->storedObjectManager->read($storedObjectVersion);
$newVersion = $this->storedObjectManager->write($storedObjectVersion->getStoredObject(), $oldContent, $storedObjectVersion->getType());
$newVersion->setCreatedFrom($storedObjectVersion);
$this->logger->info('[StoredObjectRestore] Restore stored object version', [
'stored_object_uuid' => $storedObjectVersion->getStoredObject()->getUuid(),
'old_version_id' => $storedObjectVersion->getId(),
'old_version_version' => $storedObjectVersion->getVersion(),
'new_version_id' => $newVersion->getVersion(),
]);
return $newVersion;
}
}

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\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
/**
* Restore an old version of the stored object as the current one.
*/
interface StoredObjectRestoreInterface
{
public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion;
}

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\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Component\Mime\MimeTypesInterface;
/**
* Class StoredObjectToPdfConverter.
*
* Converts stored objects to PDF or other specified formats using WopiConverter.
*/
class StoredObjectToPdfConverter
{
public function __construct(
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly WopiConverter $wopiConverter,
private readonly MimeTypesInterface $mimeTypes,
) {}
/**
* Converts the given stored object to a specified format and stores the new version.
*
* @param StoredObject $storedObject the stored object to be converted
* @param string $lang the language for the conversion context
* @param string $convertTo The target format for the conversion. Default is 'pdf'.
*
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion} contains the point in time before conversion and the new version of the stored object
*
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
* @throws \RuntimeException if the conversion or storage of the new version fails
* @throws StoredObjectManagerException
*/
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf'): array
{
$newMimeType = $this->mimeTypes->getMimeTypes($convertTo)[0] ?? null;
if (null === $newMimeType) {
throw new \UnexpectedValueException(sprintf('could not find a preferred mime type for conversion to %s', $convertTo));
}
$currentVersion = $storedObject->getCurrentVersion();
if ($currentVersion->getType() === $newMimeType) {
throw new \UnexpectedValueException('Already at the same mime type');
}
$content = $this->storedObjectManager->read($currentVersion);
try {
$converted = $this->wopiConverter->convert($lang, $content, $newMimeType, $convertTo);
} catch (\RuntimeException $e) {
throw new \RuntimeException('could not store a new version for document', previous: $e);
}
$pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType);
return [$pointInTime, $version];
}
}

View File

@@ -11,28 +11,52 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Registry;
class WorkflowStoredObjectPermissionHelper
{
public function __construct(private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager) {}
public function __construct(
private readonly Security $security,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly Registry $registry,
) {}
public function notBlockedByWorkflow(object $entity): bool
{
$workflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$currentUser = $this->security->getUser();
foreach ($workflows as $workflow) {
if ($workflow->isFinal()) {
foreach ($entityWorkflows as $entityWorkflow) {
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $active) {
$metadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if ($metadata['isFinalPositive'] ?? true) {
return false;
}
if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) {
}
} else {
if (!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) {
return false;
}
}
// as soon as there is one signatured applyied, we are not able to
// edit the document any more
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return false;
}
}
}
}
return true;
}
}

View File

@@ -28,6 +28,10 @@ class WopiEditTwigExtension extends AbstractExtension
'needs_environment' => true,
'is_safe' => ['html'],
]),
new TwigFilter('chill_document_download_only_button', [WopiEditTwigExtensionRuntime::class, 'renderDownloadButton'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}

View File

@@ -15,6 +15,7 @@ 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 Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
@@ -177,6 +178,17 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
]);
}
public function renderDownloadButton(Environment $environment, StoredObject $storedObject, string $title = ''): string
{
return $environment->render(
'@ChillDocStore/Button/button_download.html.twig',
[
'document_json' => $this->normalizer->normalize($storedObject, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]]),
'title' => $title,
]
);
}
public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string
{
return $environment->render(self::TEMPLATE, [

View File

@@ -0,0 +1,74 @@
<?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 App\Tests\Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
use Chill\DocStoreBundle\Controller\StoredObjectRestoreVersionApiController;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectRestoreVersionApiControllerTest extends TestCase
{
public function testRestoreStoredObjectVersion(): void
{
$security = $this->createMock(Security::class);
$storedObjectRestore = $this->createMock(StoredObjectRestoreInterface::class);
$entityManager = $this->createMock(EntityManagerInterface::class);
$serializer = $this->createMock(SerializerInterface::class);
$storedObjectVersion = $this->createMock(StoredObjectVersion::class);
$controller = new StoredObjectRestoreVersionApiController($security, $storedObjectRestore, $entityManager, $serializer);
$security->expects($this->once())
->method('isGranted')
->willReturn(true);
$storedObjectRestore->expects($this->once())
->method('restore')
->willReturn($storedObjectVersion);
$entityManager->expects($this->once())
->method('persist');
$entityManager->expects($this->once())
->method('flush');
$serializer->expects($this->once())
->method('serialize')
->willReturn('test');
$response = $controller->restoreStoredObjectVersion($storedObjectVersion);
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('test', $response->getContent());
}
public function testRestoreStoredObjectVersionAccessDenied(): void
{
$security = $this->createMock(Security::class);
$storedObjectRestore = $this->createMock(StoredObjectRestoreInterface::class);
$entityManager = $this->createMock(EntityManagerInterface::class);
$serializer = $this->createMock(SerializerInterface::class);
$storedObjectVersion = $this->createMock(StoredObjectVersion::class);
$controller = new StoredObjectRestoreVersionApiController($security, $storedObjectRestore, $entityManager, $serializer);
self::expectException(\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException::class);
$security->expects($this->once())
->method('isGranted')
->willReturn(false);
$controller->restoreStoredObjectVersion($storedObjectVersion);
}
}

View File

@@ -0,0 +1,77 @@
<?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\StoredObjectVersionApiController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\MainBundle\Pagination\Paginator;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
{
use ProphecyTrait;
public function testListVersion(): void
{
$storedObject = new StoredObject();
for ($i = 0; $i < 15; ++$i) {
$storedObject->registerVersion();
}
$security = $this->prophesize(Security::class);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
->willReturn(true)
->shouldBeCalledOnce();
$controller = $this->buildController($security->reveal());
$response = $controller->listVersions($storedObject);
$body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($response->getStatusCode(), 200);
self::assertIsArray($body);
self::assertArrayHasKey('results', $body);
self::assertCount(10, $body['results']);
}
private function buildController(Security $security): StoredObjectVersionApiController
{
$paginator = $this->prophesize(Paginator::class);
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
$paginator->getItemsPerPage()->willReturn(10);
$paginator->getTotalItems()->willReturn(15);
$paginator->hasNextPage()->willReturn(false);
$paginator->hasPreviousPage()->willReturn(false);
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
$paginatorFactory->create(Argument::type('int'))->willReturn($paginator);
$serializer = new Serializer([
new StoredObjectVersionNormalizer(), new CollectionNormalizer(),
], [new JsonEncoder()]);
return new StoredObjectVersionApiController($paginatorFactory->reveal(), $serializer, $security);
}
}

View File

@@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
@@ -54,4 +56,27 @@ class StoredObjectTest extends KernelTestCase
self::assertNotSame($firstVersion, $version);
}
public function testHasKeptBeforeConversionVersion(): void
{
$storedObject = new StoredObject();
$version1 = $storedObject->registerVersion();
self::assertFalse($storedObject->hasKeptBeforeConversionVersion());
// add a point in time without the correct version
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BY_USER);
self::assertFalse($storedObject->hasKeptBeforeConversionVersion());
self::assertNull($storedObject->getLastKeptBeforeConversionVersion());
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
self::assertTrue($storedObject->hasKeptBeforeConversionVersion());
// add a second version
$version2 = $storedObject->registerVersion();
new StoredObjectPointInTime($version2, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
self::assertSame($version2, $storedObject->getLastKeptBeforeConversionVersion());
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Form;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
@@ -132,7 +133,8 @@ class StoredObjectTypeTest extends TypeTestCase
new StoredObjectNormalizer(
$jwtTokenProvider->reveal(),
$urlGenerator->reveal(),
$security->reveal()
$security->reveal(),
$this->createMock(TempUrlGeneratorInterface::class)
),
new StoredObjectDenormalizer($storedObjectRepository->reveal()),
new StoredObjectVersionNormalizer(),

View File

@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
@@ -70,7 +72,9 @@ class StoredObjectNormalizerTest extends TestCase
return ['sub' => 'sub'];
});
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security);
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer->setNormalizer($globalNormalizer);
$actual = $normalizer->normalize($storedObject, 'json');
@@ -95,4 +99,48 @@ class StoredObjectNormalizerTest extends TestCase
self::assertArrayHasKey('dav_link', $actual['_links']);
self::assertEqualsCanonicalizing(['href' => $davLink, 'expiration' => $d->getTimestamp()], $actual['_links']['dav_link']);
}
public function testWithDownloadLinkOnly(): void
{
$storedObject = new StoredObject();
$storedObject->registerVersion();
$storedObject->setTitle('test');
$reflection = new \ReflectionClass(StoredObject::class);
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($storedObject, 1);
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
$jwtProvider->expects($this->never())->method('createToken')->withAnyParameters();
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(Security::class);
$security->expects($this->never())->method('isGranted');
$globalNormalizer = $this->createMock(NormalizerInterface::class);
$globalNormalizer->expects($this->exactly(4))->method('normalize')
->withAnyParameters()
->willReturnCallback(function (?object $object, string $format, array $context) {
if (null === $object) {
return null;
}
return ['sub' => 'sub'];
});
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$tempUrlGenerator->expects($this->once())->method('generate')->with('GET', $storedObject->getCurrentVersion()->getFilename(), $this->isType('int'))
->willReturn(new SignedUrl('GET', 'https://some-link/test', new \DateTimeImmutable('300 seconds'), $storedObject->getCurrentVersion()->getFilename()));
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer->setNormalizer($globalNormalizer);
$actual = $normalizer->normalize($storedObject, 'json', ['groups' => ['read', 'read:download-link-only']]);
self::assertIsArray($actual);
self::assertArrayHasKey('_links', $actual);
self::assertArrayHasKey('downloadLink', $actual['_links']);
self::assertEquals(['sub' => 'sub'], $actual['_links']['downloadLink']);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectPointInTimeNormalizer;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
use Chill\MainBundle\Templating\Entity\UserRender;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectPointInTimeNormalizerTest extends TestCase
{
use ProphecyTrait;
public function testNormalize(): void
{
$storedObject = new StoredObject();
$version = $storedObject->registerVersion();
$storedObjectPointInTime = new StoredObjectPointInTime($version, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION, new User());
$normalizer = new StoredObjectPointInTimeNormalizer();
$normalizer->setNormalizer($this->buildNormalizer());
$actual = $normalizer->normalize($storedObjectPointInTime, 'json', ['read']);
self::assertIsArray($actual);
self::assertArrayHasKey('id', $actual);
self::assertArrayHasKey('byUser', $actual);
self::assertArrayHasKey('reason', $actual);
self::assertEquals(StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION->value, $actual['reason']);
}
public function buildNormalizer(): NormalizerInterface
{
$userRender = $this->prophesize(UserRender::class);
$userRender->renderString(Argument::type(User::class), Argument::type('array'))->willReturn('username');
return new Serializer(
[new UserNormalizer($userRender->reveal(), new MockClock())]
);
}
}

View File

@@ -35,7 +35,7 @@ class StoredObjectVersionRepositoryTest extends KernelTestCase
$repository = new StoredObjectVersionRepository($this->entityManager);
// get old version, to get a chance to get one
$actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersion(new \DateTimeImmutable('1970-01-01'));
$actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(new \DateTimeImmutable('1970-01-01'));
self::assertIsIterable($actual);
self::assertContainsOnly('int', $actual);

View File

@@ -20,12 +20,12 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
/**
* @internal
@@ -45,6 +45,9 @@ class PdfSignedMessageHandlerTest extends TestCase
$entityWorkflow->setStep('new_step', $dto, 'new_transition', new \DateTimeImmutable(), new User());
$step = $entityWorkflow->getCurrentStep();
$signature = $step->getSignatures()->first();
$stateChanger = $this->createMock(SignatureStepStateChanger::class);
$stateChanger->expects(self::once())->method('markSignatureAsSigned')
->with($signature, 99);
$handler = new PdfSignedMessageHandler(
new NullLogger(),
@@ -52,15 +55,12 @@ class PdfSignedMessageHandlerTest extends TestCase
$this->buildStoredObjectManager($storedObject, $expectedContent = '1234'),
$this->buildSignatureRepository($signature),
$this->buildEntityManager(true),
new MockClock('now'),
$stateChanger,
);
// we simply call the handler. The mocked StoredObjectManager will check that the "write" method is invoked once
// with the content "1234"
$handler(new PdfSignedMessage(10, 99, $expectedContent));
self::assertEquals('signed', $signature->getState()->value);
self::assertEquals(99, $signature->getZoneSignatureIndex());
}
private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository

View File

@@ -46,7 +46,7 @@ class RemoveOldVersionCronJobTest extends KernelTestCase
$clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00')));
$repository = $this->createMock(StoredObjectVersionRepository::class);
$repository->expects($this->once())
->method('findIdsByVersionsOlderThanDateAndNotLastVersion')
->method('findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime')
->with(new \DateTime('2023-10-03 00:00:00', new \DateTimeZone('+00:00')))
->willReturnCallback(function ($arg) {
yield 1;

View File

@@ -0,0 +1,130 @@
<?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\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectDuplicateTest extends TestCase
{
use ProphecyTrait;
public function testDuplicateHappyScenario(): void
{
$storedObject = new StoredObject();
// we create multiple version, we want the last to be duplicated
$storedObject->registerVersion(type: 'application/test');
$version = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->createMock(StoredObjectManagerInterface::class);
$manager->method('read')->with($version)->willReturn('1234');
$manager
->expects($this->once())
->method('write')
->with($this->isInstanceOf(StoredObject::class), '1234', 'application/test')
->willReturnCallback(fn (StoredObject $so, $content, $type) => $so->registerVersion(type: $type));
$storedObjectDuplicate = new StoredObjectDuplicate($manager, new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version, $actual->getCurrentVersion()->getCreatedFrom());
}
public function testDuplicateWithKeptVersion(): void
{
$storedObject = new StoredObject();
// we create two versions for stored object
// the first one is "kept before conversion", and that one should
// be duplicated, not the second one
$version1 = $storedObject->registerVersion(type: $type = 'application/test');
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version2 = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->prophesize(StoredObjectManagerInterface::class);
// we create both possibilities for the method "read"
$manager->read($version1)->willReturn('1234');
$manager->read($version2)->willReturn('4567');
// we create the write method, and check that it is called with the content from version1, not version2
$manager->write(Argument::type(StoredObject::class), '1234', 'application/test')
->shouldBeCalled()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0]; // args are ordered by key, so the first one is the stored object...
$type = $args[2]; // and the last one is the string $type
return $storedObject->registerVersion(type: $type);
});
// we create the service which will duplicate things
$storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version1, $actual->getCurrentVersion()->getCreatedFrom());
}
public function testDuplicateWithKeptVersionButWeWantToDuplicateTheLastOne(): void
{
$storedObject = new StoredObject();
// we create two versions for stored object
// the first one is "kept before conversion", and that one should
// be duplicated, not the second one
$version1 = $storedObject->registerVersion(type: $type = 'application/test');
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version2 = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->prophesize(StoredObjectManagerInterface::class);
// we create both possibilities for the method "read"
$manager->read($version1)->willReturn('1234');
$manager->read($version2)->willReturn('4567');
// we create the write method, and check that it is called with the content from version1, not version2
$manager->write(Argument::type(StoredObject::class), '4567', 'application/test')
->shouldBeCalled()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0]; // args are ordered by key, so the first one is the stored object...
$type = $args[2]; // and the last one is the string $type
return $storedObject->registerVersion(type: $type);
});
// we create the service which will duplicate things
$storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject, false);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version2, $actual->getCurrentVersion()->getCreatedFrom());
}
}

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\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\StoredObjectRestore;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectRestoreTest extends TestCase
{
use ProphecyTrait;
public function testRestore(): void
{
$storedObject = new StoredObject();
$version = $storedObject->registerVersion(type: 'application/test');
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($version)->willReturn('1234')->shouldBeCalledOnce();
$storedObjectManager->write($storedObject, '1234', 'application/test')->shouldBeCalledOnce()
->will(function ($args) {
/** @var StoredObject $object */
$object = $args[0];
return $object->registerVersion();
})
;
$restore = new StoredObjectRestore($storedObjectManager->reveal(), new NullLogger());
$newVersion = $restore->restore($version);
self::assertNotSame($version, $newVersion);
self::assertSame($version, $newVersion->getCreatedFrom());
}
}

View File

@@ -0,0 +1,61 @@
<?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\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\WopiBundle\Service\WopiConverter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Mime\MimeTypes;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectToPdfConverterTest extends TestCase
{
use ProphecyTrait;
public function testAddConvertedVersion(): void
{
$storedObject = new StoredObject();
$currentVersion = $storedObject->registerVersion(type: 'text/html');
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($currentVersion)->willReturn('1234');
$storedObjectManager->write($storedObject, '5678', 'application/pdf')->shouldBeCalled()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0];
return $storedObject->registerVersion(type: $args[2]);
});
$converter = $this->prophesize(WopiConverter::class);
$converter->convert('fr', '1234', 'application/pdf', 'pdf')->shouldBeCalled()
->willReturn('5678');
$converter = new StoredObjectToPdfConverter($storedObjectManager->reveal(), $converter->reveal(), MimeTypes::getDefault());
$actual = $converter->addConvertedVersion($storedObject, 'fr');
self::assertIsArray($actual);
self::assertInstanceOf(StoredObjectPointInTime::class, $actual[0]);
self::assertSame($currentVersion, $actual[0]->getObjectVersion());
self::assertInstanceOf(StoredObjectVersion::class, $actual[1]);
}
}

View File

@@ -0,0 +1,161 @@
<?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\Service;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class WorkflowStoredObjectPermissionHelperTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataNotBlockByWorkflow
*/
public function testNotBlockByWorkflow(EntityWorkflow $entityWorkflow, User $user, bool $expected, string $message): void
{
// all entities must have this workflow name, so we are ok to set it here
$entityWorkflow->setWorkflowName('dummy');
$object = new \stdClass();
$helper = $this->buildHelper($object, $entityWorkflow, $user);
self::assertEquals($expected, $helper->notBlockedByWorkflow($entityWorkflow), $message);
}
private function buildHelper(object $relatedEntity, EntityWorkflow $entityWorkflow, User $user): WorkflowStoredObjectPermissionHelper
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([$entityWorkflow]);
return new WorkflowStoredObjectPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry());
}
public static function provideDataNotBlockByWorkflow(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
yield [$entityWorkflow, new User(), false, 'blocked because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user);
yield [$entityWorkflow, $user, true, 'allowed because the user is present as a dest user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), $user);
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [$entityWorkflow, $user, false, 'blocked because the step is final, and final positive'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user);
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [$entityWorkflow, $user, true, 'allowed because the step is final, and final negative'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user);
$step = $entityWorkflow->getCurrentStep();
new EntityWorkflowStepSignature($step, new Person());
yield [$entityWorkflow, $user, true, 'allow, a signature is present but still pending'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user);
$step = $entityWorkflow->getCurrentStep();
$signature = new EntityWorkflowStepSignature($step, new Person());
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user);
$step = $entityWorkflow->getCurrentStep();
$signature = new EntityWorkflowStepSignature($step, new Person());
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed, although the workflow is final negative'];
}
private static function buildRegistry(): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces(['initial'])
->addPlaces(['initial', 'test', 'final_positive', 'final_negative'])
->setMetadataStore(
new InMemoryMetadataStore(
placesMetadata: [
'final_positive' => [
'isFinal' => true,
'isFinalPositive' => true,
],
'final_negative' => [
'isFinal' => true,
'isFinalPositive' => false,
],
]
)
);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
$registry = new Registry();
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
});
return $registry;
}
}

View File

@@ -0,0 +1,70 @@
<?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\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\DocumentCategory;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class AccompanyingCourseDocumentDuplicatorTest extends TestCase
{
public function testDuplicate(): void
{
$object = new StoredObject();
$document = new AccompanyingCourseDocument();
$document
->setDate($date = new \DateTimeImmutable())
->setObject($object)
->setTitle('Title')
->setUser($user = new User())
->setCategory($category = new DocumentCategory('bundle', 10))
->setDescription($description = 'Description');
$actual = $this->buildDuplicator()->duplicate($document);
self::assertSame($date, $actual->getDate());
// FYI, the duplication of object is checked by the mock
self::assertNotNull($actual->getObject());
self::assertStringStartsWith('Title', $actual->getTitle());
self::assertSame($user, $actual->getUser());
self::assertSame($category, $actual->getCategory());
self::assertEquals($description, $actual->getDescription());
}
private function buildDuplicator(): AccompanyingCourseDocumentDuplicator
{
$storedObjectDuplicate = $this->createMock(StoredObjectDuplicate::class);
$storedObjectDuplicate->expects($this->once())->method('duplicate')
->with($this->isInstanceOf(StoredObject::class))->willReturn(new StoredObject());
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->withAnyParameters()->willReturn('duplicated');
$clock = new MockClock();
return new AccompanyingCourseDocumentDuplicator(
$storedObjectDuplicate,
$translator,
$clock
);
}
}

View File

@@ -0,0 +1,94 @@
<?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\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentWorkflowHandler;
use Chill\DocStoreBundle\Workflow\WorkflowWithPublicViewDocumentHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvidePersonsAssociated;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvideThirdPartiesAssociated;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class AccompanyingCourseDocumentWorkflowHandlerTest extends TestCase
{
use ProphecyTrait;
public function testGetSuggestedUsers()
{
$accompanyingPeriod = new AccompanyingPeriod();
$document = new AccompanyingCourseDocument();
$document->setCourse($accompanyingPeriod)->setUser($user1 = new User());
$accompanyingPeriod->setUser($user = new User());
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setRelatedEntityId(1);
$handler = new AccompanyingCourseDocumentWorkflowHandler(
$this->prophesize(TranslatorInterface::class)->reveal(),
$this->prophesize(EntityWorkflowRepository::class)->reveal(),
$this->buildRepository($document, 1),
new WorkflowWithPublicViewDocumentHelper($this->prophesize(Environment::class)->reveal()),
$this->prophesize(ProvideThirdPartiesAssociated::class)->reveal(),
$this->prophesize(ProvidePersonsAssociated::class)->reveal(),
);
$users = $handler->getSuggestedUsers($entityWorkflow);
self::assertCount(2, $users);
self::assertContains($user, $users);
self::assertContains($user1, $users);
}
public function testGetSuggestedUsersWithDuplicates()
{
$accompanyingPeriod = new AccompanyingPeriod();
$document = new AccompanyingCourseDocument();
$document->setCourse($accompanyingPeriod)->setUser($user1 = new User());
$accompanyingPeriod->setUser($user1);
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setRelatedEntityId(1);
$handler = new AccompanyingCourseDocumentWorkflowHandler(
$this->prophesize(TranslatorInterface::class)->reveal(),
$this->prophesize(EntityWorkflowRepository::class)->reveal(),
$this->buildRepository($document, 1),
new WorkflowWithPublicViewDocumentHelper($this->prophesize(Environment::class)->reveal()),
$this->prophesize(ProvideThirdPartiesAssociated::class)->reveal(),
$this->prophesize(ProvidePersonsAssociated::class)->reveal(),
);
$users = $handler->getSuggestedUsers($entityWorkflow);
self::assertCount(1, $users);
self::assertContains($user1, $users);
}
private function buildRepository(AccompanyingCourseDocument $document, int $id): AccompanyingCourseDocumentRepository
{
$repository = $this->prophesize(AccompanyingCourseDocumentRepository::class);
$repository->find($id)->willReturn($document);
return $repository->reveal();
}
}

View File

@@ -0,0 +1,208 @@
<?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\Workflow;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\DocStoreBundle\Workflow\ConvertToPdfBeforeSignatureStepEventSubscriber;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class ConvertToPdfBeforeSignatureStepEventSubscriberTest extends \PHPUnit\Framework\TestCase
{
use ProphecyTrait;
public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignature(): void
{
$entityWorkflow = new EntityWorkflow();
$storedObject = new StoredObject();
$previousVersion = $storedObject->registerVersion();
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($storedObject, 'fr', 'pdf')
->shouldBeCalledOnce()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0];
$pointInTime = new StoredObjectPointInTime($storedObject->getCurrentVersion(), StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$newVersion = $storedObject->registerVersion(filename: 'next');
return [$pointInTime, $newVersion];
});
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('signature', $entityWorkflow->getStep());
self::assertNotSame($previousVersion, $storedObject->getCurrentVersion());
self::assertTrue($previousVersion->hasPointInTimes());
self::assertCount(2, $storedObject->getVersions());
self::assertEquals('next', $storedObject->getCurrentVersion()->getFilename());
}
public function testConvertToPdfBeforeSignatureStepEventSubscriberToNotASignatureStep(): void
{
$entityWorkflow = new EntityWorkflow();
$storedObject = new StoredObject();
$previousVersion = $storedObject->registerVersion();
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($storedObject, 'fr', 'pdf')
->shouldNotBeCalled();
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_something', ['context' => $dto, 'transition' => 'to_something', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('something', $entityWorkflow->getStep());
self::assertSame($previousVersion, $storedObject->getCurrentVersion());
self::assertFalse($previousVersion->hasPointInTimes());
self::assertCount(1, $storedObject->getVersions());
}
public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureAlreadyAPdf(): void
{
$entityWorkflow = new EntityWorkflow();
$storedObject = new StoredObject();
$previousVersion = $storedObject->registerVersion(type: 'application/pdf');
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($storedObject, 'fr', 'pdf')
->shouldNotBeCalled();
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('signature', $entityWorkflow->getStep());
self::assertSame($previousVersion, $storedObject->getCurrentVersion());
self::assertFalse($previousVersion->hasPointInTimes());
self::assertCount(1, $storedObject->getVersions());
}
public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureWithNoStoredObject(): void
{
$entityWorkflow = new EntityWorkflow();
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion(Argument::type(StoredObject::class), 'fr', 'pdf')
->shouldNotBeCalled();
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn(null);
$request = new Request();
$request->setLocale('fr');
$stack = new RequestStack();
$stack->push($request);
$eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack);
$registry = $this->buildRegistry($eventSubscriber);
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals('signature', $entityWorkflow->getStep());
}
private function buildRegistry(EventSubscriberInterface $eventSubscriber): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces('initial')
->addPlaces(['initial', 'signature', 'something'])
->addTransition(new Transition('to_something', 'initial', 'something'))
->addTransition(new Transition('to_signature', 'initial', 'signature'));
$metadataStore = new InMemoryMetadataStore([], ['signature' => ['isSignature' => ['user']]]);
$builder->setMetadataStore($metadataStore);
$eventDispatcher = new EventDispatcher();
$eventDispatcher->addSubscriber($eventSubscriber);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher, 'dummy');
$supports = new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
};
$registry = new Registry();
$registry->addWorkflow($workflow, $supports);
return $registry;
}
}

View File

@@ -0,0 +1,45 @@
<?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\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Stores the logic to duplicate an AccompanyingCourseDocument associated to a workflow.
*/
class AccompanyingCourseDocumentDuplicator
{
public function __construct(
private readonly StoredObjectDuplicate $storedObjectDuplicate,
private readonly TranslatorInterface $translator,
private readonly ClockInterface $clock,
) {}
public function duplicate(AccompanyingCourseDocument $document): AccompanyingCourseDocument
{
$newDoc = new AccompanyingCourseDocument();
$newDoc
->setCourse($document->getCourse())
->setTitle($document->getTitle().' ('.$this->translator->trans('acc_course_document.duplicated_at', ['at' => $this->clock->now()]).')')
->setDate($document->getDate())
->setDescription($document->getDescription())
->setCategory($document->getCategory())
->setUser($document->getUser())
->setObject($this->storedObjectDuplicate->duplicate($document->getObject()))
;
return $newDoc;
}
}

View File

@@ -16,20 +16,28 @@ use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowWithPublicViewInterface;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\MainBundle\Workflow\Templating\EntityWorkflowViewMetadataDTO;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvideThirdPartiesAssociated;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvidePersonsAssociated;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @implements EntityWorkflowWithStoredObjectHandlerInterface<AccompanyingCourseDocument>
*/
readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface
final readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface, EntityWorkflowWithPublicViewInterface
{
public function __construct(
private TranslatorInterface $translator,
private EntityWorkflowRepository $workflowRepository,
private AccompanyingCourseDocumentRepository $repository,
private WorkflowWithPublicViewDocumentHelper $publicViewDocumentHelper,
private ProvideThirdPartiesAssociated $thirdPartiesAssociated,
private ProvidePersonsAssociated $providePersonsAssociated,
) {}
public function getDeletionRoles(): array
@@ -87,12 +95,28 @@ readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkfl
public function getSuggestedUsers(EntityWorkflow $entityWorkflow): array
{
$suggestedUsers = $entityWorkflow->getUsersInvolved();
$related = $this->getRelatedEntity($entityWorkflow);
$referrer = $this->getRelatedEntity($entityWorkflow)->getCourse()->getUser();
$suggestedUsers[spl_object_hash($referrer)] = $referrer;
if (null === $related) {
return [];
}
return $suggestedUsers;
$users = [];
if (null !== $user = $related->getUser()) {
$users[] = $user;
}
if (null !== $user = $related->getCourse()->getUser()) {
$users[] = $user;
}
return array_values(
// filter objects to remove duplicates
array_filter(
$users,
fn ($o, $k) => array_search($o, $users, true) === $k,
ARRAY_FILTER_USE_BOTH
)
);
}
public function getTemplate(EntityWorkflow $entityWorkflow, array $options = []): string
@@ -136,4 +160,31 @@ readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkfl
return $this->workflowRepository->findByRelatedEntity(AccompanyingCourseDocument::class, $object->getId());
}
public function renderPublicView(EntityWorkflowSend $entityWorkflowSend, EntityWorkflowViewMetadataDTO $metadata): string
{
return $this->publicViewDocumentHelper->render($entityWorkflowSend, $metadata, $this);
}
public function getSuggestedPersons(EntityWorkflow $entityWorkflow): array
{
$related = $this->getRelatedEntity($entityWorkflow);
if (null === $related) {
return [];
}
return $this->providePersonsAssociated->getPersonsAssociated($related->getCourse());
}
public function getSuggestedThirdParties(EntityWorkflow $entityWorkflow): array
{
$related = $this->getRelatedEntity($entityWorkflow);
if (null === $related) {
return [];
}
return $this->thirdPartiesAssociated->getThirdPartiesAssociated($related->getCourse());
}
}

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\Workflow;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Workflow\Event\CompletedEvent;
use Symfony\Component\Workflow\WorkflowEvents;
/**
* Event subscriber to convert objects to PDF when the document reach a signature step.
*/
class ConvertToPdfBeforeSignatureStepEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly StoredObjectToPdfConverter $storedObjectToPdfConverter,
private readonly RequestStack $requestStack,
) {}
public static function getSubscribedEvents(): array
{
return [
WorkflowEvents::COMPLETED => 'convertToPdfBeforeSignatureStepEvent',
];
}
public function convertToPdfBeforeSignatureStepEvent(CompletedEvent $event): void
{
$entityWorkflow = $event->getSubject();
if (!$entityWorkflow instanceof EntityWorkflow) {
return;
}
$tos = $event->getTransition()->getTos();
$workflow = $event->getWorkflow();
$metadataStore = $workflow->getMetadataStore();
foreach ($tos as $to) {
$metadata = $metadataStore->getPlaceMetadata($to);
if (array_key_exists('isSignature', $metadata) && 0 < count($metadata['isSignature'])) {
$this->convertToPdf($entityWorkflow);
return;
}
}
}
private function convertToPdf(EntityWorkflow $entityWorkflow): void
{
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
return;
}
if ('application/pdf' === $storedObject->getCurrentVersion()->getType()) {
return;
}
$this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $this->requestStack->getCurrentRequest()->getLocale(), 'pdf');
}
}

View File

@@ -0,0 +1,45 @@
<?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\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\MainBundle\Workflow\Templating\EntityWorkflowViewMetadataDTO;
use Twig\Environment;
class WorkflowWithPublicViewDocumentHelper
{
public function __construct(private readonly Environment $twig) {}
public function render(EntityWorkflowSend $send, EntityWorkflowViewMetadataDTO $metadata, EntityWorkflowHandlerInterface&EntityWorkflowWithStoredObjectHandlerInterface $handler): string
{
$entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow();
$storedObject = $handler->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
return 'document removed';
}
$title = $handler->getEntityTitle($entityWorkflow);
return $this->twig->render(
'@ChillDocStore/Workflow/public_view_with_document_render.html.twig',
[
'title' => $title,
'storedObject' => $storedObject,
'send' => $send,
'metadata' => $metadata,
]
);
}
}

View File

@@ -105,3 +105,50 @@ paths:
404:
description: "Not found"
/1.0/doc-store/stored-object/{uuid}/versions:
get:
tags:
- storedobject
summary: Get a signed route to post stored object
parameters:
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObjeect
schema:
type: string
format: uuid
responses:
200:
description: "OK"
content:
application/json:
schema:
type: object
403:
description: "Unauthorized"
404:
description: "Not found"
/1.0/doc-store/stored-object/restore-from-version/{id}:
post:
tags:
- storedobject
summary: Restore an old version of a stored object
parameters:
- in: path
name: id
required: true
allowEmptyValue: false
description: The id of the stored object version
schema:
type: integer
responses:
200:
description: "OK"
content:
application/json:
schema:
type: object

View File

@@ -5,5 +5,6 @@ module.exports = function(encore)
});
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');
encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index.ts');
encore.addEntry('mod_document_download_button', __dirname + '/Resources/public/module/button_download/index');
encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index');
};

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240910093735 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add point in time for stored object version';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_doc.stored_object_point_in_time_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_doc.stored_object_point_in_time (id INT NOT NULL, stored_object_version_id INT NOT NULL, reason TEXT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, byUser_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_CC83C7B81D0AB8B9 ON chill_doc.stored_object_point_in_time (stored_object_version_id)');
$this->addSql('CREATE INDEX IDX_CC83C7B8D23C0240 ON chill_doc.stored_object_point_in_time (byUser_id)');
$this->addSql('CREATE INDEX IDX_CC83C7B83174800F ON chill_doc.stored_object_point_in_time (createdBy_id)');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_point_in_time.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B81D0AB8B9 FOREIGN KEY (stored_object_version_id) REFERENCES chill_doc.stored_object_version (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B8D23C0240 FOREIGN KEY (byUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B83174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix SET DEFAULT \'\'');
$this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename SET DEFAULT \'\'');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_doc.stored_object_point_in_time_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B81D0AB8B9');
$this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B8D23C0240');
$this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B83174800F');
$this->addSql('DROP TABLE chill_doc.stored_object_point_in_time');
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix DROP DEFAULT');
$this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename DROP DEFAULT');
}
}

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\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240918073234 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a relation between stored object version when a version is restored';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object_version ADD createdFrom_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_doc.stored_object_version ADD CONSTRAINT FK_C1D553024DEC38BB FOREIGN KEY (createdFrom_id) REFERENCES chill_doc.stored_object_version (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_C1D553024DEC38BB ON chill_doc.stored_object_version (createdFrom_id)');
$this->addSql('ALTER INDEX chill_doc.idx_c1d55302232d562b RENAME TO IDX_C1D553024B136083');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object_version DROP createdFrom_id');
$this->addSql('ALTER INDEX chill_doc.idx_c1d553024b136083 RENAME TO idx_c1d55302232d562b');
}
}

View File

@@ -0,0 +1,11 @@
acc_course_document:
duplicated_at: >-
Dupliqué le {at, date, long} à {at, time, short}
workflow:
public_link:
doc_shared_by_at_explanation: >-
Le document a été partagé avec vous par {byUser}, le {at, date, long} à {at, time, short}.
doc_shared_automatically_at_explanation: >-
Le document a été partagé avec vous le {at, date, long} à {at, time, short}

View File

@@ -80,6 +80,10 @@ online_edit_document: Éditer en ligne
workflow:
Document deleted: Document supprimé
public_link:
shared_doc: Document partagé
title: Document partagé
main_document: Document principal
# ROLES
accompanyingCourseDocument: Documents dans les parcours d'accompagnement

View File

@@ -94,4 +94,38 @@ class NotificationApiController
return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false);
}
/**
* @Route("/mark/allread", name="chill_api_main_notification_mark_allread", methods={"POST"})
*/
public function markAllRead(): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$modifiedNotificationIds = $this->notificationRepository->markAllNotificationAsReadForUser($user);
return new JsonResponse($modifiedNotificationIds);
}
/**
* @Route("/mark/undoallread", name="chill_api_main_notification_mark_undoallread", methods={"POST"})
*/
public function undoAllRead(Request $request): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$ids = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$touchedIds = $this->notificationRepository->markAllNotificationAsUnreadForUser($user, $ids);
return new JsonResponse($touchedIds);
}
}

View File

@@ -169,7 +169,7 @@ class NotificationController extends AbstractController
#[Route(path: '/inbox', name: 'chill_main_notification_my')]
public function inboxAction(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$this->denyAccessUnlessGranted('ROLE_USER');
$currentUser = $this->security->getUser();
$notificationsNbr = $this->notificationRepository->countAllForAttendee($currentUser);
@@ -177,8 +177,8 @@ class NotificationController extends AbstractController
$notifications = $this->notificationRepository->findAllForAttendee(
$currentUser,
$limit = $paginator->getItemsPerPage(),
$offset = $paginator->getCurrentPage()->getFirstItemNumber()
$paginator->getItemsPerPage(),
$paginator->getCurrentPage()->getFirstItemNumber()
);
return $this->render('@ChillMain/Notification/list.html.twig', [

View File

@@ -278,7 +278,7 @@ final class PasswordController extends AbstractController
}
/**
* @return \Symfony\Component\Form\Form
* @return \Symfony\Component\Form\FormInterface
*/
private function passwordForm(User $user)
{

View File

@@ -264,6 +264,7 @@ class UserController extends CRUDController
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
@@ -273,11 +274,7 @@ class UserController extends CRUDController
return parent::countEntities($action, $request, $filterOrder);
}
if (null === $filterOrder->getQueryString()) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->userRepository->countByUsernameOrEmail($filterOrder->getQueryString());
return $this->userRepository->countFilteredUsers($filterOrder->getQueryString(), $filterOrder->getCheckboxData('activeFilter'));
}
protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface
@@ -334,16 +331,13 @@ class UserController extends CRUDController
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
if (null === $filterOrder->getQueryString()) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->userRepository->countFilteredUsers($queryString, $activeFilter);
return $this->userRepository->findByUsernameOrEmail(
$filterOrder->getQueryString(),
['usernameCanonical' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->userRepository->findFilteredUsers($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
}
protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request)
@@ -374,10 +368,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder()
->setAction($this->generateUrl(
->setAction(
$this->generateUrl(
'admin_user_add_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId()])
))
)
)
->setMethod('POST')
->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class)
->add('submit', SubmitType::class, ['label' => 'Add a new groupCenter'])
@@ -392,10 +388,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder()
->setAction($this->generateUrl(
->setAction(
$this->generateUrl(
'admin_user_delete_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()])
))
)
)
->setMethod('DELETE')
->add('submit', SubmitType::class, ['label' => 'Delete'])
->getForm();

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Symfony\Component\HttpFoundation\Request;
class UserGroupAdminController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addSelect('JSON_EXTRACT(e.label, :lang) AS HIDDEN labeli18n')
->setParameter('lang', $request->getLocale());
$query->addOrderBy('labeli18n', 'ASC');
return $query;
}
}

View File

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

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