Compare commits

...

99 Commits

Author SHA1 Message Date
99e4824137 Release bundles v.3.8.1 2025-02-05 18:14:22 +01:00
dacaaea235 Fix household link in parcours banner 2025-02-05 18:09:34 +01:00
096466e79e Add missing lines for v3.8.0 2025-02-03 22:22:11 +01:00
7285e5c2b0 Merge branch '331-manage-attachments-to-workflow' into 'master'
Add attachments to workflow

Closes #331

See merge request Chill-Projet/chill-bundles!764
2025-02-03 21:15:00 +00:00
37227a3aeb Add attachments to workflow 2025-02-03 21:15:00 +00:00
7569667189 Release v3.8.0 2025-02-03 22:11:42 +01:00
b0993f4062 Merge branch '334-current-user-activity-suggestion' into 'master'
Resolve "Quand on crée un échange, l'utilisateur courant doit toujours être suggéré"

Closes #334

See merge request Chill-Projet/chill-bundles!786
2025-02-03 21:08:49 +00:00
7c79b65f48 Merge branch '350-validation_person_duplicate' into 'master'
Resolve "Erreur 500 lorsqu'un utilisateur fusionne deux dossiers d'usagers, et que les deux dossiers usagers sélectionnés sont les mêmes"

Closes #350

See merge request Chill-Projet/chill-bundles!788
2025-02-03 21:04:28 +00:00
b8f25bcd45 Resolve "Erreur 500 lorsqu'un utilisateur fusionne deux dossiers d'usagers, et que les deux dossiers usagers sélectionnés sont les mêmes" 2025-02-03 21:04:27 +00:00
f4efb0e975 Merge branch '351-prettier_span_fix' into 'master'
Resolve "UX bug caused by 'Prettier' linter"

Closes #351

See merge request Chill-Projet/chill-bundles!790
2025-02-03 21:02:46 +00:00
c641baec78 Resolve "UX bug caused by 'Prettier' linter" 2025-02-03 21:02:44 +00:00
cc150e32f0 Merge branch '354-fix_creation_document_category' into 'master'
Resolve "chill_doc: cannot create a document category"

Closes #354

See merge request Chill-Projet/chill-bundles!791
2025-02-03 21:02:03 +00:00
26cf6459b4 Add changie 2025-02-03 11:23:12 +01:00
d0fa6dd512 Apply fix to query idInsideBundle for documentCategory creation 2025-02-03 11:21:00 +01:00
03748a7e84 Add changie 2025-01-30 12:00:11 +01:00
9e3431f397 Merge branch 'improve_news_item_admin_form' into 'master'
Improve ux news item admin form

See merge request Chill-Projet/chill-bundles!779
2025-01-27 10:59:51 +00:00
912861dbff Remove assert attribute on startdate 2025-01-27 11:03:46 +01:00
35f25daf7c Merge branch '320-display-persons-in-banner' into 'master'
Display first associated persons in the banner of an accompanying course

Closes #320

See merge request Chill-Projet/chill-bundles!769
2025-01-23 20:19:31 +00:00
21274155b5 Display first associated persons in the banner of an accompanying course 2025-01-23 20:19:31 +00:00
3f7c136d6b Remove yarn.lock from tracking 2025-01-23 12:40:39 +01:00
5d9c573853 Merge branch '319-display-info-in-notification' into 'master'
Resolve "Afficher les noms des usagers et l'entité concerné par l'entité notifiée dans la liste des notifications"

Closes #319

See merge request Chill-Projet/chill-bundles!767
2025-01-23 11:34:16 +00:00
9a5fd67842 Resolve "Afficher les noms des usagers et l'entité concerné par l'entité notifiée dans la liste des notifications" 2025-01-23 11:34:16 +00:00
2755bc12c4 Add current user to suggested users when creating activity 2025-01-22 15:41:25 +01:00
9e191f1b5b Restrict eslint on src directory 2025-01-22 14:19:30 +00:00
ab684a20ad Release version v3.7.1 2025-01-21 23:06:30 +01:00
bc92b52498 Merge branch 'fix-notifier-config' into 'master'
Fix notifier  legacy configuration and some improvements

See merge request Chill-Projet/chill-bundles!784
2025-01-21 22:05:27 +00:00
be5655e537 Refine Notifier component configuration guidance
Added detailed instructions to configure the Symfony Notifier component correctly, emphasizing proper setup for SMS providers like OVHcloud. Clarified removal of legacy configurations and included examples for both utilizing and replacing the added configuration.
2025-01-21 22:59:08 +01:00
ceb0bd982e Prevent default texter configuration if already defined
Check existing framework configurations for `texter_transports` before applying default settings. This avoids overwriting or conflicting with pre-defined notifier configurations.
2025-01-21 22:59:08 +01:00
47c0af3623 Handle missing message IDs in SentMessageEventSubscriber
Added a condition to log an info message when the sent SMS lacks a message ID. Ensures clearer distinction between successful and incomplete SMS message logging.
2025-01-21 22:39:37 +01:00
f6f2efee2c Release v3.7.0 with new feature and updated dependencies
Introduce the Symfony Notifier component for SMS messaging, supporting more providers. Fix referrer's scope and job aggregation issue (#348). Update numerous dependencies, including Babel and ESLint, for improved stability and functionality.
2025-01-21 16:07:00 +01:00
59fd9fc63f Add a line in the documentation about the role of prettier within eslint configuration 2025-01-21 10:36:45 +01:00
ec2c08681e Add --fix to eslint yarn command 2025-01-21 09:52:29 +01:00
fedcbb9a70 Add missing changie for MR !782 2025-01-20 15:47:01 +01:00
3f1a4fe353 Merge branch 'short-message-use-notifier-component' into 'master'
Send short messages using Notifier integration

See merge request Chill-Projet/chill-bundles!782
2025-01-20 14:17:01 +00:00
fc27c73dab Merge branch '332-changie-schema-prompt' into 'master'
Add a prompt to signal a schema change in the db

Closes #332

See merge request Chill-Projet/chill-bundles!772
2025-01-20 14:13:33 +00:00
20bfd5b717 Add Rector command to composer scripts
Included the Rector command in composer.json to streamline running Rector tasks. This addition ensures consistency and improves developer efficiency by integrating the tool directly into the project workflow.
2025-01-20 15:10:44 +01:00
5e3a1eb2ab Remove legacy ShortMessage components and integrate Notifier
Replaced outdated ShortMessage functionalities with Symfony's Notifier component for handling SMS messages. Deprecated legacy `ShortMessage` components and introduced a transition layer for existing OVH configurations. Updated dependencies and environment setup to support the new implementation.
2025-01-20 15:10:44 +01:00
b02820407c Add log SMS when a message is sent
Introduced a new event subscriber to log SMS sent events with details such as recipient and message IDs. This enhances monitoring and debugging of SMS delivery.
2025-01-20 15:10:43 +01:00
594ed4a5b4 Replace custom ShortMessage usage with Symfony’s SmsMessage.
Switched the entire short message notification system to leverage Symfony's Notifier component and its TexterInterface with SmsMessage. This update simplifies the implementation, removes custom short message handling, and aligns with Symfony's standardized approach.
2025-01-20 15:10:43 +01:00
88fbf7bc1c Add Symfony Notifier integration
Integrated Symfony Notifier into the project by adding it to `composer.json` and creating a configuration file `notifier.yaml`. Updated `symfony.lock` to include the recipe configuration for Notifier. Minor documentation formatting issues were also fixed in `index.rst`.
2025-01-20 14:28:49 +01:00
aa26e67f6f Update PHP-CS-Fixer cache path and add PHPStan command
Changed the cache path in `.php-cs-fixer.dist.php` to improve organization by moving it to the `var` directory. Added a new Composer script for running PHPStan to streamline static analysis workflows.
2025-01-20 14:12:24 +01:00
21ac3eaab4 Merge branch '348-erreur-dans-le-regroupement-par-service-du-referent-de-parcours' into 'master'
Resolve "Erreur dans le regroupement par service du référent de parcours"

Closes #348

See merge request Chill-Projet/chill-bundles!783
2025-01-20 12:10:25 +00:00
2ff500b00e Resolve "Erreur dans le regroupement par service du référent de parcours" 2025-01-20 12:10:25 +00:00
19fa308c06 Update chill bundles to version 3.6.0 2025-01-16 18:00:48 +01:00
1b831bc424 Fix activity between dates filter: condition added for alias 2025-01-16 15:19:38 +01:00
573118e514 Undo change migration 2025-01-16 13:05:18 +01:00
0cabf5654a Add changie for fix 2025-01-16 12:25:42 +01:00
cfb547d55f Increase length of varchar for id chill_person_marital_status 2025-01-16 12:23:59 +01:00
a915c35026 Rector changes 2025-01-16 10:25:02 +01:00
018f8aef5c Add export button to social actions template 2025-01-15 16:45:05 +01:00
de6385ba21 Refactor SocialIssuesExportController to include method for exporting social actions 2025-01-15 16:44:45 +01:00
edb51dd3cd Add export button to template 2025-01-15 16:35:09 +01:00
c379bccad4 Create social issue export controller 2025-01-15 16:33:57 +01:00
bd9ad8a569 remove prettier command from yarn 2025-01-15 13:08:26 +01:00
0cdd9184a3 Add condition to rendering of schema change 2025-01-14 16:10:58 +01:00
cb5fd2b69d Merge branch 'address-importer-ban' into 'master'
Add service and command to import French addresses from BAN

See merge request Chill-Projet/chill-bundles!781
2025-01-13 16:09:17 +00:00
feebcf6662 Add changie [ci-skip] 2025-01-13 17:08:45 +01:00
2a61197999 Merge branch '332-changie-schema-prompt' of https://gitlab.com/Chill-Projet/chill-bundles into 332-changie-schema-prompt 2025-01-13 16:22:14 +01:00
0a53a9a9d1 Add a prompt to signal a schema change in the db 2025-01-13 16:22:04 +01:00
eea1e40663 Merge branch 'improve_news_item_admin_form' of https://gitlab.com/Chill-Projet/chill-bundles into improve_news_item_admin_form 2025-01-13 16:20:08 +01:00
1b0771eb07 Add changie 2025-01-13 16:19:52 +01:00
3a74c48104 Indicate user to set startDate equal to or greater than today + set empty_data in form content field 2025-01-13 16:19:52 +01:00
6de4861b98 Fix email attachment handling in address import reports
Updated attachment logic to use in-memory file contents and apply a `.gz` suffix to filenames. This ensures better file handling and resolves potential issues with attaching files directly from a path.
2025-01-10 23:00:40 +01:00
b4a1e824ac Add service and command to import French addresses from BAN
Introduce a service to handle the import of French addresses from the Base Adresse Nationale (BAN) dataset. Add a new console command `chill:main:address-ref-from-ban` to trigger the import by department numbers, with an option to send a report email for unmatched addresses.
2025-01-10 22:52:08 +01:00
d87cf925e2 Add changie file for storing document on disk feature 2025-01-09 16:28:36 +01:00
ce3cce7b95 Merge branch '346-store-docs-on-disk' into 'master'
Resolve "Permettre de stocker les documents sur disque, localement."

Closes #346

See merge request Chill-Projet/chill-bundles!774
2025-01-09 15:16:45 +00:00
6c97654e5e Add documentation for document storage configuration
Introduce a new guide detailing document storage options, including on-disk storage and cloud-based OpenStack integration. This document explains configuration steps, benefits, and limitations for both methods, and is now linked in the production installation index.
2025-01-09 16:05:58 +01:00
0787e61c22 Set default configuration file for chill_doc_store 2025-01-09 15:25:44 +01:00
73bcfb82b7 Add configuration option to select storage driver
Introduces a new `use_driver` configuration option to specify the desired storage driver (`local_storage` or `openstack`). Ensures proper validation to handle multiple drivers and throws appropriate errors when configurations are inconsistent or missing. Refactors related logic to improve clarity and maintainability.
2025-01-09 15:25:43 +01:00
812e4047d0 Adjust key size in KeyGenerator to 32 bytes.
Changed the key size from 128 bytes to 32 bytes in the KeyGenerator service. This aligns with the expected algorithm requirements and ensures proper cryptographic behavior.
2025-01-09 15:25:43 +01:00
999ac3af2b Add TempUrl signature validation to local storage
Implemented local storage-based file handling with TempUrl signature validation for upload and retrieval. Added validation checks for parameters like max file size/count, expiration, and signature integrity. Included unit tests for TempUrl signature validation and adjusted configuration for local storage.
2025-01-09 15:25:42 +01:00
0c628c39db store encrypted content 2025-01-09 15:25:42 +01:00
c65f1d495d Refactor ConfigureOpenstackObjectStorageCommand
- change namespace for more obvious handling;
- remove command of local storage is configured
2025-01-09 15:25:41 +01:00
83f7086bb0 Configure DI for providing kernel secret for TempUrlLocalStorageGenerator 2025-01-09 15:25:41 +01:00
c1e449f48e Implements StoredObjectManager for local storage 2025-01-09 15:25:41 +01:00
1f6de3cb11 Implement TempUrlLocalStorageGenerator and its tests
Added the full implementation for TempUrlLocalStorageGenerator, including methods to generate signed URLs and POST requests with expiration and signature logic. Introduced corresponding unit tests to validate functionality using mocked dependencies.
2025-01-09 15:25:40 +01:00
3a2548ed89 Select storage depending on configuration 2025-01-09 15:25:39 +01:00
d7652658f2 Define new configuration for local storage 2025-01-09 15:25:39 +01:00
67b5bc6dba Implements required interface to store documents on disk 2025-01-09 15:25:38 +01:00
e25c1e1816 Refactor object storage to separate local storage and openstack storage 2025-01-09 15:25:38 +01:00
282b7f7fbb Merge branch 'import-addresses-handle-no-postcode' into 'master'
Allow addresses without postal code to be imported without failure, and add email reporting for unimported addresses in import commands

See merge request Chill-Projet/chill-bundles!780
2025-01-09 11:52:21 +00:00
ab311eaecb Add email reporting for unimported addresses in import commands
Enhanced address import commands to optionally send a recap of unimported addresses via email. Updated import logic to handle cases where postal codes are missing, log issues, and generate compressed CSV reports with failed entries.
2025-01-09 12:21:10 +01:00
b37d7fb907 Add changie 2025-01-08 16:21:36 +01:00
57b8dacba0 Indicate user to set startDate equal to or greater than today + set empty_data in form content field 2025-01-08 16:18:13 +01:00
edcc01149b Merge branch 'master' of gitlab.com:Chill-Projet/chill-bundles 2025-01-07 16:59:32 +01:00
27b2d77fdb Fix EntityToJsonTransformer for saved exports 2025-01-07 16:59:16 +01:00
96bb98f854 upgrade yarn deps 2025-01-07 10:03:40 +01:00
68ed2db51e Refactor exception type to InvalidConfigurationException.
Replaced InvalidArgumentException with InvalidConfigurationException for widget service alias conflicts. This ensures the exception better reflects the configuration-related nature of the error.
2025-01-07 10:03:24 +01:00
184bb095d8 Update chill-bundles version to 3.5.2 2024-12-19 10:45:07 +01:00
78c8e94765 Merge branch '345-export-activity-bug' into 'master'
Fix the filtering of users that have been associated to an activity between certain dates

Closes #345

See merge request Chill-Projet/chill-bundles!773
2024-12-19 09:41:21 +00:00
c8d1a91953 Fix the filtering of users that have been associated to an activity between certain dates 2024-12-19 09:41:21 +00:00
3e8e2b0fa8 Merge branch 'add-employment-status' into 'master'
add an employmentStatus property to the person entity

See merge request Chill-Projet/chill-bundles!763
2024-12-17 15:15:57 +00:00
Christophe Siraut
2c9c700ca7 composer.json: specify doctrine/data-fixtures version 2024-12-16 17:35:22 +01:00
Christophe Siraut
110db30748 ChillPersonBundle: add employmentStatus property to Person 2024-12-16 17:35:19 +01:00
Christophe Siraut
1f96f76f87 ChillPersonBundle: add a visibility parameter to addFieldNode() 2024-12-16 16:54:18 +01:00
Christophe Siraut
65902ea231 ChillCUstomFieldsBundle: Symfony\Component\Frm\FormFactory::createNamedBuilder(): Argument #1 () must be of type string 2024-12-16 16:54:18 +01:00
Christophe Siraut
88c0b1570d composer: require-dev php-cs-fixer 2024-12-16 16:54:18 +01:00
4933d2251c fix tests about PdfSignedMessageHandler.php 2024-12-16 16:50:07 +01:00
60386ae9ac Add a prompt to signal a schema change in the db 2024-12-10 18:30:44 +01:00
236 changed files with 7360 additions and 1831 deletions

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

@@ -0,0 +1,3 @@
## v3.5.2 - 2024-12-19
### Fixed
* ([#345](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/345)) Export: activity filtering of users that were associated to an activity between certain dates. Results contained activities that were not within the specified date range"

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

@@ -0,0 +1,3 @@
## v3.5.3 - 2025-01-07
### Fixed
* Fix the EntityToJsonTransformer to return an empty array if the value is ""

9
.changes/v3.6.0.md Normal file
View File

@@ -0,0 +1,9 @@
## v3.6.0 - 2025-01-16
### Feature
* Importer for addresses does not fails when the postal code is not found with some addresses, and compute a recap list of all addresses that could not be imported. This recap list can be send by email.
* ([#346](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/346)) Create a driver for storing documents on disk (instead of openstack object store)
* Add address importer from french Base d'Adresse Nationale (BAN)
* ([#343](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/343)) Add csv export for social issues and social actions
### Fixed
* Export: fix missing alias in activity between certain dates filter. Condition added for alias.

62
.changes/v3.7.0.md Normal file
View File

@@ -0,0 +1,62 @@
## v3.7.0 - 2025-01-21
### Feature
* Use the Notifier component from Symfony to sens short messages (SMS). This allow to use more provider.
### Fixed
* ([#348](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/348)) [export] Fix aggregation of referrer's scope and job: fix the date range comparison
### Warning on configuration of Notifier component
If installed in an symfony app where the recipes are activated, this configuration should be added automatically:
```yaml
framework:
notifier:
chatter_transports:
texter_transports:
ovhcloud: '%env(OVHCLOUD_DSN)%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }
```
Actually, you should either:
- remove the configuration of ovhcloud added by the recipe
- or remove the previous configuration of chill, to avoid keeping legacy configuration
#### Remove the added configuration and keep the legacy configuration
To remove the configuration:
```diff
framework:
notifier:
chatter_transports:
texter_transports:
- ovhcloud: '%env(OVHCLOUD_DSN)%'
```
In that case, the previous configuration, which was stored under the `chill_main.short_messages.dsn` will be reconfigured into the Notifier component's configuration.
#### Properly configure SMS
You can also properly configure it, as [described in the OVH cloud provider repository](https://github.com/symfony/ovh-cloud-notifier/tree/5.4?tab=readme-ov-file#dsn-example) (where the scheme is `ovhcloud`):
**NOTE**: You have access to all notifier available with the [Notifier component](https://symfony.com/doc/current/notifier.html#notifier-sms-channel). You are not restricted to use OVH as a provider.
```diff
framework:
notifier:
chatter_transports:
texter_transports:
+ ovhcloud: '%env(OVHCLOUD_DSN)%' # this value should be located in a variable, and have `ovhcloud://` as a scheme
chill_main:
- short_messages:
- dsn: '%env(string:SHORT_MESSAGE_DSN)%'
```

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

@@ -0,0 +1,3 @@
## v3.7.1 - 2025-01-21
### Fixed
* Fix legacy configuration processor for notifier component

11
.changes/v3.8.0.md Normal file
View File

@@ -0,0 +1,11 @@
## v3.8.0 - 2025-02-03
### Feature
* Improve the UX of the news item admin form to prevent wrong usage
* ([#319](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/319)) Notification list: display the concerned person's badges in the list
* ([#320](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/320)) Show the first 3 persons directly in the accompanying period's banner
* ([#334](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/334)) Suggest current user when creating an activity
* ([#331](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/331)) Add attachments to workflows
### Fixed
* ([#350](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/350)) Add validation error to manual selection of person in PersonDuplicateController
* ([#354](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/354)) Fix document category creation
* ([#351](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/351)) Add definitive whitespace between span elements in vue PersonText component

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

@@ -0,0 +1,3 @@
## v3.8.1 - 2025-02-05
### Fixed
* Fix household link in the parcours banner

View File

@@ -7,15 +7,29 @@ versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}'
kindFormat: '### {{.Kind}}'
# Note: it is possible to add a `.custom.Long` text manually into the yaml file produced by `changie new`. This will add a long description.
changeFormat: >-
* {{ if not (eq .Custom.Issue "") }}([#{{ .Custom.Issue }}](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/{{ .Custom.Issue }})) {{ end }}{{.Body}} {{ if and (.Custom.Long) (not (eq .Custom.Long "")) }}
* {{ if not (eq .Custom.Issue "") }}([#{{ .Custom.Issue }}](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/{{ .Custom.Issue }})) {{ end }}{{ .Body }} {{ if and .Custom.SchemaChange (ne .Custom.SchemaChange "No schema change") }}
**Schema Change**: {{ .Custom.SchemaChange }}
{{- end -}}
{{ if and (.Custom.Long) (not (eq .Custom.Long "")) }}{{ .Custom.Long }}{{ end }}
{{ .Custom.Long }}{{ end }}
custom:
- key: SchemaChange
label: Is a schema change required?
optional: false
type: enum
enumOptions:
- "No schema change"
- "Add columns or tables"
- "Drop or rename table or columns, or enforce new constraint that must be manually fixed"
- key: Issue
label: Issue number (on chill-bundles repository) (optional)
optional: true
type: int
minInt: 1
body:
# allow multiline messages
block: true

4
.env
View File

@@ -88,3 +88,7 @@ REDIS_HOST=redis
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
###< chill-project/chill-bundles ###
###> symfony/ovh-cloud-notifier ###
# OVHCLOUD_DSN=ovhcloud://APPLICATION_KEY:APPLICATION_SECRET@default?consumer_key=CONSUMER_KEY&service_name=SERVICE_NAME
###< symfony/ovh-cloud-notifier ###

6
.gitignore vendored
View File

@@ -5,6 +5,7 @@ composer.lock
docs/build/
.php_cs.cache
.cache/*
yarn.lock
docker/db/data
docker/rabbitmq/data
@@ -51,3 +52,8 @@ phpstan.neon
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###

View File

@@ -25,7 +25,7 @@ $config = new PhpCsFixer\Config();
$config
->setFinder($finder)
->setRiskyAllowed(true)
->setCacheFile('.cache/php-cs-fixer.cache')
->setCacheFile('var/php-cs-fixer.cache')
->setUsingCache(true)
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
;

View File

@@ -6,6 +6,107 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v3.8.1 - 2025-02-05
### Fixed
* Fix household link in the parcours banner
## v3.8.0 - 2025-02-03
### Feature
* Improve the UX of the news item admin form to prevent wrong usage
* ([#319](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/319)) Notification list: display the concerned person's badges in the list
* ([#320](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/320)) Show the first 3 persons directly in the accompanying period's banner
* ([#334](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/334)) Suggest current user when creating an activity
* ([#331](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/331)) Add attachments to workflows
### Fixed
* ([#350](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/350)) Add validation error to manual selection of person in PersonDuplicateController
* ([#354](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/354)) Fix document category creation
* ([#351](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/351)) Add definitive whitespace between span elements in vue PersonText component
## v3.7.1 - 2025-01-21
### Fixed
* Fix legacy configuration processor for notifier component
## v3.7.0 - 2025-01-21
### Feature
* Use the Notifier component from Symfony to sens short messages (SMS). This allow to use more provider.
### Fixed
* ([#348](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/348)) [export] Fix aggregation of referrer's scope and job: fix the date range comparison
### Warning on configuration of Notifier component
If installed in an symfony app where the recipes are activated, this configuration should be added automatically:
```yaml
framework:
notifier:
chatter_transports:
texter_transports:
ovhcloud: '%env(OVHCLOUD_DSN)%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }
```
Actually, you should either:
- remove the configuration of ovhcloud added by the recipe
- or remove the previous configuration of chill, to avoid keeping legacy configuration
#### Remove the added configuration and keep the legacy configuration
To remove the configuration:
```diff
framework:
notifier:
chatter_transports:
texter_transports:
- ovhcloud: '%env(OVHCLOUD_DSN)%'
```
In that case, the previous configuration, which was stored under the `chill_main.short_messages.dsn` will be reconfigured into the Notifier component's configuration.
#### Properly configure SMS
You can also properly configure it, as [described in the OVH cloud provider repository](https://github.com/symfony/ovh-cloud-notifier/tree/5.4?tab=readme-ov-file#dsn-example) (where the scheme is `ovhcloud`):
**NOTE**: You have access to all notifier available with the [Notifier component](https://symfony.com/doc/current/notifier.html#notifier-sms-channel). You are not restricted to use OVH as a provider.
```diff
framework:
notifier:
chatter_transports:
texter_transports:
+ ovhcloud: '%env(OVHCLOUD_DSN)%' # this value should be located in a variable, and have `ovhcloud://` as a scheme
chill_main:
- short_messages:
- dsn: '%env(string:SHORT_MESSAGE_DSN)%'
```
## v3.6.0 - 2025-01-16
### Feature
* Importer for addresses does not fails when the postal code is not found with some addresses, and compute a recap list of all addresses that could not be imported. This recap list can be send by email.
* ([#346](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/346)) Create a driver for storing documents on disk (instead of openstack object store)
* Add address importer from french Base d'Adresse Nationale (BAN)
* ([#343](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/343)) Add csv export for social issues and social actions
### Fixed
* Export: fix missing alias in activity between certain dates filter. Condition added for alias.
## v3.5.3 - 2025-01-07
### Fixed
* Fix the EntityToJsonTransformer to return an empty array if the value is ""
## v3.5.2 - 2024-12-19
### Fixed
* ([#345](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/345)) Export: activity filtering of users that were associated to an activity between certain dates. Results contained activities that were not within the specified date range"
## v3.5.1 - 2024-12-16
### Fixed
* Filiation: fix the display of the gender label in the graph

View File

@@ -13,8 +13,10 @@
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",
"ext-zlib": "*",
"champs-libres/wopi-bundle": "dev-master@dev",
"champs-libres/wopi-lib": "dev-master@dev",
"doctrine/data-fixtures": "^1.8",
"doctrine/doctrine-bundle": "^2.1",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.13.0",
@@ -56,7 +58,9 @@
"symfony/messenger": "^5.4",
"symfony/mime": "^5.4",
"symfony/monolog-bundle": "^3.5",
"symfony/notifier": "^5.4",
"symfony/options-resolver": "^5.4",
"symfony/ovh-cloud-notifier": "^5.4",
"symfony/process": "^5.4",
"symfony/property-access": "^5.4",
"symfony/property-info": "^5.4",
@@ -85,6 +89,7 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3",
"fakerphp/faker": "^1.13",
"friendsofphp/php-cs-fixer": "3.65.0",
"jangregor/phpstan-prophecy": "^1.0",
"nelmio/alice": "^3.8",
"nikic/php-parser": "^4.15",
@@ -157,7 +162,9 @@
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"php-cs-fixer": "php-cs-fixer fix --config=./.php-cs-fixer.dist.php --show-progress=none"
"php-cs-fixer": "php-cs-fixer fix --config=./.php-cs-fixer.dist.php --show-progress=none",
"phpstan": "phpstan --no-progress",
"rector": "rector --no-progress-bar"
},
"extra": {
"symfony": {

View File

@@ -1,4 +1,7 @@
chill_doc_store:
use_driver: openstack
local_storage:
storage_path: '%kernel.project_dir%/var/storage'
openstack:
temp_url:
temp_url_key: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_KEY)%' # Required

View File

@@ -0,0 +1,13 @@
framework:
notifier:
texter_transports:
#ovhcloud: '%env(OVHCLOUD_DSN)%'
#ovhcloud: '%env(SHORT_MESSAGE_DSN)%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }

View File

@@ -0,0 +1,19 @@
when@dev:
sass_assets:
path: /_dev/assets
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: '@ChillMain/Dev/dev.assets.html.twig'
sass_assets_test1:
path: /_dev/assets_test1
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: '@ChillMain/Dev/dev.assets.test1.html.twig'
sass_assets_test2:
path: /_dev/assets_test2
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: '@ChillMain/Dev/dev.assets.test2.html.twig'

View File

@@ -0,0 +1,12 @@
when@dev:
swagger_ui:
path: /_dev/swagger
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: '@ChillMain/Dev/swagger-ui/index.html.twig'
swagger_specs:
path: /_dev/specs.yaml
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: api/specs.yaml

View File

@@ -12,6 +12,8 @@ This runs eslint **not** taking the baseline into account, thus showing all exis
A script was also added to package.json allowing you to execute ``yarn run eslint``.
This will run eslint, but **taking the baseline into account**, thus only alerting to newly created errors.
The eslint command is configured to also run ``prettier`` which will simply format the code to look more uniform (takes care indentation for example).
Interesting options that can be used in combination with eslint are:
- ``--quiet`` to only get errors and silence the warnings

View File

@@ -16,7 +16,7 @@ Welcome to Chill documentation!
Chill is a free software for social workers.
Chill rely on the php framework `Symfony <http://symfony.com>`_.
Chill rely on the php framework `Symfony <http://symfony.com>`_.
Contents of this documentation:
@@ -42,7 +42,7 @@ Contribute
User manual
===========
An user manual exists in French and currently focuses on describing the main concept of the software.
An user manual exists in French and currently focuses on describing the main concept of the software.
`Read (and contribute) to the manual <https://fr.wikibooks.org/wiki/Chill>`_
@@ -55,12 +55,11 @@ Available bundles
* Chill Person, to deal with persons,
* chill custom fields, to add custom fields to some entities,
* chill activity: to add activities to people,
* chill report: to add report to people,
* chill report: to add report to people,
* chill event: to gather people into events,
* chill docs store: to store documents to people, but also entities,
* chill task: to register task with people,
* chill third party: to register third parties,
* chill family members: to register family members
You will also found the following projects :

View File

@@ -0,0 +1,84 @@
Document storage
################
You can store document on two different ways:
- on disk
- in the cloud, using object storage: currently only `openstack swift <https://docs.openstack.org/api-ref/object-store/index.html>`_ is supported.
Comparison
==========
Storing documents within the cloud is particularily suitable for "portable" deployments, like in kubernetes, or within container
without having to manage volumes to store documents. But you'll have to subscribe on a commercial offer.
Storing documents on disk is more easy to configure, but more difficult to manage: if you use container, you will have to
manager volumes to attach documents on disk. You'll have to do some backup of the directory. If chill is load-balanced (and
multiple instances of chill are run), you will have to find a way to share the directories in read-write mode for every instance.
On Disk
=======
Configure Chill like this:
.. code-block:: yaml
# file config/packages/chill_doc_store.yaml
chill_doc_store:
use_driver: local_storage
local_storage:
storage_path: '%kernel.project_dir%/var/storage'
In this configuration, documents will be stored in :code:`var/storage` within your app directory. But this path can be
elsewhere on the disk. Be aware that the directory must be writable by the user executing the chill app (php-fpm or www-data).
Documents will be stored in subpathes within that directory. The files will be encrypted, the key is stored in the database.
In the cloud, using openstack object store
##########################################
You must subscribe to a commercial offer for object store.
Chill use some features to allow documents to be stored in the cloud without being uploaded first to the chill server:
- `Form POST Middelware <https://docs.openstack.org/swift/latest/api/form_post_middleware.html>`_;
- `Temporary URL Middelware <https://docs.openstack.org/swift/latest/api/temporary_url_middleware.html>`_.
A secret key must be generated and configured, and CORS must be configured depending on the domain you will use to serve Chill.
At first, create a container and get the base path to the container. For instance, on OVH, if you create a container named "mychill",
you will be able to retrieve the base path of the container within the OVH interface, like this:
- base_path: :code:`https://storage.gra.cloud.ovh.net/v1/AUTH_123456789/mychill/` => will be variable :code:`ASYNC_UPLOAD_TEMP_URL_BASE_PATH`
- container: :code:`mychill` => will be variable :code:`ASYNC_UPLOAD_TEMP_URL_CONTAINER`
You can also generate a key, which should have at least 20 characters. This key will go in the variable :code:`ASYNC_UPLOAD_TEMP_URL_KEY`.
.. note::
See the `documentation of symfony <https://symfony.com/doc/current/configuration.html#config-env-vars>`_ on how to store variables, and how to encrypt them if needed.
Configure the storage like this:
.. code-block:: yaml
# file config/packages/chill_doc_store.yaml
chill_doc_store:
use_driver: openstack
openstack:
temp_url:
temp_url_key: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_KEY)%' # Required
container: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_CONTAINER)%' # Required
temp_url_base_path: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_BASE_PATH)%' # Required
Chill is able to configure the container in order to store document. Grab an Openstack Token (for instance, using :code:`openstack token issue` or
the web interface of your openstack provider), and run this command:
.. code-block:: bash
symfony console async-upload:configure --os_token=OPENSTACK_TOKEN -d https://mychill.mydomain.example
# or, without symfony-cli
bin/console async-upload:configure --os_token=OPENSTACK_TOKEN -d https://mychill.mydomain.example

View File

@@ -323,6 +323,7 @@ Going further
:maxdepth: 2
prod.rst
document-storage.rst
load-addresses.rst
prod-calendar-sms-sending.rst
msgraph-configure.rst

View File

@@ -16,7 +16,7 @@
"@eslint/js": "^9.14.0",
"@luminateone/eslint-baseline": "^1.0.9",
"@symfony/webpack-encore": "^4.1.0",
"@tsconfig/node14": "^1.0.1",
"@tsconfig/node20": "^20.1.4",
"@types/dompurify": "^3.0.5",
"@types/eslint__js": "^8.42.3",
"@typescript-eslint/parser": "^8.12.2",
@@ -30,7 +30,6 @@
"eslint-plugin-vue": "^9.30.0",
"fork-awesome": "^1.1.7",
"jquery": "^3.6.0",
"marked": "^12.0.1",
"node-sass": "^8.0.0",
"popper.js": "^1.16.1",
"postcss-loader": "^7.0.2",
@@ -78,10 +77,14 @@
"scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev",
"prettier": "prettier --write \"**/*.{js,ts,vue}\"",
"watch": "encore dev --watch",
"build": "encore production --progress",
"eslint": "npx eslint-baseline \"**/*.{js,ts,vue}\""
"specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml> templates/api/specs.yaml",
"specs-validate": "swagger-cli validate templates/api/specs.yaml",
"specs-create-dir": "mkdir -p templates/api",
"specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate",
"version": "node --version",
"eslint": "npx eslint-baseline --fix \"src/**/*.{js,ts,vue}\""
},
"private": true
}

View File

@@ -20,6 +20,10 @@ return static function (RectorConfig $rectorConfig): void {
__DIR__ . '/src',
]);
$rectorConfig->skip([
\Rector\Php55\Rector\String_\StringClassNameToClassConstantRector::class => __DIR__ . 'src/Bundle/ChillMainBundle/Service/Notifier/LegacyOvhCloudFactory.php'
]);
$rectorConfig->symfonyContainerXml(__DIR__ . '/var/cache/dev/test/App_KernelTestDebugContainer.xml ');
$rectorConfig->symfonyContainerPhp(__DIR__ . '/tests/symfony-container.php');

View File

@@ -55,6 +55,10 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
.' AND '
.'(person_person_having_activity.id = person.id OR person MEMBER OF activity_person_having_activity.persons)');
if (\in_array('activity', $qb->getAllAliases(), true)) {
$sqb->andWhere('activity_person_having_activity.id = activity.id');
}
if (isset($data['reasons']) && [] !== $data['reasons']) {
// add clause activity reason
$sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity');

View File

@@ -15,10 +15,13 @@ use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
final readonly class ActivityNotificationHandler implements NotificationHandlerInterface
{
public function __construct(private ActivityRepository $activityRepository) {}
public function __construct(private ActivityRepository $activityRepository, private TranslatableStringHelperInterface $translatableStringHelper) {}
public function getTemplate(Notification $notification, array $options = []): string
{
@@ -37,4 +40,30 @@ final readonly class ActivityNotificationHandler implements NotificationHandlerI
{
return Activity::class === $notification->getRelatedEntityClass();
}
public function getTitle(Notification $notification, array $options = []): TranslatableInterface
{
if (null === $activity = $this->getRelatedEntity($notification)) {
return new TranslatableMessage('activity.deleted');
}
return new TranslatableMessage('activity.title', [
'date' => $activity->getDate(),
'type' => $this->translatableStringHelper->localize($activity->getActivityType()->getName()),
]);
}
public function getAssociatedPersons(Notification $notification, array $options = []): array
{
if (null === $activity = $this->getRelatedEntity($notification)) {
return [];
}
return $activity->getPersonsAssociated();
}
public function getRelatedEntity(Notification $notification): ?Activity
{
return $this->activityRepository->find($notification->getRelatedEntityId());
}
}

View File

@@ -2,6 +2,7 @@ import "es6-promise/auto";
import { createStore } from "vuex";
import { postLocation } from "./api";
import prepareLocations from "./store.locations.js";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
const debug = process.env.NODE_ENV !== "production";
//console.log('window.activity', window.activity);
@@ -23,6 +24,7 @@ const removeIdFromValue = (string, id) => {
const store = createStore({
strict: debug,
state: {
me: null,
activity: window.activity,
socialIssuesOther: [],
socialActionsList: [],
@@ -79,15 +81,25 @@ const store = createStore({
);
},
suggestedUser(state) {
// console.log('current user', state.me)
const existingUserIds = state.activity.users.map((p) => p.id);
return state.activity.activityType.usersVisible === 0
? []
: [state.activity.accompanyingPeriod.user].filter(
(u) => u !== null && !existingUserIds.includes(u.id),
);
let suggestedUsers =
state.activity.activityType.usersVisible === 0
? []
: [state.activity.accompanyingPeriod.user].filter(
(u) => u !== null && !existingUserIds.includes(u.id),
);
// Add the current user from the state
if (state.me && !existingUserIds.includes(state.me.id)) {
suggestedUsers.push(state.me);
}
console.log("suggested users", suggestedUsers);
return suggestedUsers;
},
suggestedResources(state) {
const resources = state.activity.accompanyingPeriod.resources;
// const resources = state.activity.accompanyingPeriod.resources;
const existingPersonIds = state.activity.persons.map((p) => p.id);
const existingThirdPartyIds = state.activity.thirdParties.map(
(p) => p.id,
@@ -111,6 +123,9 @@ const store = createStore({
},
},
mutations: {
setWhoAmI(state, me) {
state.me = me;
},
// SocialIssueAcc
addIssueInList(state, issue) {
//console.log('add issue list', issue.id);
@@ -326,9 +341,17 @@ const store = createStore({
}
commit("updateLocation", value);
},
getWhoAmI({ commit }) {
const url = `/api/1.0/main/whoami.json`;
makeFetch("GET", url).then((user) => {
commit("setWhoAmI", user);
});
},
},
});
store.dispatch("getWhoAmI");
prepareLocations(store);
export default store;

View File

@@ -1,83 +1,3 @@
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %}
{% import '@ChillPerson/Macro/updatedBy.html.twig' as mmm %}
{% set person_id = null %}
{% if activity.person %}
{% set person_id = activity.person.id %}
{% endif %}
{% set accompanying_course_id = null %}
{% if activity.accompanyingPeriod %}
{% set accompanying_course_id = activity.accompanyingPeriod.id %}
{% endif %}
<div class="item-bloc activity-item{% if itemBlocClass is defined %} {{ itemBlocClass }}{% endif %}">
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div>
{% if activity.accompanyingPeriod is not null and context == 'person' %}
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ activity.accompanyingPeriod.id }}
</span>&nbsp;
{% endif %}
<div class="badge-activity-type">
<span class="title_label"></span>
<span class="title_action">
{{ activity.type.name | localize_translatable_string }}
{% if activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</div>
</div>
<div class="denomination h2">
{{ document.title|chill_print_or_message("No title") }}
</div>
{% if document.hasTemplate %}
<div>
<p>{{ document.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
<div class="dates row text-end">
<span>{{ document.createdAt|format_date('short') }}</span>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %}
<li>
{{ document|chill_document_button_group(document.title, is_granted('CHILL_ACTIVITY_UPDATE', activity), {small: false}) }}
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_SEE', activity)%}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': activity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_UPDATE', activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_edit', {'id': activity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id }) }}" class="btn btn-edit"></a>
</li>
{% endif %}
</ul>
</div>
{{ include('@ChillActivity/GenericDoc/activity_document_row.html.twig') }}
</div>

View File

@@ -0,0 +1,81 @@
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %}
{% import '@ChillPerson/Macro/updatedBy.html.twig' as mmm %}
{% set person_id = null %}
{% if activity.person %}
{% set person_id = activity.person.id %}
{% endif %}
{% set accompanying_course_id = null %}
{% if activity.accompanyingPeriod %}
{% set accompanying_course_id = activity.accompanyingPeriod.id %}
{% endif %}
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div>
{% if activity.accompanyingPeriod is not null and context == 'person' %}
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ activity.accompanyingPeriod.id }}
</span>&nbsp;
{% endif %}
<div class="badge-activity-type">
<span class="title_label"></span>
<span class="title_action">
{{ activity.type.name | localize_translatable_string }}
{% if activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</div>
</div>
<div class="denomination h2">
{{ document.title|chill_print_or_message("No title") }}
</div>
{% if document.hasTemplate %}
<div>
<p>{{ document.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
<div class="dates row text-end">
<span>{{ document.createdAt|format_date('short') }}</span>
</div>
</div>
</div>
</div>
{% if show_actions %}
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %}
<li>
{{ document|chill_document_button_group(document.title, is_granted('CHILL_ACTIVITY_UPDATE', activity), {small: false}) }}
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_SEE', activity)%}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': activity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_UPDATE', activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_edit', {'id': activity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id }) }}" class="btn btn-edit"></a>
</li>
{% endif %}
</ul>
</div>
{% endif %}

View File

@@ -0,0 +1,54 @@
<?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\Service\GenericDoc\Normalizer;
use Chill\ActivityBundle\Service\GenericDoc\Providers\AccompanyingPeriodActivityGenericDocProvider;
use Chill\ActivityBundle\Service\GenericDoc\Renderers\AccompanyingPeriodActivityGenericDocRenderer;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocNormalizerInterface;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
final readonly class AccompanyingPeriodActivityGenericDocNormalizer implements GenericDocNormalizerInterface
{
public function __construct(
private StoredObjectRepositoryInterface $storedObjectRepository,
private AccompanyingPeriodActivityGenericDocRenderer $renderer,
private Environment $twig,
private TranslatorInterface $translator,
) {}
public function supportsNormalization(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool
{
return AccompanyingPeriodActivityGenericDocProvider::KEY === $genericDocDTO->key
&& 'json' == $format;
}
public function normalize(GenericDocDTO $genericDocDTO, string $format, array $context = []): array
{
$storedObject = $this->storedObjectRepository->find($genericDocDTO->identifiers['id']);
if (null === $storedObject) {
return ['title' => $this->translator->trans('generic_doc.document removed'), 'isPresent' => false];
}
return [
'isPresent' => true,
'title' => $storedObject->getTitle(),
'html' => $this->twig->render(
$this->renderer->getTemplate($genericDocDTO, ['show-actions' => false, 'row-only' => true]),
$this->renderer->getTemplateData($genericDocDTO, ['show-actions' => false, 'row-only' => true]),
),
];
}
}

View File

@@ -13,10 +13,12 @@ namespace Chill\ActivityBundle\Service\GenericDoc\Providers;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Repository\ActivityDocumentACLAwareRepositoryInterface;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
@@ -34,8 +36,47 @@ final readonly class AccompanyingPeriodActivityGenericDocProvider implements Gen
private EntityManagerInterface $em,
private Security $security,
private ActivityDocumentACLAwareRepositoryInterface $activityDocumentACLAwareRepository,
private ActivityRepository $activityRepository,
) {}
public function fetchAssociatedStoredObject(GenericDocDTO $genericDocDTO): ?StoredObject
{
if (null === $activity = $this->getRelatedEntity($genericDocDTO->key, $genericDocDTO->identifiers)) {
return null;
}
return $activity->getDocuments()->findFirst(fn (int $key, StoredObject $storedObject) => $storedObject->getId() === $genericDocDTO->identifiers['id']);
}
public function supportsGenericDoc(GenericDocDTO $genericDocDTO): bool
{
return $this->supportsKeyAndIdentifiers($genericDocDTO->key, $genericDocDTO->identifiers);
}
public function supportsKeyAndIdentifiers(string $key, array $identifiers): bool
{
return self::KEY === $key && array_key_exists('activity_id', $identifiers);
}
private function getRelatedEntity(string $key, array $identifiers): ?Activity
{
return $this->activityRepository->find($identifiers['activity_id']);
}
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO
{
if (null === $activity = $this->getRelatedEntity($key, $identifiers)) {
return null;
}
return new GenericDocDTO(
self::KEY,
$identifiers,
\DateTimeImmutable::createFromInterface($activity->getDate()),
$activity->getAccompanyingPeriod(),
);
}
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
$storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class);

View File

@@ -18,6 +18,9 @@ use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
/**
* @implements GenericDocRendererInterface<array{row-only?: bool, show-actions?: bool}>
*/
final readonly class AccompanyingPeriodActivityGenericDocRenderer implements GenericDocRendererInterface
{
public function __construct(private StoredObjectRepository $objectRepository, private ActivityRepository $activityRepository) {}
@@ -29,7 +32,8 @@ final readonly class AccompanyingPeriodActivityGenericDocRenderer implements Gen
public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string
{
return '@ChillActivity/GenericDoc/activity_document.html.twig';
return ($options['row-only'] ?? false) ? '@ChillActivity/GenericDoc/activity_document_row.html.twig' :
'@ChillActivity/GenericDoc/activity_document.html.twig';
}
public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array
@@ -38,6 +42,7 @@ final readonly class AccompanyingPeriodActivityGenericDocRenderer implements Gen
'activity' => $this->activityRepository->find($genericDocDTO->identifiers['activity_id']),
'document' => $this->objectRepository->find($genericDocDTO->identifiers['id']),
'context' => $genericDocDTO->getContext(),
'show_actions' => $options['show-actions'] ?? true,
];
}
}

View File

@@ -14,3 +14,5 @@ export:
describe_action_with_subject: >-
Filtré par personne ayant eu un échange entre le {date_from, date} et le {date_to, date}, et un de ces sujets choisis: {reasons}
activity:
title: Échange du {date, date, long} - {type}

View File

@@ -101,6 +101,7 @@ activity:
Insert a document: Insérer un document
Remove a document: Supprimer le document
comment: Commentaire
deleted: Échange supprimé
No documents: Aucun document
# activity filter in list page

View File

@@ -21,9 +21,7 @@ namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Service\ShortMessageNotification\ShortMessageForCalendarBuilderInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\MainBundle\Service\ShortMessage\ShortMessageTransporterInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use libphonenumber\PhoneNumber;
@@ -36,6 +34,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Notifier\TexterInterface;
class SendTestShortMessageOnCalendarCommand extends Command
{
@@ -44,9 +43,8 @@ class SendTestShortMessageOnCalendarCommand extends Command
public function __construct(
private readonly PersonRepository $personRepository,
private readonly PhoneNumberUtil $phoneNumberUtil,
private readonly PhoneNumberHelperInterface $phoneNumberHelper,
private readonly ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder,
private readonly ShortMessageTransporterInterface $transporter,
private readonly TexterInterface $transporter,
private readonly UserRepositoryInterface $userRepository,
) {
parent::__construct('chill:calendar:test-send-short-message');
@@ -152,10 +150,6 @@ class SendTestShortMessageOnCalendarCommand extends Command
return $phone;
});
$phone = $helper->ask($input, $output, $question);
$question = new ConfirmationQuestion('really send the message to the phone ?');
$reallySend = (bool) $helper->ask($input, $output, $question);
$messages = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar);
@@ -165,8 +159,12 @@ class SendTestShortMessageOnCalendarCommand extends Command
foreach ($messages as $key => $message) {
$output->writeln("The short message for SMS {$key} will be: ");
$output->writeln($message->getContent());
$message->setPhoneNumber($phone);
$output->writeln($message->getSubject());
$output->writeln('The destination number will be:');
$output->writeln($message->getPhone());
$question = new ConfirmationQuestion('really send the message to the phone ?');
$reallySend = (bool) $helper->ask($input, $output, $question);
if ($reallySend) {
$this->transporter->send($message);

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\CalendarDoc;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
@@ -49,4 +50,21 @@ class CalendarDocRepository implements ObjectRepository, CalendarDocRepositoryIn
{
return CalendarDoc::class;
}
/**
* @param StoredObject|int $storedObject the StoredObject instance, or the id of the stored object
*/
public function findOneByStoredObject(StoredObject|int $storedObject): ?CalendarDoc
{
$storedObjectId = $storedObject instanceof StoredObject ? $storedObject->getId() : $storedObject;
$qb = $this->repository->createQueryBuilder('c');
$qb->where(
$qb->expr()->eq(':storedObject', 'c.storedObject')
);
$qb->setParameter('storedObject', $storedObjectId);
return $qb->getQuery()->getOneOrNullResult();
}
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\CalendarDoc;
use Chill\DocStoreBundle\Entity\StoredObject;
interface CalendarDocRepositoryInterface
{
@@ -29,5 +30,7 @@ interface CalendarDocRepositoryInterface
public function findOneBy(array $criteria): ?CalendarDoc;
public function findOneByStoredObject(StoredObject|int $storedObject): ?CalendarDoc;
public function getClassName();
}

View File

@@ -106,7 +106,10 @@ export default {
});
state.key = state.key + toAdd.length;
},
addExternals(state, externalEvents: (EventInput & { id: string })[]) {
addExternals(
state: CalendarRangesState,
externalEvents: (EventInput & { id: string })[],
) {
const toAdd = externalEvents.filter(
(r) => !state.rangesIndex.has(r.id),
);
@@ -160,7 +163,7 @@ export default {
state.key = state.key + 1;
}
},
updateRange(state, range: CalendarRange) {
updateRange(state: CalendarRangesState, range: CalendarRange) {
const found = state.ranges.find(
(r) => r.calendarRangeId === range.id && r.is === "range",
);
@@ -207,7 +210,7 @@ export default {
});
},
createRange(
ctx,
ctx: Context,
{
start,
end,
@@ -253,10 +256,10 @@ export default {
throw error;
});
},
deleteRange(ctx, calendarRangeId: number) {
deleteRange(ctx: Context, calendarRangeId: number) {
const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`;
makeFetch<undefined, never>("DELETE", url).then((_) => {
makeFetch<undefined, never>("DELETE", url).then(() => {
ctx.commit("removeRange", calendarRangeId);
});
},
@@ -347,10 +350,10 @@ export default {
);
}
return Promise.all(promises).then((_) => Promise.resolve(null));
return Promise.all(promises).then(() => Promise.resolve(null));
},
copyFromWeekToAnotherWeek(
ctx,
ctx: Context,
{ fromMonday, toMonday }: { fromMonday: Date; toMonday: Date },
): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] =
@@ -371,7 +374,7 @@ export default {
);
}
return Promise.all(promises).then((_) => Promise.resolve(null));
return Promise.all(promises).then(() => Promise.resolve(null));
},
},
} as Module<CalendarRangesState, State>;

View File

@@ -5,71 +5,5 @@
{% set c = document.calendar %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.storedObject.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.storedObject.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.storedObject.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div>
{% if c.accompanyingPeriod is not null and context == 'person' %}
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ c.accompanyingPeriod.id }}
</span>&nbsp;
{% endif %}
<span class="badge-calendar">
<span class="title_label"></span>
<span class="title_action">
{{ 'Calendar'|trans }}
{% if c.endDate.diff(c.startDate).days >= 1 %}
{{ c.startDate|format_datetime('short', 'short') }}
- {{ c.endDate|format_datetime('short', 'short') }}
{% else %}
{{ c.startDate|format_datetime('short', 'short') }}
- {{ c.endDate|format_datetime('none', 'short') }}
{% endif %}
</span>
</span>
</div>
<div class="denomination h2">
{{ document.storedObject.title|chill_print_or_message("No title") }}
</div>
{% if document.storedObject.hasTemplate %}
<div>
<p>{{ document.storedObject.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
<div class="dates row text-end">
<span>{{ document.storedObject.createdAt|format_date('short') }}</span>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if is_granted('CHILL_CALENDAR_DOC_SEE', document) %}
<li>
{{ document.storedObject|chill_document_button_group(document.storedObject.title, is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c)) }}
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', {'id': c.id, 'docId': document.id}) }}" class="btn btn-edit"></a>
</li>
{% endif %}
</ul>
</div>
{{ include('@ChillCalendar/GenericDoc/calendar_document_row.html.twig') }}
</div>

View File

@@ -0,0 +1,75 @@
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %}
{% import '@ChillPerson/Macro/updatedBy.html.twig' as mmm %}
{% set c = document.calendar %}
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.storedObject.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.storedObject.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.storedObject.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div>
{% if c.accompanyingPeriod is not null and context == 'person' %}
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ c.accompanyingPeriod.id }}
</span>&nbsp;
{% endif %}
<span class="badge-calendar">
<span class="title_label"></span>
<span class="title_action">
{{ 'Calendar'|trans }}
{% if c.endDate.diff(c.startDate).days >= 1 %}
{{ c.startDate|format_datetime('short', 'short') }}
- {{ c.endDate|format_datetime('short', 'short') }}
{% else %}
{{ c.startDate|format_datetime('short', 'short') }}
- {{ c.endDate|format_datetime('none', 'short') }}
{% endif %}
</span>
</span>
</div>
<div class="denomination h2">
{{ document.storedObject.title|chill_print_or_message("No title") }}
</div>
{% if document.storedObject.hasTemplate %}
<div>
<p>{{ document.storedObject.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
<div class="dates row text-end">
<span>{{ document.storedObject.createdAt|format_date('short') }}</span>
</div>
</div>
</div>
</div>
{% if show_actions %}
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if is_granted('CHILL_CALENDAR_DOC_SEE', document) %}
<li>
{{ document.storedObject|chill_document_button_group(document.storedObject.title, is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c)) }}
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', {'id': c.id, 'docId': document.id}) }}" class="btn btn-edit"></a>
</li>
{% endif %}
</ul>
</div>
{% endif %}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Service\GenericDoc\Normalizer;
use Chill\CalendarBundle\Repository\CalendarDocRepositoryInterface;
use Chill\CalendarBundle\Service\GenericDoc\Providers\AccompanyingPeriodCalendarGenericDocProvider;
use Chill\CalendarBundle\Service\GenericDoc\Renderers\AccompanyingPeriodCalendarGenericDocRenderer;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocNormalizerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
final readonly class AccompanyingPeriodCalendarGenericDocNormalizer implements GenericDocNormalizerInterface
{
public function __construct(
private AccompanyingPeriodCalendarGenericDocRenderer $renderer,
private CalendarDocRepositoryInterface $calendarDocRepository,
private Environment $twig,
private TranslatorInterface $translator,
) {}
public function supportsNormalization(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool
{
return AccompanyingPeriodCalendarGenericDocProvider::KEY === $genericDocDTO->key && 'json' === $format;
}
public function normalize(GenericDocDTO $genericDocDTO, string $format, array $context = []): array
{
if (null === $calendarDoc = $this->calendarDocRepository->find($genericDocDTO->identifiers['id'])) {
return ['title' => $this->translator->trans('generic_doc.document removed'), 'isPresent' => false];
}
return [
'isPresent' => true,
'title' => $calendarDoc->getStoredObject()->getTitle(),
'html' => $this->twig->render(
$this->renderer->getTemplate($genericDocDTO, ['show-actions' => false, 'row-only' => true]),
$this->renderer->getTemplateData($genericDocDTO, ['show-actions' => false, 'row-only' => true])
),
];
}
}

View File

@@ -13,10 +13,12 @@ namespace Chill\CalendarBundle\Service\GenericDoc\Providers;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarDoc;
use Chill\CalendarBundle\Repository\CalendarDocRepositoryInterface;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
@@ -38,8 +40,38 @@ final readonly class AccompanyingPeriodCalendarGenericDocProvider implements Gen
public function __construct(
private Security $security,
private EntityManagerInterface $em,
private CalendarDocRepositoryInterface $calendarRepository,
) {}
public function fetchAssociatedStoredObject(GenericDocDTO $genericDocDTO): ?StoredObject
{
return $this->calendarRepository->find($genericDocDTO->identifiers['id'])?->getStoredObject();
}
public function supportsGenericDoc(GenericDocDTO $genericDocDTO): bool
{
return $this->supportsKeyAndIdentifiers($genericDocDTO->key, $genericDocDTO->identifiers);
}
public function supportsKeyAndIdentifiers(string $key, array $identifiers): bool
{
return self::KEY === $key && array_key_exists('id', $identifiers);
}
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO
{
if (null === $calendarDoc = $this->calendarRepository->find($identifiers['id'])) {
return null;
}
return new GenericDocDTO(
self::KEY,
$identifiers,
\DateTimeImmutable::createFromInterface($calendarDoc->getCreatedAt() ?? new \DateTimeImmutable('now')),
$calendarDoc->getCalendar()->getAccompanyingPeriod() ?? $calendarDoc->getCalendar()->getPerson()
);
}
/**
* @throws MappingException
*/
@@ -82,7 +114,7 @@ final readonly class AccompanyingPeriodCalendarGenericDocProvider implements Gen
[Types::INTEGER]
);
return $query;
return $this->addWhereClausesToQuery($query, $startDate, $endDate, $content);
}
public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool

View File

@@ -17,6 +17,9 @@ use Chill\CalendarBundle\Service\GenericDoc\Providers\PersonCalendarGenericDocPr
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface;
/**
* @implements GenericDocRendererInterface<array{row-only?: bool, show-actions?: bool}>
*/
final readonly class AccompanyingPeriodCalendarGenericDocRenderer implements GenericDocRendererInterface
{
public function __construct(private CalendarDocRepository $repository) {}
@@ -28,7 +31,8 @@ final readonly class AccompanyingPeriodCalendarGenericDocRenderer implements Gen
public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string
{
return '@ChillCalendar/GenericDoc/calendar_document.html.twig';
return $options['row-only'] ?? false ? '@ChillCalendar/GenericDoc/calendar_document_row.html.twig'
: '@ChillCalendar/GenericDoc/calendar_document.html.twig';
}
public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array
@@ -36,6 +40,7 @@ final readonly class AccompanyingPeriodCalendarGenericDocRenderer implements Gen
return [
'document' => $this->repository->find($genericDocDTO->identifiers['id']),
'context' => $genericDocDTO->getContext(),
'show_actions' => $options['show-actions'] ?? true,
];
}
}

View File

@@ -21,11 +21,17 @@ namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Notifier\TexterInterface;
class BulkCalendarShortMessageSender
{
public function __construct(private readonly CalendarForShortMessageProvider $provider, private readonly EntityManagerInterface $em, private readonly LoggerInterface $logger, private readonly MessageBusInterface $messageBus, private readonly ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder) {}
public function __construct(
private readonly CalendarForShortMessageProvider $provider,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly TexterInterface $texter,
private readonly ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder,
) {}
public function sendBulkMessageToEligibleCalendars()
{
@@ -36,7 +42,7 @@ class BulkCalendarShortMessageSender
$smses = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar);
foreach ($smses as $sms) {
$this->messageBus->dispatch($sms);
$this->texter->send($sms);
++$countSms;
}

View File

@@ -19,12 +19,26 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Notifier\Message\SmsMessage;
class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBuilderInterface
{
public function __construct(private readonly \Twig\Environment $engine) {}
private readonly PhoneNumberUtil $phoneUtil;
public function __construct(private readonly \Twig\Environment $engine)
{
$this->phoneUtil = PhoneNumberUtil::getInstance();
}
/**
* @return list<SmsMessage>
*
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function buildMessageForCalendar(Calendar $calendar): array
{
if (true !== $calendar->getSendSMS()) {
@@ -39,16 +53,14 @@ class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBu
}
if (Calendar::SMS_PENDING === $calendar->getSmsStatus()) {
$toUsers[] = new ShortMessage(
$toUsers[] = new SmsMessage(
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]),
$person->getMobilenumber(),
ShortMessage::PRIORITY_LOW
);
} elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) {
$toUsers[] = new ShortMessage(
$toUsers[] = new SmsMessage(
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]),
$person->getMobilenumber(),
ShortMessage::PRIORITY_LOW
);
}
}

View File

@@ -19,12 +19,12 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use Symfony\Component\Notifier\Message\SmsMessage;
interface ShortMessageForCalendarBuilderInterface
{
/**
* @return array|ShortMessage[]
* @return list<SmsMessage>
*/
public function buildMessageForCalendar(Calendar $calendar): array;
}

View File

@@ -23,17 +23,16 @@ use Chill\CalendarBundle\Service\ShortMessageNotification\BulkCalendarShortMessa
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
use Chill\CalendarBundle\Service\ShortMessageNotification\ShortMessageForCalendarBuilderInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use Chill\MainBundle\Test\PrepareUserTrait;
use Chill\PersonBundle\DataFixtures\Helper\PersonRandomHelper;
use Doctrine\ORM\EntityManagerInterface;
use libphonenumber\PhoneNumberUtil;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Notifier\Message\SentMessage;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\TexterInterface;
/**
* @internal
@@ -101,24 +100,23 @@ final class BulkCalendarShortMessageSenderTest extends KernelTestCase
$messageBuilder->buildMessageForCalendar(Argument::type(Calendar::class))
->willReturn(
[
new ShortMessage(
new SmsMessage(
'+32470123456',
'content',
PhoneNumberUtil::getInstance()->parse('+32470123456', 'BE'),
ShortMessage::PRIORITY_MEDIUM
),
]
);
$bus = $this->prophesize(MessageBusInterface::class);
$bus->dispatch(Argument::type(ShortMessage::class))
->willReturn(new Envelope(new \stdClass()))
$texter = $this->prophesize(TexterInterface::class);
$texter->send(Argument::type(SmsMessage::class))
->will(fn ($args): SentMessage => new SentMessage($args[0], 'sms'))
->shouldBeCalledTimes(1);
$bulk = new BulkCalendarShortMessageSender(
$provider->reveal(),
$em,
new NullLogger(),
$bus->reveal(),
$texter->reveal(),
$messageBuilder->reveal()
);

View File

@@ -23,7 +23,6 @@ use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultShortMessageFor
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -90,10 +89,9 @@ final class DefaultShortMessageForCalendarBuilderTest extends TestCase
$this->assertCount(1, $sms);
$this->assertEquals(
'+32470123456',
$this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164)
$sms[0]->getPhone()
);
$this->assertEquals('message content', $sms[0]->getContent());
$this->assertEquals('low', $sms[0]->getPriority());
$this->assertEquals('message content', $sms[0]->getSubject());
// if the calendar is canceled
$calendar
@@ -105,9 +103,8 @@ final class DefaultShortMessageForCalendarBuilderTest extends TestCase
$this->assertCount(1, $sms);
$this->assertEquals(
'+32470123456',
$this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164)
$sms[0]->getRecipientId(),
);
$this->assertEquals('message canceled', $sms[0]->getContent());
$this->assertEquals('low', $sms[0]->getPriority());
$this->assertEquals('message canceled', $sms[0]->getSubject());
}
}

View File

@@ -298,7 +298,7 @@ class CustomFieldsGroupController extends AbstractController
->setCustomFieldsGroup($customFieldsGroup);
$builder = $this->get('form.factory')
->createNamedBuilder(null, FormType::class, $customfield, [
->createNamedBuilder('', FormType::class, $customfield, [
'method' => 'GET',
'action' => $this->generateUrl('customfield_new'),
'csrf_protection' => false,

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocGeneratorBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\GenericDocForAccompanyingPeriodListApiController;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\ManagerInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Pagination\Paginator;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
class GenericDocForAccompanyingPeriodListApiControllerTest extends TestCase
{
public function testSmokeTest(): void
{
$accompanyingPeriod = new AccompanyingPeriod();
$docs = [
new GenericDocDTO('dummy', ['id' => 9], new \DateTimeImmutable('2024-08-01'), $accompanyingPeriod),
new GenericDocDTO('dummy', ['id' => 1], new \DateTimeImmutable('2024-09-01'), $accompanyingPeriod),
];
$manager = $this->createMock(ManagerInterface::class);
$manager->method('findDocForAccompanyingPeriod')->with($accompanyingPeriod)->willReturn($docs);
$manager->method('countDocForAccompanyingPeriod')->with($accompanyingPeriod)->willReturn(2);
$paginatorFactory = $this->createMock(PaginatorFactoryInterface::class);
$paginatorFactory->method('create')->with(2)->willReturn(new Paginator(
2,
20,
1,
'/route',
[],
$this->createMock(UrlGeneratorInterface::class),
'page',
'item-per-page'
));
$serializer = $this->createMock(SerializerInterface::class);
$serializer->method('serialize')->with($this->isInstanceOf(Collection::class))->willReturn(
json_encode(['docs' => []])
);
$security = $this->createMock(Security::class);
$security->expects($this->once())->method('isGranted')
->with(AccompanyingCourseDocumentVoter::SEE, $accompanyingPeriod)->willReturn(true);
$controller = new GenericDocForAccompanyingPeriodListApiController(
$manager,
$security,
$paginatorFactory,
$serializer,
);
$response = $controller($accompanyingPeriod);
$this->assertInstanceOf(JsonResponse::class, $response);
$this->assertEquals('{"docs":[]}', $response->getContent());
}
}

View File

@@ -0,0 +1,227 @@
<?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\AsyncUpload\Driver\LocalStorage;
use Base64Url\Base64Url;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
class StoredObjectManager implements StoredObjectManagerInterface
{
private readonly string $baseDir;
private readonly Filesystem $filesystem;
public function __construct(
ParameterBagInterface $parameterBag,
private readonly KeyGenerator $keyGenerator,
) {
$this->baseDir = $parameterBag->get('chill_doc_store')['local_storage']['storage_path'];
$this->filesystem = new Filesystem();
}
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
{
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (null === $version) {
throw StoredObjectManagerException::storedObjectDoesNotContainsVersion();
}
$path = $this->buildPath($version->getFilename());
if (false === $ts = filemtime($path)) {
throw StoredObjectManagerException::unableToReadDocumentOnDisk($path);
}
return \DateTimeImmutable::createFromFormat('U', (string) $ts);
}
public function getContentLength(StoredObject|StoredObjectVersion $document): int
{
return strlen($this->read($document));
}
public function exists(StoredObject|StoredObjectVersion $document): bool
{
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (null === $version) {
return false;
}
return $this->existsContent($version->getFilename());
}
public function read(StoredObject|StoredObjectVersion $document): string
{
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (null === $version) {
throw StoredObjectManagerException::storedObjectDoesNotContainsVersion();
}
$content = $this->readContent($version->getFilename());
if (!$this->isVersionEncrypted($version)) {
return $content;
}
$clearData = openssl_decrypt(
$content,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA,
pack('C*', ...$version->getIv())
);
if (false === $clearData) {
throw StoredObjectManagerException::unableToDecrypt(openssl_error_string());
}
return $clearData;
}
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
{
$newIv = $document->isEncrypted() ? $document->getIv() : $this->keyGenerator->generateIv();
$newKey = $document->isEncrypted() ? $document->getKeyInfos() : $this->keyGenerator->generateKey(self::ALGORITHM);
$newType = $contentType ?? $document->getType();
$version = $document->registerVersion(
$newIv,
$newKey,
$newType
);
$encryptedContent = $this->isVersionEncrypted($version)
? openssl_encrypt(
$clearContent,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA,
pack('C*', ...$version->getIv())
)
: $clearContent;
if (false === $encryptedContent) {
throw StoredObjectManagerException::unableToEncryptDocument((string) openssl_error_string());
}
$this->writeContent($version->getFilename(), $encryptedContent);
return $version;
}
public function readContent(string $filename): string
{
$path = $this->buildPath($filename);
if (!file_exists($path)) {
throw StoredObjectManagerException::unableToFindDocumentOnDisk($path);
}
if (false === $content = file_get_contents($path)) {
throw StoredObjectManagerException::unableToReadDocumentOnDisk($path);
}
return $content;
}
public function writeContent(string $filename, string $encryptedContent): void
{
$fullPath = $this->buildPath($filename);
$dir = Path::getDirectory($fullPath);
if (!$this->filesystem->exists($dir)) {
$this->filesystem->mkdir($dir);
}
$result = file_put_contents($fullPath, $encryptedContent);
if (false === $result) {
throw StoredObjectManagerException::unableToStoreDocumentOnDisk();
}
}
public function existsContent(string $filename): bool
{
$path = $this->buildPath($filename);
return $this->filesystem->exists($path);
}
private function buildPath(string $filename): string
{
$dirs = [$this->baseDir];
for ($i = 0; $i < min(strlen($filename), 8); ++$i) {
$dirs[] = $filename[$i];
}
$dirs[] = $filename;
return Path::canonicalize(implode(DIRECTORY_SEPARATOR, $dirs));
}
public function delete(StoredObjectVersion $storedObjectVersion): void
{
if (!$this->exists($storedObjectVersion)) {
return;
}
$path = $this->buildPath($storedObjectVersion->getFilename());
$this->filesystem->remove($path);
$this->removeDirectoriesRecursively(Path::getDirectory($path));
}
private function removeDirectoriesRecursively(string $path): void
{
if ($path === $this->baseDir) {
return;
}
$files = scandir($path);
// if it does contains only "." and "..", we can remove the directory
if (2 === count($files) && in_array('.', $files, true) && in_array('..', $files, true)) {
$this->filesystem->remove($path);
$this->removeDirectoriesRecursively(Path::getDirectory($path));
}
}
/**
* @throws StoredObjectManagerException
*/
public function etag(StoredObject|StoredObjectVersion $document): string
{
return md5($this->read($document));
}
public function clearCache(): void
{
// there is no cache: nothing to do here !
}
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
{
return $storedObjectVersion->isEncrypted();
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TempUrlLocalStorageGenerator implements TempUrlGeneratorInterface
{
private const SIGNATURE_DURATION = 180;
public function __construct(
private readonly string $secret,
private readonly ClockInterface $clock,
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl
{
$expiration = $this->clock->now()->getTimestamp() + min($expire_delay ?? self::SIGNATURE_DURATION, self::SIGNATURE_DURATION);
return new SignedUrl(
strtoupper($method),
$this->urlGenerator->generate('chill_docstore_stored_object_operate', [
'object_name' => $object_name,
'exp' => $expiration,
'sig' => $this->sign(strtoupper($method), $object_name, $expiration),
], UrlGeneratorInterface::ABSOLUTE_URL),
\DateTimeImmutable::createFromFormat('U', (string) $expiration),
$object_name,
);
}
public function generatePost(?int $expire_delay = null, ?int $submit_delay = null, int $max_file_count = 1, ?string $object_name = null): SignedUrlPost
{
$submitDelayComputed = min($submit_delay ?? self::SIGNATURE_DURATION, self::SIGNATURE_DURATION);
$expireDelayComputed = min($expire_delay ?? self::SIGNATURE_DURATION, self::SIGNATURE_DURATION);
$objectNameComputed = $object_name ?? StoredObject::generatePrefix();
$expiration = $this->clock->now()->getTimestamp() + $expireDelayComputed + $submitDelayComputed;
return new SignedUrlPost(
$this->urlGenerator->generate(
'chill_docstore_storedobject_post',
['prefix' => $objectNameComputed],
UrlGeneratorInterface::ABSOLUTE_URL
),
\DateTimeImmutable::createFromFormat('U', (string) $expiration),
$objectNameComputed,
15_000_000,
1,
$submitDelayComputed,
'',
$objectNameComputed,
$this->sign('POST', $object_name, $expiration),
);
}
private function sign(string $method, string $object_name, int $expiration): string
{
return hash('sha512', sprintf('%s.%s.%s.%d', $method, $this->secret, $object_name, $expiration));
}
public function validateSignaturePost(string $signature, string $prefix, int $expiration, int $maxFileSize, int $maxFileCount): bool
{
if (15_000_000 !== $maxFileSize || 1 !== $maxFileCount) {
return false;
}
return $this->internalValidateSignature($signature, 'POST', $prefix, $expiration);
}
private function internalValidateSignature(string $signature, string $method, string $object_name, int $expiration): bool
{
if ($expiration < $this->clock->now()->format('U')) {
return false;
}
if ('' === $object_name) {
return false;
}
return $this->sign($method, $object_name, $expiration) === $signature;
}
public function validateSignature(string $signature, string $method, string $objectName, int $expiration): bool
{
if (!in_array($method, ['GET', 'HEAD'], true)) {
return false;
}
return $this->internalValidateSignature($signature, $method, $objectName, $expiration);
}
}

View File

@@ -9,7 +9,7 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\AsyncUpload\Command;
namespace Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;

View File

@@ -9,13 +9,14 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
namespace Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore;
use Base64Url\Base64Url;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
@@ -24,8 +25,6 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
final class StoredObjectManager implements StoredObjectManagerInterface
{
private const ALGORITHM = 'AES-256-CBC';
private array $inMemory = [];
public function __construct(
@@ -361,6 +360,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
{
return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv());
return $storedObjectVersion->isEncrypted();
}
}

View File

@@ -11,8 +11,10 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle;
use Chill\DocStoreBundle\DependencyInjection\Compiler\StorageConfigurationCompilerPass;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocNormalizerInterface;
use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
@@ -27,5 +29,9 @@ class ChillDocStoreBundle extends Bundle
->addTag('chill_doc_store.generic_doc_person_provider');
$container->registerForAutoconfiguration(GenericDocRendererInterface::class)
->addTag('chill_doc_store.generic_doc_renderer');
$container->registerForAutoconfiguration(GenericDocNormalizerInterface::class)
->addTag('chill_doc_store.generic_doc_metadata_normalizer');
$container->addCompilerPass(new StorageConfigurationCompilerPass());
}
}

View File

@@ -92,13 +92,14 @@ class DocumentCategoryController extends AbstractController
$nextId = $em
->createQuery(
'SELECT MAX(c.idInsideBundle) + 1 FROM ChillDocStoreBundle:DocumentCategory c'
'SELECT (CASE WHEN MAX(c.idInsideBundle) IS NULL THEN 1 ELSE MAX(c.idInsideBundle) + 1 END)
FROM ChillDocStoreBundle:DocumentCategory c'
)
->getSingleResult();
->getSingleScalarResult();
$documentCategory = new DocumentCategory(
ChillDocStoreBundle::class,
reset($nextId)
$nextId
);
$documentCategory

View File

@@ -11,7 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\GenericDoc\ManagerInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory;
@@ -25,7 +25,7 @@ final readonly class GenericDocForAccompanyingPeriodController
{
public function __construct(
private FilterOrderHelperFactory $filterOrderHelperFactory,
private Manager $manager,
private ManagerInterface $manager,
private PaginatorFactory $paginator,
private Security $security,
private \Twig\Environment $twig,
@@ -68,6 +68,9 @@ final readonly class GenericDocForAccompanyingPeriodController
);
$paginator = $this->paginator->create($nb);
// restrict the number of items for performance reasons
$paginator->setItemsPerPage(20);
$documents = $this->manager->findDocForAccompanyingPeriod(
$accompanyingPeriod,
$paginator->getCurrentPageFirstItemNumber(),

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\GenericDoc\ManagerInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
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;
/**
* Provide the list of GenericDoc for an accompanying period.
*/
final readonly class GenericDocForAccompanyingPeriodListApiController
{
public function __construct(
private ManagerInterface $manager,
private Security $security,
private PaginatorFactoryInterface $paginator,
private SerializerInterface $serializer,
) {}
#[Route('/api/1.0/doc-store/generic-doc/by-period/{id}/index', methods: ['GET'])]
public function __invoke(AccompanyingPeriod $accompanyingPeriod): JsonResponse
{
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $accompanyingPeriod)) {
throw new AccessDeniedHttpException('not allowed to see the documents for accompanying period');
}
$nb = $this->manager->countDocForAccompanyingPeriod($accompanyingPeriod);
$paginator = $this->paginator->create($nb);
$docs = $this->manager->findDocForAccompanyingPeriod($accompanyingPeriod, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
$collection = new Collection($docs, $paginator);
return new JsonResponse(
$this->serializer->serialize($collection, 'json', [AbstractNormalizer::GROUPS => ['read']]),
json: true,
);
}
}

View File

@@ -11,7 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\GenericDoc\ManagerInterface;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory;
@@ -25,7 +25,7 @@ final readonly class GenericDocForPerson
{
public function __construct(
private FilterOrderHelperFactory $filterOrderHelperFactory,
private Manager $manager,
private ManagerInterface $manager,
private PaginatorFactory $paginator,
private Security $security,
private \Twig\Environment $twig,

View File

@@ -0,0 +1,120 @@
<?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\AsyncUpload\Driver\LocalStorage\StoredObjectManager;
use Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\TempUrlLocalStorageGenerator;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller to deal with local storage operation.
*/
final readonly class StoredObjectContentToLocalStorageController
{
public function __construct(
private StoredObjectManager $storedObjectManager,
private TempUrlLocalStorageGenerator $tempUrlLocalStorageGenerator,
) {}
#[Route('/public/stored-object/post', name: 'chill_docstore_storedobject_post', methods: ['POST'])]
public function postContent(Request $request): Response
{
$prefix = $request->query->get('prefix', '');
if ('' === $prefix) {
throw new BadRequestHttpException('Prefix parameter is missing');
}
if (0 === $maxFileSize = $request->request->getInt('max_file_size', 0)) {
throw new BadRequestHttpException('Max file size is not set or equal to zero');
}
if (1 !== $maxFileCount = $request->request->getInt('max_file_count', 0)) {
throw new BadRequestHttpException('Max file count is not set or equal to zero');
}
if (0 === $expiration = $request->request->getInt('expires', 0)) {
throw new BadRequestHttpException('Expiration is not set or equal to zero');
}
if ('' === $signature = $request->request->get('signature', '')) {
throw new BadRequestHttpException('Signature is not set or is a blank string');
}
if (!$this->tempUrlLocalStorageGenerator->validateSignaturePost($signature, $prefix, $expiration, $maxFileSize, $maxFileCount)) {
throw new AccessDeniedHttpException('Invalid signature');
}
$keyFiles = $request->files->keys();
if ($maxFileCount < count($keyFiles)) {
throw new AccessDeniedHttpException('More files than max file count');
}
if (0 === count($keyFiles)) {
throw new BadRequestHttpException('Zero files given');
}
foreach ($keyFiles as $keyFile) {
/** @var UploadedFile $file */
$file = $request->files->get($keyFile);
if ($maxFileSize < strlen($file->getContent())) {
throw new AccessDeniedHttpException('File is too big');
}
if (!str_starts_with((string) $keyFile, $prefix)) {
throw new AccessDeniedHttpException('Filename does not start with signed prefix');
}
$this->storedObjectManager->writeContent($keyFile, $file->getContent());
}
return new Response(status: Response::HTTP_NO_CONTENT);
}
#[Route('/public/stored-object/operate', name: 'chill_docstore_stored_object_operate', methods: ['GET', 'HEAD'])]
public function contentOperate(Request $request): Response
{
if ('' === $objectName = $request->query->get('object_name', '')) {
throw new BadRequestHttpException('Object name parameter is missing');
}
if (0 === $expiration = $request->query->getInt('exp', 0)) {
throw new BadRequestHttpException('Expiration is not set or equal to zero');
}
if ('' === $signature = $request->query->get('sig', '')) {
throw new BadRequestHttpException('Signature is not set or is a blank string');
}
if (!$this->tempUrlLocalStorageGenerator->validateSignature($signature, strtoupper($request->getMethod()), $objectName, $expiration)) {
throw new AccessDeniedHttpException('Invalid signature');
}
if (!$this->storedObjectManager->existsContent($objectName)) {
throw new NotFoundHttpException('Object does not exists on disk');
}
return match ($request->getMethod()) {
'GET' => new Response($this->storedObjectManager->readContent($objectName)),
'HEAD' => new Response(''),
default => throw new BadRequestHttpException('method not supported'),
};
}
}

View File

@@ -53,7 +53,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$this->prependTwig($container);
}
protected function prependAuthorization(ContainerBuilder $container)
private function prependAuthorization(ContainerBuilder $container)
{
$container->prependExtensionConfig('security', [
'role_hierarchy' => [
@@ -69,7 +69,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
]);
}
protected function prependRoute(ContainerBuilder $container)
private function prependRoute(ContainerBuilder $container)
{
// declare routes for task bundle
$container->prependExtensionConfig('chill_main', [
@@ -81,7 +81,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
]);
}
protected function prependTwig(ContainerBuilder $container)
private function prependTwig(ContainerBuilder $container)
{
$twigConfig = [
'form_themes' => ['@ChillDocStore/Form/fields.html.twig'],

View File

@@ -0,0 +1,86 @@
<?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\DependencyInjection\Compiler;
use Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\TempUrlLocalStorageGenerator;
use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\ConfigureOpenstackObjectStorageCommand;
use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\TempUrlOpenstackGenerator;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Controller\StoredObjectContentToLocalStorageController;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class StorageConfigurationCompilerPass implements CompilerPassInterface
{
private const SERVICES_OPENSTACK = [
\Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\StoredObjectManager::class,
TempUrlOpenstackGenerator::class,
ConfigureOpenstackObjectStorageCommand::class,
];
private const SERVICES_LOCAL_STORAGE = [
\Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\StoredObjectManager::class,
TempUrlLocalStorageGenerator::class,
StoredObjectContentToLocalStorageController::class,
];
public function process(ContainerBuilder $container)
{
$config = $container
->getParameterBag()
->resolveValue($container->getParameter('chill_doc_store'));
if (array_key_exists('local_storage', $config) && !array_key_exists('openstack', $config)) {
$driver = 'local_storage';
$this->checkUseDriverConfiguration($config['use_driver'] ?? null, $driver);
} elseif (!array_key_exists('local_storage', $config) && array_key_exists('openstack', $config)) {
$driver = 'openstack';
$this->checkUseDriverConfiguration($config['use_driver'] ?? null, $driver);
} elseif (array_key_exists('openstack', $config) && array_key_exists('local_storage', $config)) {
$driver = $config['use_driver'] ?? null;
if (null === $driver) {
throw new InvalidConfigurationException('There are multiple drivers configured for chill_doc_store, set the one you want to use with the variable use_driver');
}
} else {
throw new InvalidConfigurationException('No driver defined for storing document. Define one in chill_doc_store configuration');
}
if ('local_storage' === $driver) {
foreach (self::SERVICES_OPENSTACK as $service) {
$container->removeDefinition($service);
}
$container->setAlias(StoredObjectManagerInterface::class, \Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\StoredObjectManager::class);
$container->setAlias(TempUrlGeneratorInterface::class, TempUrlLocalStorageGenerator::class);
} else {
foreach (self::SERVICES_LOCAL_STORAGE as $service) {
$container->removeDefinition($service);
}
$container->setAlias(StoredObjectManagerInterface::class, \Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\StoredObjectManager::class);
$container->setAlias(TempUrlGeneratorInterface::class, TempUrlOpenstackGenerator::class);
}
}
private function checkUseDriverConfiguration(?string $useDriver, string $driver): void
{
if (null === $useDriver) {
return;
}
if ($useDriver !== $driver) {
throw new InvalidConfigurationException(sprintf('The "use_driver" configuration require a driver (%s) which is not configured. Configure this driver in order to use it.', $useDriver));
}
}
}

View File

@@ -30,10 +30,22 @@ class Configuration implements ConfigurationInterface
/* @phpstan-ignore-next-line As there are inconsistencies in return types, but the code works... */
$rootNode->children()
->enumNode('use_driver')
->values(['local_storage', 'openstack'])
->info('Driver to use. Default to the single one if multiple driver are defined. Configuration will raise an error if there are multiple drivers defined, and if this key is not set')
->end()
->arrayNode('local_storage')
->info('where the stored object should be stored')
->children()
->scalarNode('storage_path')
->info('the folder where the stored object should be stored')
->isRequired()->cannotBeEmpty()
->end() // end of storage_path
->end() // end of children
->end() // end of local_storage
// openstack node
->arrayNode('openstack')
->info('parameters to authenticate and generate temp url against the openstack object storage service')
->addDefaultsIfNotSet()
->children()
// openstack.temp_url
->arrayNode('temp_url')

View File

@@ -46,9 +46,10 @@ class Document implements TrackCreationInterface, TrackUpdateInterface
#[ORM\ManyToOne(targetEntity: DocGeneratorTemplate::class)]
private ?DocGeneratorTemplate $template = null;
#[Assert\Length(min: 2, max: 250)]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
private string $title = '';
/**
* Store the title of the document, if the title is set before the document.
*/
private string $proxyTitle = '';
#[ORM\ManyToOne(targetEntity: \Chill\MainBundle\Entity\User::class)]
private ?\Chill\MainBundle\Entity\User $user = null;
@@ -78,9 +79,10 @@ class Document implements TrackCreationInterface, TrackUpdateInterface
return $this->template;
}
#[Assert\Length(min: 2, max: 250)]
public function getTitle(): string
{
return $this->title;
return (string) $this->getObject()?->getTitle();
}
public function getUser()
@@ -113,6 +115,10 @@ class Document implements TrackCreationInterface, TrackUpdateInterface
{
$this->object = $object;
if ('' !== $this->proxyTitle) {
$this->object->setTitle($this->proxyTitle);
}
return $this;
}
@@ -125,7 +131,11 @@ class Document implements TrackCreationInterface, TrackUpdateInterface
public function setTitle(string $title): self
{
$this->title = $title;
if (null !== $this->getObject()) {
$this->getObject()->setTitle($title);
} else {
$this->proxyTitle = $title;
}
return $this;
}

View File

@@ -448,4 +448,12 @@ class StoredObject implements Document, TrackCreationInterface
{
return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty();
}
/**
* Return true if it has a current version, and if the current version is encrypted.
*/
public function isEncrypted(): bool
{
return $this->hasCurrentVersion() && $this->getCurrentVersion()->isEncrypted();
}
}

View File

@@ -226,4 +226,9 @@ class StoredObjectVersion implements TrackCreationInterface
return $this;
}
public function isEncrypted(): bool
{
return ([] !== $this->getKeyInfos()) && ([] !== $this->getIv());
}
}

View File

@@ -34,4 +34,29 @@ final class StoredObjectManagerException extends \Exception
{
return new self('Unable to get content from response.', 500, $exception);
}
public static function unableToStoreDocumentOnDisk(?\Throwable $exception = null): self
{
return new self('Unable to store document on disk.', previous: $exception);
}
public static function unableToFindDocumentOnDisk(string $path): self
{
return new self('Unable to find document on disk at path "'.$path.'".');
}
public static function unableToReadDocumentOnDisk(string $path): self
{
return new self('Unable to read document on disk at path "'.$path.'".');
}
public static function unableToEncryptDocument(string $errors): self
{
return new self('Unable to encrypt document: '.$errors);
}
public static function storedObjectDoesNotContainsVersion(): self
{
return new self('Stored object does not contains any version');
}
}

View File

@@ -0,0 +1,20 @@
<?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\GenericDoc\Exception;
class AssociatedStoredObjectNotFound extends \RuntimeException
{
public function __construct(string $key, array $identifiers, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct(sprintf('No stored object found for generic doc with key "%s" and identifiers "%s"', $key, json_encode($identifiers)), $code, $previous);
}
}

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ namespace Chill\DocStoreBundle\GenericDoc;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
interface GenericDocForAccompanyingPeriodProviderInterface
interface GenericDocForAccompanyingPeriodProviderInterface extends GenericDocProviderInterface
{
public function buildFetchQueryForAccompanyingPeriod(
AccompanyingPeriod $accompanyingPeriod,

View File

@@ -0,0 +1,30 @@
<?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\GenericDoc;
/**
* Normalize a Generic Doc.
*/
interface GenericDocNormalizerInterface
{
/**
* Return true if a generic doc can be normalized by this implementation.
*/
public function supportsNormalization(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool;
/**
* Normalize a generic doc into an array.
*
* @return array{title: string, html?: string, isPresent: bool}
*/
public function normalize(GenericDocDTO $genericDocDTO, string $format, array $context = []): array;
}

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\GenericDoc;
use Chill\DocStoreBundle\Entity\StoredObject;
interface GenericDocProviderInterface
{
public function fetchAssociatedStoredObject(GenericDocDTO $genericDocDTO): ?StoredObject;
/**
* Return true if this provider supports the given Generic doc for various informations.
*
* Concerned:
*
* - @see{self::fetchAssociatedStoredObject}
*/
public function supportsGenericDoc(GenericDocDTO $genericDocDTO): bool;
/**
* return true if the implementation supports key and identifiers.
*/
public function supportsKeyAndIdentifiers(string $key, array $identifiers): bool;
/**
* Build a GenericDocDTO, given the key and identifiers.
*/
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO;
}

View File

@@ -11,13 +11,16 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\GenericDoc;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\Exception\AssociatedStoredObjectNotFound;
use Chill\DocStoreBundle\GenericDoc\Exception\NotNormalizableGenericDocException;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Types\Types;
final readonly class Manager
final readonly class Manager implements ManagerInterface
{
private FetchQueryToSqlBuilder $builder;
@@ -31,16 +34,16 @@ final readonly class Manager
* @var iterable<GenericDocForPersonProviderInterface>
*/
private iterable $providersForPerson,
/**
* @var iterable<GenericDocNormalizerInterface>
*/
private iterable $genericDocNormalizers,
private Connection $connection,
) {
$this->builder = new FetchQueryToSqlBuilder();
}
/**
* @param list<string> $places
*
* @throws Exception
*/
public function countDocForAccompanyingPeriod(
AccompanyingPeriod $accompanyingPeriod,
?\DateTimeImmutable $startDate = null,
@@ -83,13 +86,6 @@ final readonly class Manager
return $this->countDoc($sql, $params, $types);
}
/**
* @param list<string> $places places to search. When empty, search in all places
*
* @return iterable<GenericDocDTO>
*
* @throws Exception
*/
public function findDocForAccompanyingPeriod(
AccompanyingPeriod $accompanyingPeriod,
int $offset = 0,
@@ -129,10 +125,35 @@ final readonly class Manager
}
/**
* @param list<string> $places places to search. When empty, search in all places
* Fetch a generic doc, if it does exists.
*
* @return iterable<GenericDocDTO>
* Currently implemented only on generic docs linked with accompanying period
*/
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO
{
foreach ($this->providersForAccompanyingPeriod as $provider) {
if ($provider->supportsKeyAndIdentifiers($key, $identifiers)) {
return $provider->buildOneGenericDoc($key, $identifiers);
}
}
return null;
}
/**
* @throws AssociatedStoredObjectNotFound if no stored object can be found
*/
public function fetchStoredObject(GenericDocDTO $genericDocDTO): StoredObject
{
foreach ($this->providersForAccompanyingPeriod as $provider) {
if ($provider->supportsGenericDoc($genericDocDTO)) {
return $provider->fetchAssociatedStoredObject($genericDocDTO);
}
}
throw new AssociatedStoredObjectNotFound($genericDocDTO->key, $genericDocDTO->identifiers);
}
public function findDocForPerson(
Person $person,
int $offset = 0,
@@ -161,6 +182,28 @@ final readonly class Manager
return $this->places($sql, $params, $types);
}
public function isGenericDocNormalizable(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool
{
foreach ($this->genericDocNormalizers as $genericDocNormalizer) {
if ($genericDocNormalizer->supportsNormalization($genericDocDTO, $format, $context)) {
return true;
}
}
return false;
}
public function normalizeGenericDoc(GenericDocDTO $genericDocDTO, string $format, array $context = []): array
{
foreach ($this->genericDocNormalizers as $genericDocNormalizer) {
if ($genericDocNormalizer->supportsNormalization($genericDocDTO, $format, $context)) {
return $genericDocNormalizer->normalize($genericDocDTO, $format, $context);
}
}
throw new NotNormalizableGenericDocException();
}
private function places(string $sql, array $params, array $types): array
{
if ('' === $sql) {

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\GenericDoc;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\Exception\AssociatedStoredObjectNotFound;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Exception;
interface ManagerInterface
{
/**
* @param list<string> $places
*
* @throws Exception
*/
public function countDocForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, array $places = []): int;
public function countDocForPerson(Person $person, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, array $places = []): int;
/**
* @param list<string> $places places to search. When empty, search in all places
*
* @return iterable<GenericDocDTO>
*
* @throws Exception
*/
public function findDocForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, int $offset = 0, int $limit = 20, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, array $places = []): iterable;
/**
* @param list<string> $places places to search. When empty, search in all places
*
* @return iterable<GenericDocDTO>
*/
public function findDocForPerson(Person $person, int $offset = 0, int $limit = 20, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, array $places = []): iterable;
public function placesForPerson(Person $person): array;
public function placesForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): array;
public function isGenericDocNormalizable(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool;
/**
* @return array{title: string, html?: string}
*/
public function normalizeGenericDoc(GenericDocDTO $genericDocDTO, string $format, array $context = []): array;
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO;
/**
* @throws AssociatedStoredObjectNotFound if no stored object can be found
*/
public function fetchStoredObject(GenericDocDTO $genericDocDTO): StoredObject;
}

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\GenericDoc\Normalizer;
use Chill\DocStoreBundle\GenericDoc\Exception\UnexpectedValueException;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocNormalizerInterface;
use Chill\DocStoreBundle\GenericDoc\Providers\AccompanyingCourseDocumentGenericDocProvider;
use Chill\DocStoreBundle\GenericDoc\Renderer\AccompanyingCourseDocumentGenericDocRenderer;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Twig\Environment;
class AccompanyingCourseDocumentGenericDocNormalizer implements GenericDocNormalizerInterface
{
public function __construct(
private readonly AccompanyingCourseDocumentRepository $repository,
private readonly Environment $twig,
private readonly AccompanyingCourseDocumentGenericDocRenderer $renderer,
) {}
public function supportsNormalization(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool
{
return AccompanyingCourseDocumentGenericDocProvider::KEY === $genericDocDTO->key;
}
public function normalize(GenericDocDTO $genericDocDTO, string $format, array $context = []): array
{
if (!array_key_exists('id', $genericDocDTO->identifiers)) {
throw new UnexpectedValueException('key id not found in identifier');
}
$document = $this->repository->find($genericDocDTO->identifiers['id']);
if (null === $document) {
throw new UnexpectedValueException('document not found with id '.$genericDocDTO->identifiers['id']);
}
return [
'isPresent' => true,
'title' => $document->getTitle(),
'html' => $this->twig->render(
$this->renderer->getTemplate($genericDocDTO, ['show-actions' => false, 'row-only' => true]),
$this->renderer->getTemplateData($genericDocDTO, ['show-actions' => false, 'row-only' => true])
),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc\Normalizer;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocNormalizerInterface;
use Chill\DocStoreBundle\GenericDoc\Providers\PersonDocumentGenericDocProvider;
use Chill\DocStoreBundle\GenericDoc\Renderer\AccompanyingCourseDocumentGenericDocRenderer;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
final readonly class PersonDocumentGenericDocNormalizer implements GenericDocNormalizerInterface
{
public function __construct(
private PersonDocumentRepository $personDocumentRepository,
private AccompanyingCourseDocumentGenericDocRenderer $renderer,
private Environment $twig,
private TranslatorInterface $translator,
) {}
public function supportsNormalization(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool
{
return PersonDocumentGenericDocProvider::KEY === $genericDocDTO->key && 'json' === $format;
}
public function normalize(GenericDocDTO $genericDocDTO, string $format, array $context = []): array
{
if (null === $personDocument = $this->personDocumentRepository->find($genericDocDTO->identifiers['id'])) {
return ['title' => $this->translator->trans('generic_doc.document removed'), 'isPresent' => false];
}
return [
'isPresent' => true,
'title' => $personDocument->getTitle(),
'html' => $this->twig->render(
$this->renderer->getTemplate($genericDocDTO, ['show-actions' => false, 'row-only' => true]),
$this->renderer->getTemplateData($genericDocDTO, ['show-actions' => false, 'row-only' => true])
),
];
}
}

View File

@@ -12,10 +12,13 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\GenericDoc\Providers;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
@@ -31,17 +34,47 @@ final readonly class AccompanyingCourseDocumentGenericDocProvider implements Gen
public function __construct(
private Security $security,
private EntityManagerInterface $entityManager,
private AccompanyingCourseDocumentRepository $accompanyingCourseDocumentRepository,
) {}
public function fetchAssociatedStoredObject(GenericDocDTO $genericDocDTO): ?StoredObject
{
return $this->accompanyingCourseDocumentRepository->find($genericDocDTO->identifiers['id'])?->getObject();
}
public function supportsGenericDoc(GenericDocDTO $genericDocDTO): bool
{
return $this->supportsKeyAndIdentifiers($genericDocDTO->key, $genericDocDTO->identifiers);
}
public function supportsKeyAndIdentifiers(string $key, array $identifiers): bool
{
return self::KEY === $key;
}
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO
{
if (null === $accompanyingCourseDocument = $this->accompanyingCourseDocumentRepository->find($identifiers['id'])) {
return null;
}
return new GenericDocDTO(
self::KEY,
$identifiers,
\DateTimeImmutable::createFromInterface($accompanyingCourseDocument->getDate()),
$accompanyingCourseDocument->getCourse(),
);
}
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
$classMetadata = $this->entityManager->getClassMetadata(AccompanyingCourseDocument::class);
$query = new FetchQuery(
self::KEY,
sprintf('jsonb_build_object(\'id\', %s)', $classMetadata->getIdentifierColumnNames()[0]),
sprintf('jsonb_build_object(\'id\', acc_course_document.%s)', $classMetadata->getIdentifierColumnNames()[0]),
$classMetadata->getColumnName('date'),
$classMetadata->getSchemaName().'.'.$classMetadata->getTableName()
$classMetadata->getSchemaName().'.'.$classMetadata->getTableName().' AS acc_course_document'
);
$query->addWhereClause(
@@ -64,7 +97,7 @@ final readonly class AccompanyingCourseDocumentGenericDocProvider implements Gen
$query = new FetchQuery(
self::KEY,
sprintf('jsonb_build_object(\'id\', %s)', $classMetadata->getIdentifierColumnNames()[0]),
sprintf('jsonb_build_object(\'id\', acc_course_document.%s)', $classMetadata->getIdentifierColumnNames()[0]),
$classMetadata->getColumnName('date'),
$classMetadata->getSchemaName().'.'.$classMetadata->getTableName().' AS acc_course_document'
);
@@ -110,6 +143,7 @@ final readonly class AccompanyingCourseDocumentGenericDocProvider implements Gen
private function addWhereClause(FetchQuery $query, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery
{
$classMetadata = $this->entityManager->getClassMetadata(AccompanyingCourseDocument::class);
$storedObjectMetadata = $this->entityManager->getClassMetadata(StoredObject::class);
if (null !== $startDate) {
$query->addWhereClause(
@@ -128,9 +162,19 @@ final readonly class AccompanyingCourseDocumentGenericDocProvider implements Gen
}
if (null !== $content and '' !== $content) {
// add join clause to stored_object table
$query->addJoinClause(
sprintf(
'JOIN %s AS doc_store ON doc_store.%s = acc_course_document.%s',
$storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName(),
$storedObjectMetadata->getSingleIdentifierColumnName(),
$classMetadata->getSingleAssociationJoinColumnName('object')
)
);
$query->addWhereClause(
sprintf(
'(%s ilike ? OR %s ilike ?)',
'(doc_store.%s ilike ? OR acc_course_document.%s ilike ?)',
$classMetadata->getColumnName('title'),
$classMetadata->getColumnName('description')
),

View File

@@ -11,10 +11,13 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\GenericDoc\Providers;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentACLAwareRepositoryInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
@@ -27,8 +30,38 @@ final readonly class PersonDocumentGenericDocProvider implements GenericDocForPe
public function __construct(
private Security $security,
private PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository,
private PersonDocumentRepository $personDocumentRepository,
) {}
public function fetchAssociatedStoredObject(GenericDocDTO $genericDocDTO): ?StoredObject
{
return $this->personDocumentRepository->find($genericDocDTO->identifiers['id'])?->getObject();
}
public function supportsGenericDoc(GenericDocDTO $genericDocDTO): bool
{
return $this->supportsKeyAndIdentifiers($genericDocDTO->key, $genericDocDTO->identifiers);
}
public function supportsKeyAndIdentifiers(string $key, array $identifiers): bool
{
return self::KEY === $key && array_key_exists('id', $identifiers);
}
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO
{
if (null === $document = $this->personDocumentRepository->find($identifiers['id'])) {
return null;
}
return new GenericDocDTO(
self::KEY,
$identifiers,
\DateTimeImmutable::createFromInterface($document->getDate()),
$document->getPerson()
);
}
public function buildFetchQueryForPerson(
Person $person,
?\DateTimeImmutable $startDate = null,

View File

@@ -18,6 +18,9 @@ use Chill\DocStoreBundle\GenericDoc\Providers\AccompanyingCourseDocumentGenericD
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
/**
* @implements GenericDocRendererInterface<array{row-only?: bool, show-actions?: bool}>
*/
final readonly class AccompanyingCourseDocumentGenericDocRenderer implements GenericDocRendererInterface
{
public function __construct(
@@ -33,6 +36,10 @@ final readonly class AccompanyingCourseDocumentGenericDocRenderer implements Gen
public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string
{
if ($options['row-only'] ?? false) {
return '@ChillDocStore/List/list_item_row.html.twig';
}
return '@ChillDocStore/List/list_item.html.twig';
}
@@ -44,6 +51,7 @@ final readonly class AccompanyingCourseDocumentGenericDocRenderer implements Gen
'accompanyingCourse' => $doc->getCourse(),
'options' => $options,
'context' => $genericDocDTO->getContext(),
'show_actions' => $options['show-actions'] ?? true,
];
}
@@ -53,6 +61,7 @@ final readonly class AccompanyingCourseDocumentGenericDocRenderer implements Gen
'person' => $doc->getPerson(),
'options' => $options,
'context' => $genericDocDTO->getContext(),
'show_actions' => $options['show-actions'] ?? true,
];
}
}

View File

@@ -13,11 +13,25 @@ namespace Chill\DocStoreBundle\GenericDoc\Twig;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
/**
* Render a generic doc, to display it into a page.
*
* @template T of array
*/
interface GenericDocRendererInterface
{
/**
* @param T $options the options defined by the renderer
*/
public function supports(GenericDocDTO $genericDocDTO, $options = []): bool;
/**
* @param T $options the options defined by the renderer
*/
public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string;
/**
* @param T $options the options defined by the renderer
*/
public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array;
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\Providers\PersonDocumentGenericDocProvider;
@@ -136,6 +137,7 @@ final readonly class PersonDocumentACLAwareRepository implements PersonDocumentA
private function addFilterClauses(FetchQuery $query, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery
{
$personDocMetadata = $this->em->getClassMetadata(PersonDocument::class);
$storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class);
if (null !== $startDate) {
$query->addWhereClause(
@@ -154,10 +156,20 @@ final readonly class PersonDocumentACLAwareRepository implements PersonDocumentA
}
if (null !== $content and '' !== $content) {
$query->addJoinClause(
sprintf(
'JOIN %s AS doc_store ON doc_store.%s = person_document.%s',
$storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName(),
$storedObjectMetadata->getSingleIdentifierColumnName(),
$personDocMetadata->getSingleAssociationJoinColumnName('object')
)
);
$query->addWhereClause(
sprintf(
'(%s ilike ? OR %s ilike ?)',
$personDocMetadata->getColumnName('title'),
'(doc_store.%s ilike ? OR person_document.%s ilike ?)',
$storedObjectMetadata->getColumnName('title'),
$personDocMetadata->getColumnName('description')
),
['%'.$content.'%', '%'.$content.'%'],

View File

@@ -0,0 +1,10 @@
import { fetchResults } from "ChillMainAssets/lib/api/apiMethods";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
export function fetch_generic_docs_by_accompanying_period(
periodId: number,
): Promise<GenericDocForAccompanyingPeriod[]> {
return fetchResults(
`/api/1.0/doc-store/generic-doc/by-period/${periodId}/index`,
);
}

View File

@@ -1,4 +1,4 @@
import { _createI18n } from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import { createApp } from "vue";
import { StoredObject, StoredObjectStatusChange } from "../../types";

View File

@@ -0,0 +1,71 @@
import { DateTime } from "ChillMainAssets/types";
import { StoredObject } from "ChillDocStoreAssets/types/index";
export interface GenericDocMetadata {
isPresent: boolean;
}
/**
* Empty metadata for a GenericDoc
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface EmptyMetadata extends GenericDocMetadata {}
/**
* Minimal Metadata for a GenericDoc with a normalizer
*/
export interface BaseMetadata extends GenericDocMetadata {
title: string;
}
/**
* A generic doc is a document attached to a Person or an AccompanyingPeriod.
*/
export interface GenericDoc {
type: "doc_store_generic_doc";
uniqueKey: string;
key: string;
identifiers: object;
context: "person" | "accompanying-period";
doc_date: DateTime;
metadata: GenericDocMetadata;
storedObject: StoredObject | null;
}
export interface GenericDocForAccompanyingPeriod extends GenericDoc {
context: "accompanying-period";
}
interface BaseMetadataWithHtml extends BaseMetadata {
html: string;
}
export interface GenericDocForAccompanyingCourseDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_document";
metadata: BaseMetadataWithHtml;
}
export interface GenericDocForAccompanyingCourseActivityDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_activity_document";
metadata: BaseMetadataWithHtml;
}
export interface GenericDocForAccompanyingCourseCalendarDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_calendar_document";
metadata: BaseMetadataWithHtml;
}
export interface GenericDocForAccompanyingCoursePersonDocument
extends GenericDocForAccompanyingPeriod {
key: "person_document";
metadata: BaseMetadataWithHtml;
}
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_period_work_evaluation_document";
metadata: BaseMetadataWithHtml;
}

View File

@@ -1,8 +1,5 @@
import {
DateTime,
User,
} from "../../../ChillMainBundle/Resources/public/types";
import { SignedUrlGet } from "./vuejs/StoredObjectButton/helpers";
import { DateTime, User } from "ChillMainAssets/types";
import { SignedUrlGet } from "ChillDocStoreAssets/vuejs/StoredObjectButton/helpers";
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
@@ -138,3 +135,10 @@ export interface ZoomLevel {
nl?: string;
};
}
export interface GenericDoc {
type: "doc_store_generic_doc";
key: string;
context: "person" | "accompanying-period";
doc_date: DateTime;
}

View File

@@ -66,7 +66,7 @@ const open_button = ref<HTMLAnchorElement | null>(null);
function buildDocumentName(): string {
let document_name = props.filename ?? props.storedObject.title;
if ("" === document_name) {
if ("" === document_name || null === document_name) {
document_name = "document";
}

View File

@@ -1,120 +1,3 @@
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %}
{% import '@ChillPerson/Macro/updatedBy.html.twig' as mmm %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.object.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.object.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.object.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
{% if context == 'person' and accompanyingCourse is defined %}
<div>
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ accompanyingCourse.id }}
</span>&nbsp;
</div>
{% elseif context == 'accompanying-period' and person is defined %}
<div>
<span class="badge bg-primary">
{{ 'Document from person %name%'|trans({ '%name%': document.person|chill_entity_render_string }) }}
</span>&nbsp;
</div>
{% endif %}
<div class="denomination h2">
{{ document.title|chill_print_or_message("No title") }}
</div>
{% if document.object.type is not empty %}
<div>
{{ mm.mimeIcon(document.object.type) }}
</div>
{% endif %}
<div>
<p>{{ document.category.name|localize_translatable_string }}</p>
</div>
{% if document.object.hasTemplate %}
<div>
<p>{{ document.object.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
{% if document.date is not null %}
<div class="dates row text-end">
<span>{{ document.date|format_date('short') }}</span>
</div>
{% endif %}
</div>
</div>
</div>
{% if document.description is not empty %}
<div class="item-row">
<blockquote class="chill-user-quote col">
{{ document.description|chill_markdown_to_html }}
</blockquote>
</div>
{% endif %}
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if document.course is defined %}
<li>
{{ chill_entity_workflow_list('Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument', document.id) }}
</li>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<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('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) %}
<li>
<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_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 %}
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ document.object|chill_document_button_group(document.title) }}
</li>
<li>
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document) %}
<li>
<a href="{{ path('person_document_edit', {'person': person.id, 'id': document.id}) }}" class="btn btn-update"></a>
</li>
{% endif %}
{% if is_granted('CHILL_PERSON_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_person_document_delete', {'person': person.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
{% include '@ChillDocStore/List/list_item_row.html.twig'%}
</div>

View File

@@ -0,0 +1,119 @@
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %}
{% import '@ChillPerson/Macro/updatedBy.html.twig' as mmm %}
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.object.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.object.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.object.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
{% if context == 'person' and accompanyingCourse is defined %}
<div>
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ accompanyingCourse.id }}
</span>&nbsp;
</div>
{% elseif context == 'accompanying-period' and person is defined %}
<div>
<span class="badge bg-primary">
{{ 'Document from person %name%'|trans({ '%name%': document.person|chill_entity_render_string }) }}
</span>&nbsp;
</div>
{% endif %}
<div class="denomination h2">
{{ document.title|chill_print_or_message("No title") }}
</div>
{% if document.object.type is not empty %}
<div>
{{ mm.mimeIcon(document.object.type) }}
</div>
{% endif %}
<div>
<p>{{ document.category.name|localize_translatable_string }}</p>
</div>
{% if document.object.hasTemplate %}
<div>
<p>{{ document.object.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
{% if document.date is not null %}
<div class="dates row text-end">
<span>{{ document.date|format_date('short') }}</span>
</div>
{% endif %}
</div>
</div>
</div>
{% if document.description is not empty %}
<div class="item-row">
<blockquote class="chill-user-quote col">
{{ document.description|chill_markdown_to_html }}
</blockquote>
</div>
{% endif %}
{% if show_actions %}
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if document.course is defined %}
<li>
{{ chill_entity_workflow_list('Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument', document.id) }}
</li>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<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('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) %}
<li>
<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_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 %}
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ document.object|chill_document_button_group(document.title) }}
</li>
<li>
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document) %}
<li>
<a href="{{ path('person_document_edit', {'person': person.id, 'id': document.id}) }}" class="btn btn-update"></a>
</li>
{% endif %}
{% if is_granted('CHILL_PERSON_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_person_document_delete', {'person': person.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
{% endif %}

View File

@@ -24,9 +24,9 @@
{% endif %}
{% endif %}
<div class="row">
<div class="row g-3">
<div class="col-xs-12 col-sm-6 col-md-4">
<div class="card"">
<div class="card">
<div class="card-body">
<h2 class="card-title">{{ title }}</h2>
<h3>{{ 'workflow.public_link.main_document'|trans }}</h3>
@@ -39,5 +39,21 @@
</div>
</div>
</div>
{% for attachment in attachments %}
<div class="col-xs-12 col-sm-6 col-md-4">
<div class="card">
<div class="card-body">
<h2 class="card-title">{{ attachment.proxyStoredObject.title }}</h2>
<h3>{{ 'workflow.public_link.attachment'|trans }}</h3>
<ul class="record_actions slim small">
<li>
{{ attachment.proxyStoredObject|chill_document_download_only_button(storedObject.title(), false) }}
</li>
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
@@ -26,7 +28,12 @@ class StoredObjectVoter extends Voter
{
public const LOG_PREFIX = '[stored object voter] ';
public function __construct(private readonly Security $security, private readonly iterable $storedObjectVoters, private readonly LoggerInterface $logger) {}
public function __construct(
private readonly Security $security,
private readonly iterable $storedObjectVoters,
private readonly LoggerInterface $logger,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
) {}
protected function supports($attribute, $subject): bool
{
@@ -39,6 +46,16 @@ class StoredObjectVoter extends Voter
/** @var StoredObject $subject */
$attributeAsEnum = StoredObjectRoleEnum::from($attribute);
// check if the stored object is attached to any workflow
$user = $token->getUser();
if ($user instanceof User && StoredObjectRoleEnum::SEE === $attributeAsEnum) {
foreach ($this->entityWorkflowAttachmentRepository->findByStoredObject($subject) as $workflowAttachment) {
if ($workflowAttachment->getEntityWorkflow()->isUserInvolved($user)) {
return true;
}
}
}
// Loop through context-specific voters
foreach ($this->storedObjectVoters as $storedObjectVoter) {
if ($storedObjectVoter->supports($attributeAsEnum, $subject)) {

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\Serializer\Normalizer;
use Chill\DocStoreBundle\GenericDoc\Exception\AssociatedStoredObjectNotFound;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\ManagerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class GenericDocNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
/**
* Special key to attach a stored object to the generic doc.
*
* This is present for performance reason: if any other part of the application "knows" about the stored object
* related to the GenericDoc, this stored object is use instead of adding costly sql queries.
*/
public const ATTACHED_STORED_OBJECT_PROXY = 'attached-stored-object-proxy';
public function __construct(private readonly ManagerInterface $manager) {}
public function normalize($object, ?string $format = null, array $context = []): array
{
/* @var GenericDocDTO $object */
try {
$storedObject = $context[self::ATTACHED_STORED_OBJECT_PROXY] ?? $this->manager->fetchStoredObject($object);
} catch (AssociatedStoredObjectNotFound) {
$storedObject = null;
}
$data = [
'type' => 'doc_store_generic_doc',
'key' => $object->key,
'uniqueKey' => $object->key.implode('', array_keys($object->identifiers)).implode('', array_values($object->identifiers)),
'identifiers' => $object->identifiers,
'context' => $object->getContext(),
'doc_date' => $this->normalizer->normalize($object->docDate, $format, $context),
'metadata' => [],
'storedObject' => $this->normalizer->normalize($storedObject, $format, $context),
];
if ($this->manager->isGenericDocNormalizable($object, $format, $context)) {
$data['metadata'] = $this->manager->normalizeGenericDoc($object, $format, $context);
}
return $data;
}
public function supportsNormalization($data, ?string $format = null): bool
{
return 'json' === $format && $data instanceof GenericDocDTO;
}
}

View File

@@ -0,0 +1,59 @@
<?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\Cryptography;
use Base64Url\Base64Url;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Random\Randomizer;
class KeyGenerator
{
private readonly Randomizer $randomizer;
public function __construct()
{
$this->randomizer = new Randomizer();
}
/**
* @return array{alg: string, ext: bool, k: string, key_ops: list<string>, kty: string}
*/
public function generateKey(string $algo = StoredObjectManagerInterface::ALGORITHM): array
{
if (StoredObjectManagerInterface::ALGORITHM !== $algo) {
throw new \LogicException(sprintf("Algorithm '%s' is not supported.", $algo));
}
$key = $this->randomizer->getBytes(32);
return [
'alg' => 'A256CBC',
'ext' => true,
'k' => Base64Url::encode($key),
'key_ops' => ['encrypt', 'decrypt'],
'kty' => 'oct',
];
}
/**
* @return list<int<0, 255>>
*/
public function generateIv(): array
{
$iv = [];
for ($i = 0; $i < 16; ++$i) {
$iv[] = unpack('C', $this->randomizer->getBytes(8))[1];
}
return $iv;
}
}

View File

@@ -53,7 +53,6 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
$this->entityManager->wrapInTransaction(function () use ($storedObject, $message, $signature) {
$this->storedObjectManager->write($storedObject, $message->content);
$this->signatureStepStateChanger->markSignatureAsSigned($signature, $message->signatureZoneIndex);
});

View File

@@ -18,6 +18,8 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
interface StoredObjectManagerInterface
{
public const ALGORITHM = 'AES-256-CBC';
/**
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*/

View File

@@ -0,0 +1,160 @@
<?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\AsyncUpload\Driver\LocalStorage;
use Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\StoredObjectManager;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectManagerTest extends TestCase
{
private const CONTENT = 'abcde';
public function testWrite(): StoredObjectVersion
{
$storedObject = new StoredObject();
$manager = $this->buildStoredObjectManager();
$version = $manager->write($storedObject, self::CONTENT);
self::assertSame($storedObject, $version->getStoredObject());
return $version;
}
/**
* @depends testWrite
*/
public function testRead(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$content = $manager->read($version);
self::assertEquals(self::CONTENT, $content);
return $version;
}
/**
* @depends testRead
*/
public function testExists(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$notExisting = new StoredObject();
$versionNotPersisted = $notExisting->registerVersion();
self::assertTrue($manager->exists($version));
self::assertFalse($manager->exists($versionNotPersisted));
self::assertFalse($manager->exists(new StoredObject()));
return $version;
}
/**
* @throws \Chill\DocStoreBundle\Exception\StoredObjectManagerException
*
* @depends testExists
*/
public function testEtag(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$actual = $manager->etag($version);
self::assertEquals(md5(self::CONTENT), $actual);
return $version;
}
/**
* @depends testEtag
*/
public function testGetContentLength(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$actual = $manager->getContentLength($version);
self::assertSame(5, $actual);
return $version;
}
/**
* @throws \Chill\DocStoreBundle\Exception\StoredObjectManagerException
*
* @depends testGetContentLength
*/
public function testGetLastModified(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$actual = $manager->getLastModified($version);
self::assertInstanceOf(\DateTimeImmutable::class, $actual);
self::assertGreaterThan((new \DateTimeImmutable('now'))->getTimestamp() - 10, $actual->getTimestamp());
return $version;
}
/**
* @depends testGetLastModified
*/
public function testDelete(StoredObjectVersion $version): void
{
$manager = $this->buildStoredObjectManager();
$manager->delete($version);
self::assertFalse($manager->exists($version));
}
public function testDeleteDoesNotRemoveOlderVersion(): void
{
$storedObject = new StoredObject();
$manager = $this->buildStoredObjectManager();
$version1 = $manager->write($storedObject, 'version1');
$version2 = $manager->write($storedObject, 'version2');
$version3 = $manager->write($storedObject, 'version3');
self::assertTrue($manager->exists($version1));
self::assertEquals('version1', $manager->read($version1));
self::assertTrue($manager->exists($version2));
self::assertEquals('version2', $manager->read($version2));
self::assertTrue($manager->exists($version3));
self::assertEquals('version3', $manager->read($version3));
// we delete the intermediate version
$manager->delete($version2);
self::assertFalse($manager->exists($version2));
// we check that we are still able to download the other versions
self::assertTrue($manager->exists($version1));
self::assertEquals('version1', $manager->read($version1));
self::assertTrue($manager->exists($version3));
self::assertEquals('version3', $manager->read($version3));
}
private function buildStoredObjectManager(): StoredObjectManager
{
return new StoredObjectManager(
new ParameterBag(['chill_doc_store' => ['local_storage' => ['storage_path' => '/tmp/chill-local-storage-test']]]),
new KeyGenerator(),
);
}
}

View File

@@ -0,0 +1,238 @@
<?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\AsyncUpload\Driver\LocalStorage;
use Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\TempUrlLocalStorageGenerator;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @internal
*
* @coversNothing
*/
class TempUrlLocalStorageGeneratorTest extends TestCase
{
use ProphecyTrait;
private const SECRET = 'abc';
public function testGenerate(): void
{
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate('chill_docstore_stored_object_operate', [
'object_name' => $object_name = 'testABC',
'exp' => $expiration = 1734307200 + 180,
'sig' => TempUrlLocalStorageGeneratorTest::expectedSignature('GET', $object_name, $expiration),
], UrlGeneratorInterface::ABSOLUTE_URL)
->shouldBeCalled()
->willReturn($url = 'http://example.com/public/doc-store/stored-object/operate/testABC');
$generator = $this->buildGenerator($urlGenerator->reveal());
$signedUrl = $generator->generate('GET', $object_name);
self::assertEquals($url, $signedUrl->url);
self::assertEquals($object_name, $signedUrl->object_name);
self::assertEquals($expiration, $signedUrl->expires->getTimestamp());
self::assertEquals('GET', $signedUrl->method);
}
public function testGeneratePost(): void
{
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate('chill_docstore_storedobject_post', [
'prefix' => 'prefixABC',
], UrlGeneratorInterface::ABSOLUTE_URL)
->shouldBeCalled()
->willReturn($url = 'http://example.com/public/doc-store/stored-object/prefixABC');
$generator = $this->buildGenerator($urlGenerator->reveal());
$signedUrl = $generator->generatePost(object_name: 'prefixABC');
self::assertEquals($url, $signedUrl->url);
self::assertEquals('prefixABC', $signedUrl->object_name);
self::assertEquals($expiration = 1734307200 + 180 + 180, $signedUrl->expires->getTimestamp());
self::assertEquals('POST', $signedUrl->method);
self::assertEquals(TempUrlLocalStorageGeneratorTest::expectedSignature('POST', 'prefixABC', $expiration), $signedUrl->signature);
}
private static function expectedSignature(string $method, $objectName, int $expiration): string
{
return hash('sha512', sprintf('%s.%s.%s.%d', $method, self::SECRET, $objectName, $expiration));
}
/**
* @dataProvider generateValidateSignatureData
*/
public function testValidateSignature(string $signature, string $method, string $objectName, int $expiration, \DateTimeImmutable $now, bool $expected, string $message): void
{
$urlGenerator = $this->buildGenerator(clock: new MockClock($now));
self::assertEquals($expected, $urlGenerator->validateSignature($signature, $method, $objectName, $expiration), $message);
}
/**
* @dataProvider generateValidateSignaturePostData
*/
public function testValidateSignaturePost(string $signature, int $expiration, string $objectName, int $maxFileSize, int $maxFileCount, \DateTimeImmutable $now, bool $expected, string $message): void
{
$urlGenerator = $this->buildGenerator(clock: new MockClock($now));
self::assertEquals($expected, $urlGenerator->validateSignaturePost($signature, $objectName, $expiration, $maxFileSize, $maxFileCount), $message);
}
public static function generateValidateSignaturePostData(): iterable
{
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('POST', $object_name = 'testABC', $expiration = 1734307200 + 180),
$expiration,
$object_name,
15_000_000,
1,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
true,
'Valid signature',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('POST', $object_name = 'testABC', $expiration = 1734307200 + 180),
$expiration,
$object_name,
15_000_001,
1,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
false,
'Wrong max file size',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('POST', $object_name = 'testABC', $expiration = 1734307200 + 180),
$expiration,
$object_name,
15_000_000,
2,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
false,
'Wrong max file count',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('POST', $object_name = 'testABC', $expiration = 1734307200 + 180),
$expiration,
$object_name.'AAA',
15_000_000,
1,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
false,
'Invalid object name',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('POST', $object_name = 'testABC', $expiration = 1734307200 + 180).'A',
$expiration,
$object_name,
15_000_000,
1,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
false,
'Invalid signature',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('POST', $object_name = 'testABC', $expiration = 1734307200 + 180),
$expiration,
$object_name,
15_000_000,
1,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration + 1)),
false,
'Expired signature',
];
}
public static function generateValidateSignatureData(): iterable
{
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('GET', $object_name = 'testABC', $expiration = 1734307200 + 180),
'GET',
$object_name,
$expiration,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
true,
'Valid signature, not expired',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('HEAD', $object_name = 'testABC', $expiration = 1734307200 + 180),
'HEAD',
$object_name,
$expiration,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
true,
'Valid signature, not expired',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('GET', $object_name = 'testABC', $expiration = 1734307200 + 180).'A',
'GET',
$object_name,
$expiration,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
false,
'Invalid signature',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('GET', $object_name = 'testABC', $expiration = 1734307200 + 180),
'GET',
$object_name,
$expiration,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration + 1)),
false,
'Signature expired',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('GET', $object_name = 'testABC', $expiration = 1734307200 + 180),
'GET',
$object_name.'____',
$expiration,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
false,
'Invalid object name',
];
yield [
TempUrlLocalStorageGeneratorTest::expectedSignature('POST', $object_name = 'testABC', $expiration = 1734307200 + 180),
'POST',
$object_name,
$expiration,
\DateTimeImmutable::createFromFormat('U', (string) ($expiration - 10)),
false,
'Wrong method',
];
}
private function buildGenerator(?UrlGeneratorInterface $urlGenerator = null, ?ClockInterface $clock = null): TempUrlLocalStorageGenerator
{
return new TempUrlLocalStorageGenerator(
self::SECRET,
$clock ?? new MockClock('2024-12-16T00:00:00+00:00'),
$urlGenerator ?? $this->prophesize(UrlGeneratorInterface::class)->reveal(),
);
}
}

View File

@@ -9,9 +9,9 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace AsyncUpload\Command;
namespace Chill\DocStoreBundle\Tests\AsyncUpload\Driver\OpenstackObjectStore;
use Chill\DocStoreBundle\AsyncUpload\Command\ConfigureOpenstackObjectStorageCommand;
use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\ConfigureOpenstackObjectStorageCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;

View File

@@ -9,13 +9,13 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Service;
namespace Chill\DocStoreBundle\Tests\AsyncUpload\Driver\OpenstackObjectStore;
use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\StoredObjectManager;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Exception\TransportException;
@@ -27,7 +27,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @internal
*
* @covers \Chill\DocStoreBundle\Service\StoredObjectManager
* @covers \Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\StoredObjectManager
*/
final class StoredObjectManagerTest extends TestCase
{

View File

@@ -0,0 +1,338 @@
<?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\AsyncUpload\Driver\LocalStorage\StoredObjectManager;
use Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\TempUrlLocalStorageGenerator;
use Chill\DocStoreBundle\Controller\StoredObjectContentToLocalStorageController;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectContentToLocalStorageControllerTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider generateOperateContentWithExceptionDataProvider
*/
public function testOperateContentWithException(Request $request, string $expectedException, string $expectedExceptionMessage, bool $existContent, string $readContent, bool $signatureValidity): void
{
$storedObjectManager = $this->prophesize(StoredObjectManager::class);
$storedObjectManager->existsContent(Argument::any())->willReturn($existContent);
$storedObjectManager->readContent(Argument::any())->willReturn($readContent);
$tempUrlLocalStorageGenerator = $this->prophesize(TempUrlLocalStorageGenerator::class);
$tempUrlLocalStorageGenerator->validateSignature(
$request->query->get('sig', ''),
$request->getMethod(),
$request->query->get('object_name', ''),
$request->query->getInt('exp', 0)
)
->willReturn($signatureValidity);
$this->expectException($expectedException);
$this->expectExceptionMessage($expectedExceptionMessage);
$controller = new StoredObjectContentToLocalStorageController(
$storedObjectManager->reveal(),
$tempUrlLocalStorageGenerator->reveal()
);
$controller->contentOperate($request);
}
public function testOperateContentGetHappyScenario(): void
{
$objectName = 'testABC';
$expiration = new \DateTimeImmutable();
$storedObjectManager = $this->prophesize(StoredObjectManager::class);
$storedObjectManager->existsContent($objectName)->willReturn(true);
$storedObjectManager->readContent($objectName)->willReturn('123456789');
$tempUrlLocalStorageGenerator = $this->prophesize(TempUrlLocalStorageGenerator::class);
$tempUrlLocalStorageGenerator->validateSignature('signature', 'GET', $objectName, $expiration->getTimestamp())
->shouldBeCalled()
->willReturn(true);
$controller = new StoredObjectContentToLocalStorageController(
$storedObjectManager->reveal(),
$tempUrlLocalStorageGenerator->reveal()
);
$response = $controller->contentOperate(new Request(['object_name' => $objectName, 'sig' => 'signature', 'exp' => $expiration->getTimestamp()]));
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('123456789', $response->getContent());
}
public function testOperateContentHeadHappyScenario(): void
{
$objectName = 'testABC';
$expiration = new \DateTimeImmutable();
$storedObjectManager = $this->prophesize(StoredObjectManager::class);
$storedObjectManager->existsContent($objectName)->willReturn(true);
$storedObjectManager->readContent($objectName)->willReturn('123456789');
$tempUrlLocalStorageGenerator = $this->prophesize(TempUrlLocalStorageGenerator::class);
$tempUrlLocalStorageGenerator->validateSignature('signature', 'HEAD', $objectName, $expiration->getTimestamp())
->shouldBeCalled()
->willReturn(true);
$controller = new StoredObjectContentToLocalStorageController(
$storedObjectManager->reveal(),
$tempUrlLocalStorageGenerator->reveal()
);
$request = new Request(['object_name' => $objectName, 'sig' => 'signature', 'exp' => $expiration->getTimestamp()]);
$request->setMethod('HEAD');
$response = $controller->contentOperate($request);
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('', $response->getContent());
}
public function testPostContentHappyScenario(): void
{
$expiration = 171899000;
$storedObjectManager = $this->prophesize(StoredObjectManager::class);
$storedObjectManager->writeContent('filePrefix/abcSUFFIX', Argument::containingString('fake_encrypted_content'))
->shouldBeCalled();
$tempUrlGenerator = $this->prophesize(TempUrlLocalStorageGenerator::class);
$tempUrlGenerator->validateSignaturePost('signature', 'filePrefix/abc', $expiration, 15_000_000, 1)
->shouldBeCalled()
->willReturn(true);
$controller = new StoredObjectContentToLocalStorageController($storedObjectManager->reveal(), $tempUrlGenerator->reveal());
$request = new Request(
['prefix' => 'filePrefix/abc'],
['signature' => 'signature', 'expires' => $expiration, 'max_file_size' => 15_000_000, 'max_file_count' => 1],
files: [
'filePrefix/abcSUFFIX' => new UploadedFile(
__DIR__.'/data/StoredObjectContentToLocalStorageControllerTest/dummy_file',
'Document.odt',
test: true
),
]
);
$response = $controller->postContent($request);
self::assertEquals(204, $response->getStatusCode());
}
/**
* @dataProvider generatePostContentWithExceptionDataProvider
*/
public function testPostContentWithException(Request $request, bool $isSignatureValid, string $expectedException, string $expectedExceptionMessage): void
{
$storedObjectManager = $this->prophesize(StoredObjectManager::class);
$storedObjectManager->writeContent(Argument::any(), Argument::any())->shouldNotBeCalled();
$tempUrlGenerator = $this->prophesize(TempUrlLocalStorageGenerator::class);
$tempUrlGenerator->validateSignaturePost('signature', Argument::any(), Argument::any(), Argument::any(), Argument::any())
->willReturn($isSignatureValid);
$controller = new StoredObjectContentToLocalStorageController(
$storedObjectManager->reveal(),
$tempUrlGenerator->reveal()
);
$this->expectException($expectedException);
$this->expectExceptionMessage($expectedExceptionMessage);
$controller->postContent($request);
}
public static function generatePostContentWithExceptionDataProvider(): iterable
{
$query = ['prefix' => 'filePrefix/abc'];
$attributes = ['signature' => 'signature', 'expires' => 15088556855, 'max_file_size' => 15_000_000, 'max_file_count' => 1];
$request = new Request([]);
$request->setMethod('POST');
yield [
$request,
true,
BadRequestHttpException::class,
'Prefix parameter is missing',
];
$attrCloned = [...$attributes];
unset($attrCloned['max_file_size']);
$request = new Request($query, $attrCloned);
$request->setMethod('POST');
yield [
$request,
true,
BadRequestHttpException::class,
'Max file size is not set or equal to zero',
];
$attrCloned = [...$attributes];
unset($attrCloned['max_file_count']);
$request = new Request($query, $attrCloned);
$request->setMethod('POST');
yield [
$request,
true,
BadRequestHttpException::class,
'Max file count is not set or equal to zero',
];
$attrCloned = [...$attributes];
unset($attrCloned['expires']);
$request = new Request($query, $attrCloned);
$request->setMethod('POST');
yield [
$request,
true,
BadRequestHttpException::class,
'Expiration is not set or equal to zero',
];
$attrCloned = [...$attributes];
unset($attrCloned['signature']);
$request = new Request($query, $attrCloned);
$request->setMethod('POST');
yield [
$request,
true,
BadRequestHttpException::class,
'Signature is not set or is a blank string',
];
$request = new Request($query, $attributes);
$request->setMethod('POST');
yield [
$request,
false,
AccessDeniedHttpException::class,
'Invalid signature',
];
$request = new Request($query, $attributes, files: []);
$request->setMethod('POST');
yield [
$request,
true,
BadRequestHttpException::class,
'Zero files given',
];
$request = new Request($query, $attributes, files: [
'filePrefix/abcSUFFIX_1' => new UploadedFile(__DIR__.'/data/StoredObjectContentToLocalStorageControllerTest/dummy_file', 'Some content', test: true),
'filePrefix/abcSUFFIX_2' => new UploadedFile(__DIR__.'/data/StoredObjectContentToLocalStorageControllerTest/dummy_file', 'Some content2', test: true),
]);
$request->setMethod('POST');
yield [
$request,
true,
AccessDeniedHttpException::class,
'More files than max file count',
];
$request = new Request($query, [...$attributes, 'max_file_size' => 3], files: [
'filePrefix/abcSUFFIX_1' => new UploadedFile(__DIR__.'/data/StoredObjectContentToLocalStorageControllerTest/dummy_file', 'Some content', test: true),
]);
$request->setMethod('POST');
yield [
$request,
true,
AccessDeniedHttpException::class,
'File is too big',
];
$request = new Request($query, [...$attributes], files: [
'some/other/prefix_SUFFIX' => new UploadedFile(__DIR__.'/data/StoredObjectContentToLocalStorageControllerTest/dummy_file', 'Some content', test: true),
]);
$request->setMethod('POST');
yield [
$request,
true,
AccessDeniedHttpException::class,
'Filename does not start with signed prefix',
];
}
public static function generateOperateContentWithExceptionDataProvider(): iterable
{
yield [
new Request(['object_name' => '', 'sig' => '', 'exp' => 0]),
BadRequestHttpException::class,
'Object name parameter is missing',
false,
'',
false,
];
yield [
new Request(['object_name' => 'testABC', 'sig' => '', 'exp' => 0]),
BadRequestHttpException::class,
'Expiration is not set or equal to zero',
false,
'',
false,
];
yield [
new Request(['object_name' => 'testABC', 'sig' => '', 'exp' => (new \DateTimeImmutable())->getTimestamp()]),
BadRequestHttpException::class,
'Signature is not set or is a blank string',
false,
'',
false,
];
yield [
new Request(['object_name' => 'testABC', 'sig' => '1234', 'exp' => (new \DateTimeImmutable())->getTimestamp()]),
AccessDeniedHttpException::class,
'Invalid signature',
false,
'',
false,
];
yield [
new Request(['object_name' => 'testABC', 'sig' => '1234', 'exp' => (new \DateTimeImmutable())->getTimestamp()]),
NotFoundHttpException::class,
'Object does not exists on disk',
false,
'',
true,
];
}
}

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