Compare commits

..

44 Commits

Author SHA1 Message Date
724e6c7365 Setup base for refactoring addPersons.vue 2025-02-13 17:34:59 +01:00
a8f8c23027 Add (temporary) types in Main and ThirdpartyBundle 2025-02-13 16:43:39 +01:00
cb7e2d752e Create types 2025-02-11 17:51:29 +01:00
1019a7bcd4 Improve merge service according to specifications 2025-02-11 16:46:44 +01:00
9b0ae7198a WIP merge service 2025-02-11 15:06:04 +01:00
30e7009178 First commit - changie for feature 2025-02-11 14:23:05 +01:00
ab35e8c034 Release v3.8.2 2025-02-10 14:52:22 +01:00
2aded2974f Merge branch '358-remove-filter-button' into 'master'
Remove "filter" button from attachment modal in workflows

Closes #358

See merge request Chill-Projet/chill-bundles!794
2025-02-10 13:49:09 +00:00
f84c1632b2 Remove "filter" button from attachment modal in workflows
The "filter" button was unnecessary in the document list within the "add attachment" modal and has been removed for simplicity. This change does not involve any schema modifications and resolves issue #358.
2025-02-10 14:41:53 +01:00
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
59fd9fc63f Add a line in the documentation about the role of prettier within eslint configuration 2025-01-21 10:36:45 +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
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
156 changed files with 4457 additions and 722 deletions

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Allow the merge of two accompanying period works
time: 2025-02-11T14:22:43.134106669+01:00
custom:
Issue: "359"
SchemaChange: No schema change

View File

@@ -1,5 +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.
* 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
* ([#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

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

@@ -0,0 +1,3 @@
## v3.8.2 - 2025-02-10
### Fixed
* ([#358](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/358)) Remove "filter" button on list of documents in the workflow's "add attachement" modal

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ composer.lock
docs/build/
.php_cs.cache
.cache/*
yarn.lock
docker/db/data
docker/rabbitmq/data

View File

@@ -6,11 +6,92 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v3.8.2 - 2025-02-10
### Fixed
* ([#358](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/358)) Remove "filter" button on list of documents in the workflow's "add attachement" modal
## 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.
* 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
* ([#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

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 @@
"@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",
@@ -80,7 +79,12 @@
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress",
"eslint": "npx eslint-baseline --fix \"**/*.{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

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

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

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

@@ -14,6 +14,7 @@ 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;
@@ -28,6 +29,8 @@ 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

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

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

@@ -11,10 +11,13 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\GenericDoc;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\Exception\NotNormalizableGenericDocException;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocNormalizerInterface;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
@@ -58,6 +61,7 @@ class ManagerTest extends KernelTestCase
$manager = new Manager(
[new SimpleGenericDocAccompanyingPeriodProvider()],
[new SimpleGenericDocPersonProvider()],
[],
$this->connection,
);
@@ -79,6 +83,7 @@ class ManagerTest extends KernelTestCase
$manager = new Manager(
[new SimpleGenericDocAccompanyingPeriodProvider()],
[new SimpleGenericDocPersonProvider()],
[],
$this->connection,
);
@@ -100,6 +105,7 @@ class ManagerTest extends KernelTestCase
$manager = new Manager(
[new SimpleGenericDocAccompanyingPeriodProvider()],
[new SimpleGenericDocPersonProvider()],
[],
$this->connection,
);
@@ -121,6 +127,7 @@ class ManagerTest extends KernelTestCase
$manager = new Manager(
[new SimpleGenericDocAccompanyingPeriodProvider()],
[new SimpleGenericDocPersonProvider()],
[],
$this->connection,
);
@@ -142,6 +149,7 @@ class ManagerTest extends KernelTestCase
$manager = new Manager(
[new SimpleGenericDocAccompanyingPeriodProvider()],
[new SimpleGenericDocPersonProvider()],
[],
$this->connection,
);
@@ -163,6 +171,7 @@ class ManagerTest extends KernelTestCase
$manager = new Manager(
[new SimpleGenericDocAccompanyingPeriodProvider()],
[new SimpleGenericDocPersonProvider()],
[],
$this->connection,
);
@@ -170,10 +179,77 @@ class ManagerTest extends KernelTestCase
self::assertEquals(['accompanying_course_document_dummy'], $places);
}
public function testIsGenericDocNormalizable(): void
{
$genericDoc = new GenericDocDTO('test', ['id' => 1], new \DateTimeImmutable(), new AccompanyingPeriod());
$manager = new Manager([], [], [$this->buildNormalizer(true)], $this->connection);
self::assertTrue($manager->isGenericDocNormalizable($genericDoc, 'json'));
$manager = new Manager([], [], [$this->buildNormalizer(false)], $this->connection);
self::assertFalse($manager->isGenericDocNormalizable($genericDoc, 'json'));
}
public function testNormalizeGenericDocMetadata(): void
{
$genericDoc = new GenericDocDTO('test', ['id' => 1], new \DateTimeImmutable(), new AccompanyingPeriod());
$manager = new Manager([], [], [$this->buildNormalizer(false), $this->buildNormalizer(true)], $this->connection);
self::assertEquals(['title' => 'Some title'], $manager->normalizeGenericDoc($genericDoc, 'json'));
}
public function testNormalizeGenericDocMetadataNoNormalizer(): void
{
$genericDoc = new GenericDocDTO('test', ['id' => 1], new \DateTimeImmutable(), new AccompanyingPeriod());
$manager = new Manager([], [], [$this->buildNormalizer(false)], $this->connection);
$this->expectException(NotNormalizableGenericDocException::class);
self::assertEquals(['title' => 'Some title'], $manager->normalizeGenericDoc($genericDoc, 'json'));
}
public function buildNormalizer(bool $supports): GenericDocNormalizerInterface
{
return new class ($supports) implements GenericDocNormalizerInterface {
public function __construct(private readonly bool $supports) {}
public function supportsNormalization(GenericDocDTO $genericDocDTO, string $format, array $context = []): bool
{
return $this->supports;
}
public function normalize(GenericDocDTO $genericDocDTO, string $format, array $context = []): array
{
return ['title' => 'Some title'];
}
};
}
}
final readonly class SimpleGenericDocAccompanyingPeriodProvider implements GenericDocForAccompanyingPeriodProviderInterface
{
public function fetchAssociatedStoredObject(GenericDocDTO $genericDocDTO): ?StoredObject
{
throw new \BadMethodCallException('not implemented');
}
public function supportsGenericDoc(GenericDocDTO $genericDocDTO): bool
{
throw new \BadMethodCallException('not implemented');
}
public function supportsKeyAndIdentifiers(string $key, array $identifiers): bool
{
return 'accompanying_course_document_dummy' === $key;
}
public function buildOneGenericDoc(string $key, array $identifiers): ?GenericDocDTO
{
return new GenericDocDTO('accompanying_course_document_dummy', $identifiers, new \DateTimeImmutable(), new AccompanyingPeriod());
}
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
$query = new FetchQuery(

View File

@@ -13,6 +13,7 @@ namespace Chill\DocStoreBundle\Tests\GenericDoc\Providers;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\Providers\AccompanyingCourseDocumentGenericDocProvider;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\EntityManagerInterface;
@@ -56,7 +57,8 @@ class AccompanyingCourseDocumentGenericDocProviderTest extends KernelTestCase
$provider = new AccompanyingCourseDocumentGenericDocProvider(
$security->reveal(),
$this->entityManager
$this->entityManager,
$this->prophesize(AccompanyingCourseDocumentRepository::class)->reveal()
);
$query = $provider->buildFetchQueryForAccompanyingPeriod($period, $startDate, $endDate, $content);

View File

@@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Tests\GenericDoc\Providers;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\Providers\PersonDocumentGenericDocProvider;
use Chill\DocStoreBundle\Repository\PersonDocumentACLAwareRepositoryInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
@@ -33,11 +34,14 @@ class PersonDocumentGenericDocProviderTest extends KernelTestCase
private PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository;
private PersonDocumentRepository $personDocumentRepository;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->personDocumentACLAwareRepository = self::getContainer()->get(PersonDocumentACLAwareRepositoryInterface::class);
$this->personDocumentRepository = self::getContainer()->get(PersonDocumentRepository::class);
}
/**
@@ -60,7 +64,8 @@ class PersonDocumentGenericDocProviderTest extends KernelTestCase
$provider = new PersonDocumentGenericDocProvider(
$security->reveal(),
$this->personDocumentACLAwareRepository
$this->personDocumentACLAwareRepository,
$this->personDocumentRepository,
);
$query = $provider->buildFetchQueryForPerson($person, $startDate, $endDate, $content);

View File

@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -44,7 +45,7 @@ class StoredObjectVoterTest extends TestCase
->with($this->logicalOr($this->identicalTo('ROLE_USER'), $this->identicalTo('ROLE_ADMIN')))
->willReturn($securityIsGrantedResult);
$voter = new StoredObjectVoter($security, $storedObjectVoters, new NullLogger());
$voter = new StoredObjectVoter($security, $storedObjectVoters, new NullLogger(), $this->createMock(EntityWorkflowAttachmentRepository::class));
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocGeneratorBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\ManagerInterface;
use Chill\DocStoreBundle\Serializer\Normalizer\GenericDocNormalizer;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @coversNothing
*/
class GenericDocNormalizerTest extends TestCase
{
private $normalizer;
private ManagerInterface $manager;
protected function setUp(): void
{
$this->manager = $this->createMock(ManagerInterface::class);
$this->normalizer = new GenericDocNormalizer($this->manager);
$innerNormalizer = $this->createMock(NormalizerInterface::class);
$innerNormalizer->method('normalize')
->willReturnCallback(fn ($date) => $date instanceof \DateTimeImmutable ? $date->format(DATE_ATOM) : null);
$this->normalizer->setNormalizer($innerNormalizer);
}
public function testNormalize()
{
$docDate = new \DateTimeImmutable('2023-10-01T15:03:01.012345Z');
$object = new GenericDocDTO(
'some_key',
['id' => 'id1', 'other_id' => 'id2'],
$docDate,
new AccompanyingPeriod(),
);
$expected = [
'type' => 'doc_store_generic_doc',
'key' => 'some_key',
'identifiers' => ['id' => 'id1', 'other_id' => 'id2'],
'context' => 'accompanying-period',
'doc_date' => $docDate->format(DATE_ATOM),
'uniqueKey' => 'some_keyidother_idid1id2',
'metadata' => [],
'storedObject' => null,
];
$this->manager->expects($this->once())->method('isGenericDocNormalizable')
->with($object, 'json', [])
->willReturn(true);
$actual = $this->normalizer->normalize($object, 'json', []);
$this->assertEquals($expected, $actual);
}
}

View File

@@ -21,6 +21,7 @@ use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowWithPublicViewInterface;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\MainBundle\Workflow\Templating\EntityWorkflowViewMetadataDTO;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvideThirdPartiesAssociated;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvidePersonsAssociated;
@@ -69,7 +70,7 @@ final readonly class AccompanyingCourseDocumentWorkflowHandler implements Entity
return $this->translator->trans('workflow.Document deleted');
}
return $this->translator->trans('workflow.Document (n°%doc%)', ['%doc%' => $entityWorkflow->getRelatedEntityId()])
return $this->translator->trans('entity_display_title.Document (n°%doc%)', ['%doc%' => $entityWorkflow->getRelatedEntityId()])
.' - '.$doc->getTitle();
}
@@ -78,6 +79,15 @@ final readonly class AccompanyingCourseDocumentWorkflowHandler implements Entity
return $this->repository->find($entityWorkflow->getRelatedEntityId());
}
public function getRelatedAccompanyingPeriod(EntityWorkflow $entityWorkflow): ?AccompanyingPeriod
{
if (null === $document = $this->getRelatedEntity($entityWorkflow)) {
return null;
}
return $document->getCourse();
}
/**
* @return array[]
*/

View File

@@ -39,6 +39,7 @@ class WorkflowWithPublicViewDocumentHelper
'storedObject' => $storedObject,
'send' => $send,
'metadata' => $metadata,
'attachments' => $entityWorkflow->getAttachments(),
]
);
}

View File

@@ -19,6 +19,22 @@ components:
type: string
type:
type: string
GenericDoc:
type: object
properties:
type:
type: string
enum:
- doc_store_generic_doc
key:
type: string
context:
type: string
enum:
- person
- accompanying-period
doc_date:
$ref: '#/components/schemas/Date'
paths:
/1.0/doc-store/stored-object/create:
@@ -69,30 +85,30 @@ paths:
- storedobject
summary: Get a signed route to get a stored object
parameters:
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObjeect
schema:
type: string
format: uuid
- in: path
name: method
required: true
allowEmptyValue: false
description: the method of the signed url (get or head)
schema:
type: string
enum: [get, head]
- in: query
name: version
required: false
allowEmptyValue: false
description: the version's filename of the stored object
schema:
type: string
minLength: 2
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObjeect
schema:
type: string
format: uuid
- in: path
name: method
required: true
allowEmptyValue: false
description: the method of the signed url (get or head)
schema:
type: string
enum: [ get, head ]
- in: query
name: version
required: false
allowEmptyValue: false
description: the version's filename of the stored object
schema:
type: string
minLength: 2
responses:
200:
description: "OK"
@@ -111,14 +127,14 @@ paths:
- storedobject
summary: Get a signed route to post stored object
parameters:
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObjeect
schema:
type: string
format: uuid
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObjeect
schema:
type: string
format: uuid
responses:
200:
description: "OK"
@@ -137,13 +153,13 @@ paths:
- storedobject
summary: Restore an old version of a stored object
parameters:
- in: path
name: id
required: true
allowEmptyValue: false
description: The id of the stored object version
schema:
type: integer
- in: path
name: id
required: true
allowEmptyValue: false
description: The id of the stored object version
schema:
type: integer
responses:
200:
description: "OK"
@@ -151,4 +167,32 @@ paths:
application/json:
schema:
type: object
/1.0/doc-store/generic-doc/by-period/{id}/index:
get:
tags:
- storedobject
summary: A list of generic doc associated with the accompanying period
parameters:
- in: path
name: id
required: true
allowEmptyValue: false
description: The id of the accompanying period
schema:
type: integer
responses:
200:
description: "OK"
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/PaginatedResult'
- type: object
properties:
results:
type: array
items:
$ref: '#/components/schemas/GenericDoc'
type: object

View File

@@ -31,6 +31,10 @@ services:
arguments:
$providersForAccompanyingPeriod: !tagged_iterator chill_doc_store.generic_doc_accompanying_period_provider
$providersForPerson: !tagged_iterator chill_doc_store.generic_doc_person_provider
$genericDocNormalizers: !tagged_iterator chill_doc_store.generic_doc_metadata_normalizer
Chill\DocStoreBundle\GenericDoc\ManagerInterface:
alias: Chill\DocStoreBundle\GenericDoc\Manager
Chill\DocStoreBundle\GenericDoc\Twig\GenericDocExtension: ~
@@ -44,6 +48,9 @@ services:
Chill\DocStoreBundle\GenericDoc\Renderer\:
resource: '../GenericDoc/Renderer/'
Chill\DocStoreBundle\GenericDoc\Normalizer\:
resource: '../GenericDoc/Normalizer/'
Chill\DocStoreBundle\Validator\:
resource: '../Validator'

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20241212112733 extends AbstractMigration
{
public function getDescription(): string
{
return 'Move the title of PersonDocument and AccompanyingCourseDocument to stored object';
}
public function up(Schema $schema): void
{
$this->addSql('UPDATE chill_doc.stored_object SET title = ac_doc.title FROM chill_doc.accompanyingcourse_document ac_doc WHERE ac_doc.object_id = stored_object.id');
$this->addSql('ALTER TABLE chill_doc.accompanyingcourse_document DROP scope_id');
$this->addSql('ALTER TABLE chill_doc.accompanyingcourse_document DROP title');
$this->addSql('UPDATE chill_doc.stored_object SET title = p_doc.title FROM chill_doc.person_document p_doc WHERE p_doc.object_id = stored_object.id');
$this->addSql('ALTER TABLE chill_doc.person_document DROP title');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.accompanyingcourse_document ADD scope_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_doc.accompanyingcourse_document ADD title TEXT DEFAULT \'\' NOT NULL');
$this->addSql('UPDATE chill_doc.accompanyingcourse_document SET title = so.title FROM chill_doc.stored_object so WHERE object_id = so.id');
$this->addSql('ALTER TABLE chill_doc.accompanyingcourse_document ADD CONSTRAINT fk_a45098f6682b5931 FOREIGN KEY (scope_id) REFERENCES scopes (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_a45098f6682b5931 ON chill_doc.accompanyingcourse_document (scope_id)');
$this->addSql('ALTER TABLE chill_doc.person_document ADD title TEXT DEFAULT \'\' NOT NULL');
$this->addSql('UPDATE chill_doc.person_document SET title = so.title FROM chill_doc.stored_object so WHERE object_id = so.id');
}
}

View File

@@ -86,6 +86,7 @@ workflow:
shared_doc: Document partagé
title: Document partagé
main_document: Document principal
attachment: Pièce jointe
# ROLES
accompanyingCourseDocument: Documents dans les parcours d'accompagnement
@@ -94,3 +95,7 @@ CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE: Supprimer un document
CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE: Voir les documents
CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS: Voir les détails d'un document
CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE: Modifier un document
entity_display_title:
Document (n°%doc%): "Document (n°%doc%)"
Doc for evaluation (n°%eval%): Document de l'évaluation n°%eval%

View File

@@ -0,0 +1,2 @@
entity_display_title:
Doc for evaluation (n°%eval%): Evaluatiedocument (n°%eval%)

View File

@@ -309,6 +309,7 @@ class NotificationController extends AbstractController
$templateData[] = [
'template' => $this->notificationHandlerManager->getTemplate($notification),
'template_data' => $this->notificationHandlerManager->getTemplateData($notification),
'handler' => $this->notificationHandlerManager->getHandler($notification),
'notification' => $notification,
];
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Chill\MainBundle\Workflow\Attachment\AddAttachmentAction;
use Chill\MainBundle\Workflow\Attachment\AddAttachmentRequestDTO;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
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;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class WorkflowAttachmentController
{
public function __construct(
private readonly Security $security,
private readonly SerializerInterface $serializer,
private readonly ValidatorInterface $validator,
private readonly EntityManagerInterface $entityManager,
private readonly AddAttachmentAction $addAttachmentAction,
) {}
#[Route('/api/1.0/main/workflow/{id}/attachment', methods: ['POST'])]
public function addAttachment(EntityWorkflow $entityWorkflow, Request $request): JsonResponse
{
if (!$this->security->isGranted(EntityWorkflowVoter::SEE, $entityWorkflow)) {
throw new AccessDeniedHttpException();
}
$dto = new AddAttachmentRequestDTO($entityWorkflow);
$this->serializer->deserialize($request->getContent(), AddAttachmentRequestDTO::class, 'json', [
AbstractNormalizer::OBJECT_TO_POPULATE => $dto, AbstractNormalizer::GROUPS => ['write'],
]);
$errors = $this->validator->validate($dto);
if (count($errors) > 0) {
return new JsonResponse(
$this->serializer->serialize($errors, 'json'),
Response::HTTP_UNPROCESSABLE_ENTITY,
json: true
);
}
$attachment = ($this->addAttachmentAction)($dto);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($attachment, 'json', [AbstractNormalizer::GROUPS => ['read']]),
json: true
);
}
#[Route('/api/1.0/main/workflow/attachment/{id}', methods: ['DELETE'])]
public function removeAttachment(EntityWorkflowAttachment $attachment): Response
{
if (!$this->security->isGranted(EntityWorkflowVoter::SEE, $attachment->getEntityWorkflow())) {
throw new AccessDeniedHttpException();
}
$this->entityManager->remove($attachment);
$this->entityManager->flush();
return new Response(null, Response::HTTP_NO_CONTENT);
}
#[Route('/api/1.0/main/workflow/{id}/attachment', methods: ['GET'])]
public function listAttachmentsForEntityWorkflow(EntityWorkflow $entityWorkflow): JsonResponse
{
if (!$this->security->isGranted(EntityWorkflowVoter::SEE, $entityWorkflow)) {
throw new AccessDeniedHttpException();
}
return new JsonResponse(
$this->serializer->serialize(
$entityWorkflow->getAttachments(),
'json',
[AbstractNormalizer::GROUPS => ['read']]
),
json: true
);
}
}

View File

@@ -351,6 +351,7 @@ class WorkflowController extends AbstractController
'entity_workflow' => $entityWorkflow,
'transition_form_errors' => $errors,
'signatures' => $signatures,
'related_accompanying_period' => $this->entityWorkflowManager->getRelatedAccompanyingPeriod($entityWorkflow),
]
);
}

View File

@@ -366,6 +366,17 @@ class ChillMainExtension extends Extension implements
*/
private function prependNotifierTexterWithLegacyData(ContainerBuilder $container): void
{
foreach (array_reverse($container->getExtensionConfig('framework')) as $c) {
// we look into each configuration for framework. If there is a configuration for
// texter_transports in one of them, we don't configure anything else
if (null !== $notifConfig = $c['notifier'] ?? null) {
if (null !== ($notifConfig['texter_transports'] ?? null)) {
return;
}
}
}
// there is no texter config, we try to configure one
$configs = $container->getExtensionConfig('chill_main');
$notifierSet = false;
foreach (array_reverse($configs) as $config) {

View File

@@ -87,12 +87,19 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
private string $workflowName;
/**
* @var Collection<int, EntityWorkflowAttachment>
*/
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowAttachment::class, cascade: ['remove'], orphanRemoval: true)]
private Collection $attachments;
public function __construct()
{
$this->subscriberToFinal = new ArrayCollection();
$this->subscriberToStep = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->steps = new ArrayCollection();
$this->attachments = new ArrayCollection();
$initialStep = new EntityWorkflowStep();
$initialStep
@@ -142,6 +149,35 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
/**
* @return $this
*
* @internal use @{EntityWorkflowAttachement::__construct} instead
*/
public function addAttachment(EntityWorkflowAttachment $attachment): self
{
if (!$this->attachments->contains($attachment)) {
$this->attachments[] = $attachment;
}
return $this;
}
/**
* @return Collection<int, EntityWorkflowAttachment>
*/
public function getAttachments(): Collection
{
return $this->attachments;
}
public function removeAttachment(EntityWorkflowAttachment $attachment): self
{
$this->attachments->removeElement($attachment);
return $this;
}
public function getComments(): Collection
{
return $this->comments;
@@ -356,6 +392,17 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return $this->getCurrentStep()->isOnHoldByUser($user);
}
public function isUserInvolved(User $user): bool
{
foreach ($this->getSteps() as $step) {
if ($step->getAllDestUser()->contains($user)) {
return true;
}
}
return false;
}
public function isUserSubscribedToFinal(User $user): bool
{
return $this->subscriberToFinal->contains($user);
@@ -420,7 +467,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
}
/**
* Method use by marking store.
* Method used by marking store.
*
* @return $this
*/

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity\Workflow;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity()]
#[ORM\Table(name: 'chill_main_workflow_entity_attachment')]
#[ORM\UniqueConstraint(name: 'unique_generic_doc_by_workflow', columns: ['relatedGenericDocKey', 'relatedGenericDocIdentifiers', 'entityworkflow_id'])]
class EntityWorkflowAttachment implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
public function __construct(
#[ORM\Column(name: 'relatedGenericDocKey', type: Types::STRING, length: 255, nullable: false)]
private string $relatedGenericDocKey,
#[ORM\Column(name: 'relatedGenericDocIdentifiers', type: Types::JSON, nullable: false, options: ['jsonb' => true])]
private array $relatedGenericDocIdentifiers,
#[ORM\ManyToOne(targetEntity: EntityWorkflow::class, inversedBy: 'attachments')]
#[ORM\JoinColumn(nullable: false, name: 'entityworkflow_id')]
private EntityWorkflow $entityWorkflow,
/**
* Stored object related to the generic doc.
*
* This is a story to keep track more easily to stored object
*/
#[ORM\ManyToOne(targetEntity: StoredObject::class)]
#[ORM\JoinColumn(nullable: false, name: 'storedobject_id')]
private StoredObject $proxyStoredObject,
) {
$this->entityWorkflow->addAttachment($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getEntityWorkflow(): EntityWorkflow
{
return $this->entityWorkflow;
}
public function getRelatedGenericDocIdentifiers(): array
{
return $this->relatedGenericDocIdentifiers;
}
public function getRelatedGenericDocKey(): string
{
return $this->relatedGenericDocKey;
}
public function getProxyStoredObject(): StoredObject
{
return $this->proxyStoredObject;
}
}

View File

@@ -17,6 +17,11 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
/**
* Contains comment for entity workflow.
*
* **NOTE**: for now, this class is not in used. Comments are, for now, stored in the EntityWorkflowStep.
*/
#[ORM\Entity]
#[ORM\Table('chill_main_workflow_entity_comment')]
class EntityWorkflowComment implements TrackCreationInterface, TrackUpdateInterface

View File

@@ -16,9 +16,18 @@ use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* A step for each EntityWorkflow.
*
* The step contains the history of position. The current one is the one which transitionAt or transitionAfter is NULL.
*
* The comments field is populated by the comment of the one who apply the transition, it means that the comment for the
* "next" step is stored in the EntityWorkflowStep in the previous step.
*
* DestUsers are the one added at the transition. DestUserByAccessKey are the users who obtained permission after having
* clicked on a link to get access (email notification to groups).
*/
#[ORM\Entity]
#[ORM\Table('chill_main_workflow_entity_step')]
class EntityWorkflowStep
@@ -80,6 +89,11 @@ class EntityWorkflowStep
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
/**
* If this is the final step.
*
* This property is filled by a listener.
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])]
private bool $isFinal = false;
@@ -254,6 +268,11 @@ class EntityWorkflowStep
return $this->ccUser;
}
/**
* This is the comment from the one who apply the transition.
*
* It means that it must be saved when the user apply a transition.
*/
public function getComment(): string
{
return $this->comment;
@@ -346,6 +365,9 @@ class EntityWorkflowStep
return $this->transitionByEmail;
}
/**
* @return bool true if this is the end of the EntityWorkflow
*/
public function isFinal(): bool
{
return $this->isFinal;
@@ -367,6 +389,9 @@ class EntityWorkflowStep
return false;
}
/**
* @return bool if the EntityWorkflowStep is waiting for a transition, and is not the final step
*/
public function isWaitingForTransition(): bool
{
if (null !== $this->transitionAfter) {
@@ -506,26 +531,6 @@ class EntityWorkflowStep
return $this->holdsOnStep;
}
#[Assert\Callback]
public function validateOnCreation(ExecutionContextInterface $context, mixed $payload): void
{
return;
if ($this->isFinalizeAfter()) {
if (0 !== \count($this->getDestUser())) {
$context->buildViolation('workflow.No dest users when the workflow is finalized')
->atPath('finalizeAfter')
->addViolation();
}
} else {
if (0 === \count($this->getDestUser())) {
$context->buildViolation('workflow.The next step must count at least one dest')
->atPath('finalizeAfter')
->addViolation();
}
}
}
public function addOnHold(EntityWorkflowStepHold $onHold): self
{
if (!$this->holdsOnStep->contains($onHold)) {

View File

@@ -53,6 +53,7 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
public function __construct(
#[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class, inversedBy: 'signatures')]
#[ORM\JoinColumn(nullable: false)]
private EntityWorkflowStep $step,
User|Person $signer,
) {

View File

@@ -29,6 +29,7 @@ class NewsItemType extends AbstractType
])
->add('content', ChillTextareaType::class, [
'required' => false,
'empty_data' => '',
])
->add(
'startDate',

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Symfony\Contracts\Translation\TranslatableInterface;
interface NotificationHandlerInterface
{
@@ -29,4 +30,13 @@ interface NotificationHandlerInterface
* Return true if the handler supports the handling for this notification.
*/
public function supports(Notification $notification, array $options = []): bool;
public function getTitle(Notification $notification, array $options = []): TranslatableInterface;
/*
* return list<Person>
*/
public function getAssociatedPersons(Notification $notification, array $options = []): array;
public function getRelatedEntity(Notification $notification): ?object;
}

View File

@@ -13,11 +13,10 @@ namespace Chill\MainBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
use Doctrine\ORM\EntityManagerInterface;
final readonly class NotificationHandlerManager
{
public function __construct(private iterable $handlers, private EntityManagerInterface $em) {}
public function __construct(private iterable $handlers) {}
/**
* @throw NotificationHandlerNotFound if handler is not found

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\MainBundle\Repository\Workflow;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
/**
* @implements ObjectRepository<EntityWorkflowAttachment>
*/
class EntityWorkflowAttachmentRepository implements ObjectRepository
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $registry)
{
$this->repository = $registry->getRepository(EntityWorkflowAttachment::class);
}
public function find($id): ?EntityWorkflowAttachment
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
}
/**
* @return array<EntityWorkflowAttachment>
*/
public function findByStoredObject(StoredObject $storedObject): array
{
$qb = $this->repository->createQueryBuilder('a');
$qb->where('a.proxyStoredObject = :storedObject')->setParameter('storedObject', $storedObject);
return $qb->getQuery()->getResult();
}
public function getClassName()
{
return EntityWorkflowAttachment::class;
}
}

View File

@@ -480,7 +480,7 @@ div.workflow {
section.step {
border: 1px solid $chill-l-gray;
padding: 1em 2em;
div.flex-table {
> div.flex-table {
margin: 1.5em -2em;
}
}

View File

@@ -10,6 +10,19 @@ div.notification {
margin-right: 0.3em;
}
}
h4.notification-subtitle {
margin: 0.5rem 0;
}
ul.notification-related-entities {
margin: 0.5rem 0;
list-style: none;
padding: 0;
& > li {
display: inline-block;
}
}
div.read {
h2.notification-title,
h6.notification-title {
@@ -52,7 +65,7 @@ div.notification-show {
li {
span.item-key {
display: inline-block;
width: 3em;
padding: 0.1em 0.3rem;
}
}
}
@@ -97,4 +110,4 @@ span.counter {
padding: 0 0.4rem;
border-radius: 50%;
}
}
}

View File

@@ -83,6 +83,10 @@ export const makeFetch = <Input, Output>(
opts = Object.assign(opts, options);
}
return fetch(url, opts).then((response) => {
if (response.status === 204) {
return Promise.resolve();
}
if (response.ok) {
return response.json();
}
@@ -173,18 +177,26 @@ function _fetchAction<T>(
throw new Error("other network error");
})
.catch((reason: any) => {
console.error(reason);
throw new Error(reason);
});
.catch(
(
reason:
| NotFoundExceptionInterface
| ServerExceptionInterface
| ValidationExceptionInterface
| TransportExceptionInterface,
) => {
console.error(reason);
throw reason;
},
);
}
export const fetchResults = async <T>(
uri: string,
params?: FetchParams,
): Promise<T[]> => {
let promises: Promise<T[]>[] = [],
page = 1;
const promises: Promise<T[]>[] = [];
let page = 1;
const firstData: PaginationResponse<T> = (await _fetchAction(
page,
uri,
@@ -229,6 +241,7 @@ const ValidationException = (
return error;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const AccessException = (response: Response): AccessExceptionInterface => {
const error = {} as AccessExceptionInterface;
error.name = "AccessException";
@@ -237,6 +250,7 @@ const AccessException = (response: Response): AccessExceptionInterface => {
return error;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const NotFoundException = (response: Response): NotFoundExceptionInterface => {
const error = {} as NotFoundExceptionInterface;
error.name = "NotFoundException";
@@ -257,6 +271,7 @@ const ServerException = (
};
const ConflictHttpException = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
response: Response,
): ConflictHttpExceptionInterface => {
const error = {} as ConflictHttpExceptionInterface;

View File

@@ -0,0 +1,22 @@
import { WorkflowAttachment } from "ChillMainAssets/types";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export const find_attachments_by_workflow = async (
workflowId: number,
): Promise<WorkflowAttachment[]> =>
makeFetch("GET", `/api/1.0/main/workflow/${workflowId}/attachment`);
export const create_attachment = async (
workflowId: number,
genericDoc: GenericDocForAccompanyingPeriod,
): Promise<WorkflowAttachment> =>
makeFetch("POST", `/api/1.0/main/workflow/${workflowId}/attachment`, {
relatedGenericDocKey: genericDoc.key,
relatedGenericDocIdentifiers: genericDoc.identifiers,
});
export const delete_attachment = async (
attachment: WorkflowAttachment,
): Promise<void> =>
makeFetch("DELETE", `/api/1.0/main/workflow/attachment/${attachment.id}`);

View File

@@ -1,3 +1,5 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
export interface DateTime {
datetime: string;
datetime8601: string;
@@ -190,3 +192,16 @@ export interface WorkflowAvailable {
name: string;
text: string;
}
export interface WorkflowAttachment {
id: number;
relatedGenericDocKey: string;
relatedGenericDocIdentifiers: object;
createdAt: DateTime | null;
createdBy: User | null;
updatedAt: DateTime | null;
updatedBy: User | null;
genericDoc: null | GenericDoc;
}
export type PrivateCommentEmbeddable = any;

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import type { WorkflowAttachment } from "ChillMainAssets/types";
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
import { GenericDoc } from "ChillDocStoreAssets/types";
interface AppConfig {
workflowId: number;
accompanyingPeriodId: number;
attachments: WorkflowAttachment[];
}
const emit = defineEmits<{
(
e: "pickGenericDoc",
payload: { genericDoc: GenericDocForAccompanyingPeriod },
): void;
(e: "removeAttachment", payload: { attachment: WorkflowAttachment }): void;
}>();
type PickGenericModalType = InstanceType<typeof PickGenericDocModal>;
const pickDocModal = useTemplateRef<PickGenericModalType>("pickDocModal");
const props = defineProps<AppConfig>();
const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
() =>
props.attachments
.map((a: WorkflowAttachment) => a.genericDoc)
.filter(
(g: GenericDoc | null) => g !== null,
) as GenericDocForAccompanyingPeriod[],
);
const openModal = function () {
pickDocModal.value?.openModal();
};
const onPickGenericDoc = ({
genericDoc,
}: {
genericDoc: GenericDocForAccompanyingPeriod;
}) => {
emit("pickGenericDoc", { genericDoc });
};
</script>
<template>
<pick-generic-doc-modal
:accompanying-period-id="props.accompanyingPeriodId"
:to-remove="attachedGenericDoc"
ref="pickDocModal"
@pickGenericDoc="onPickGenericDoc"
></pick-generic-doc-modal>
<attachment-list
:attachments="props.attachments"
@removeAttachment="(payload) => emit('removeAttachment', payload)"
></attachment-list>
<ul class="record_actions">
<li>
<button type="button" class="btn btn-create" @click="openModal">
Ajouter une pièce jointe
</button>
</li>
</ul>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { WorkflowAttachment } from "ChillMainAssets/types";
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface AttachmentListProps {
attachments: WorkflowAttachment[];
}
const emit = defineEmits<{
// eslint-disable-next-line @typescript-eslint/prefer-function-type
(e: "removeAttachment", payload: { attachment: WorkflowAttachment }): void;
}>();
const props = defineProps<AttachmentListProps>();
</script>
<template>
<p
v-if="props.attachments.length === 0"
class="chill-no-data-statement text-center"
>
Aucune pièce jointe
</p>
<!-- TODO translate -->
<div else class="flex-table">
<div v-for="a in props.attachments" :key="a.id" class="item-bloc">
<generic-doc-item-box
v-if="a.genericDoc !== null"
:generic-doc="a.genericDoc"
></generic-doc-item-box>
<div class="item-row separator">
<ul class="record_actions">
<li v-if="a.genericDoc?.storedObject !== null">
<document-action-buttons-group
:stored-object="a.genericDoc.storedObject"
></document-action-buttons-group>
</li>
<li>
<button
type="button"
class="btn btn-delete"
@click="emit('removeAttachment', { attachment: a })"
></button>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
interface GenericDocItemBoxProps {
genericDoc: GenericDocForAccompanyingPeriod;
}
const props = defineProps<GenericDocItemBoxProps>();
</script>
<template>
<div
v-if="'html' in props.genericDoc.metadata"
v-html="props.genericDoc.metadata.html"
></div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import {
GenericDoc,
GenericDocForAccompanyingPeriod,
} from "ChillDocStoreAssets/types/generic_doc";
import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue";
import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api";
import { computed, onMounted, ref } from "vue";
interface PickGenericDocProps {
accompanyingPeriodId: number;
pickedList: GenericDocForAccompanyingPeriod[];
toRemove: GenericDocForAccompanyingPeriod[];
}
const props = defineProps<PickGenericDocProps>();
const emit = defineEmits<{
(
e: "pickGenericDoc",
payload: { genericDoc: GenericDocForAccompanyingPeriod },
): void;
(
e: "removeGenericDoc",
payload: { genericDoc: GenericDocForAccompanyingPeriod },
): void;
}>();
const genericDocs = ref<GenericDocForAccompanyingPeriod[]>([]);
const loaded = ref(false);
const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean =>
props.pickedList.findIndex(
(element: GenericDocForAccompanyingPeriod) =>
element.uniqueKey === genericDoc.uniqueKey,
) !== -1;
onMounted(async () => {
genericDocs.value = await fetch_generic_docs_by_accompanying_period(
props.accompanyingPeriodId,
);
loaded.value = true;
});
const textFilter = ref<string>("");
const dateFromFilter = ref<string | null>(null);
const dateToFilter = ref<string | null>(null);
const placesFilter = ref<string[]>([]);
const availablePlaces = computed<string[]>(() => {
const places = new Set<string>(
genericDocs.value.map((genericDoc: GenericDoc) => genericDoc.key),
);
return Array.from(places).sort((a, b) => (a < b ? -1 : a === b ? 0 : 1));
});
const placeTrans = (str: string): string => {
switch (str) {
case "accompanying_course_document":
return "Documents du parcours";
case "person_document":
return "Documents de l'usager";
case "accompanying_period_calendar_document":
return "Document des rendez-vous des parcours";
case "accompanying_period_activity_document":
return "Document des échanges des parcours";
case "accompanying_period_work_evaluation_document":
return "Document des actions d'accompagnement";
default:
return str;
}
};
const filteredDocuments = computed<GenericDocForAccompanyingPeriod[]>(() => {
if (false === loaded.value) {
return [];
}
return genericDocs.value
.filter(
(genericDoc: GenericDocForAccompanyingPeriod) =>
!props.toRemove
.map((g: GenericDocForAccompanyingPeriod) => g.uniqueKey)
.includes(genericDoc.uniqueKey),
)
.filter((genericDoc: GenericDocForAccompanyingPeriod) => {
if (textFilter.value === "") {
return true;
}
const needles = textFilter.value
.trim()
.split(" ")
.map((str: string) => str.trim().toLowerCase())
.filter((str: string) => str.length > 0);
const title: string =
"title" in genericDoc.metadata
? (genericDoc.metadata.title as string)
: "";
if (title === "") {
return false;
}
return needles.every((n: string) =>
title.toLowerCase().includes(n),
);
})
.filter((genericDoc: GenericDocForAccompanyingPeriod) => {
if (placesFilter.value.length === 0) {
return true;
}
return placesFilter.value.includes(genericDoc.key);
})
.filter((genericDoc: GenericDocForAccompanyingPeriod) => {
if (dateToFilter.value === null) {
return true;
}
return genericDoc.doc_date.datetime8601 < dateToFilter.value;
})
.filter((genericDoc: GenericDocForAccompanyingPeriod) => {
if (dateFromFilter.value === null) {
return true;
}
return genericDoc.doc_date.datetime8601 > dateFromFilter.value;
});
});
</script>
<template>
<div v-if="loaded">
<div>
<form name="f" method="get">
<div class="accordion my-3" id="filterOrderAccordion">
<h2 class="accordion-header" id="filterOrderHeading">
<button
class="accordion-button"
type="button"
data-bs-toggle="collapse"
data-bs-target="#filterOrderCollapse"
aria-expanded="true"
aria-controls="filterOrderCollapse"
>
<strong
><i class="fa fa-fw fa-filter"></i>Filtrer la
liste</strong
>
</button>
</h2>
<div
class="accordion-collapse collapse"
id="filterOrderCollapse"
aria-labelledby="filterOrderHeading"
data-bs-parent="#filterOrderAccordion"
style=""
>
<div
class="accordion-body chill_filter_order container-xxl p-5 py-2"
>
<div class="row my-2">
<div class="col-sm-12">
<div class="input-group">
<input
v-model="textFilter"
type="search"
id="f_q"
name="f[q]"
placeholder="Chercher dans la liste"
class="form-control"
/>
<button
type="submit"
class="btn btn-misc"
>
<i class="fa fa-search"></i>
</button>
</div>
</div>
</div>
<div class="row my-2">
<legend
class="col-form-label col-sm-4 required"
>
Date du document
</legend>
<div class="col-sm-8 pt-1">
<div class="input-group">
<span class="input-group-text">Du</span>
<input
v-model="dateFromFilter"
type="date"
id="f_dateRanges_dateRange_from"
name="f[dateRanges][dateRange][from]"
class="form-control"
/>
<span class="input-group-text">Au</span>
<input
v-model="dateToFilter"
type="date"
id="f_dateRanges_dateRange_to"
name="f[dateRanges][dateRange][to]"
class="form-control"
/>
</div>
</div>
</div>
<div class="row my-2">
<div class="col-sm-4 col-form-label">
Filtrer par
</div>
<div class="col-sm-8 pt-2">
<div
class="form-check"
v-for="p in availablePlaces"
:key="p"
>
<input
type="checkbox"
v-model="placesFilter"
name="f[checkboxes][places][]"
class="form-check-input"
:value="p"
/>
<label class="form-check-label">{{
placeTrans(p)
}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<div v-if="genericDocs.length > 0" class="flex-table chill-task-list">
<pick-generic-doc-item
v-for="g in filteredDocuments"
:key="g.uniqueKey"
:accompanying-period-id="accompanyingPeriodId"
:genericDoc="g"
:is-picked="isPicked(g)"
@pickGenericDoc="(payload) => emit('pickGenericDoc', payload)"
@removeGenericDoc="
(payload) => emit('removeGenericDoc', payload)
"
></pick-generic-doc-item>
</div>
<div v-else class="text-center chill-no-data-statement">
Aucun document dans ce parcours
</div>
</div>
<div v-else>
<div class="d-flex align-items-center">
<strong>Chargement</strong>
<div
class="spinner-border ms-auto"
role="status"
aria-hidden="true"
></div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface PickGenericDocItemProps {
genericDoc: GenericDocForAccompanyingPeriod;
accompanyingPeriodId: number;
isPicked: boolean;
}
const props = defineProps<PickGenericDocItemProps>();
const emit = defineEmits<{
(
e: "pickGenericDoc",
payload: { genericDoc: GenericDocForAccompanyingPeriod },
): void;
(
e: "removeGenericDoc",
payload: { genericDoc: GenericDocForAccompanyingPeriod },
): void;
}>();
const clickOnAddButton = () => {
emit("pickGenericDoc", { genericDoc: props.genericDoc });
};
</script>
<template>
<div class="item-bloc" :class="{ isPicked: isPicked }">
<generic-doc-item-box
:generic-doc="props.genericDoc"
></generic-doc-item-box>
<div class="item-row separator">
<ul class="record_actions">
<li v-if="props.genericDoc.storedObject !== null">
<document-action-buttons-group
:stored-object="props.genericDoc.storedObject"
></document-action-buttons-group>
</li>
<li>
<button
v-if="!isPicked"
type="button"
class="btn btn-chill-green text-white"
@click="clickOnAddButton"
>
<i class="bi bi-cart-plus"></i>
</button>
<button
v-else
type="button"
class="btn btn-chill-red text-white"
@click="
emit('removeGenericDoc', {
genericDoc: props.genericDoc,
})
"
>
<i class="bi bi-cart-dash"></i>
</button>
</li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
.item-bloc {
&.isPicked {
background: linear-gradient(
180deg,
rgba(25, 135, 84, 1) 0px,
rgba(25, 135, 84, 0) 9px
),
linear-gradient(
270deg,
rgba(25, 135, 84, 1) 0px,
rgba(25, 135, 84, 0) 9px
),
linear-gradient(
0deg,
rgba(25, 135, 84, 1) 0px,
rgba(25, 135, 84, 0) 9px
),
linear-gradient(
90deg,
rgba(25, 135, 84, 1) 0px,
rgba(25, 135, 84, 0) 9px
);
}
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, ref, useTemplateRef } from "vue";
import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
interface PickGenericDocModalProps {
accompanyingPeriodId: number;
toRemove: GenericDocForAccompanyingPeriod[];
}
type PickGenericDocType = InstanceType<typeof PickGenericDoc>;
const props = defineProps<PickGenericDocModalProps>();
const emit = defineEmits<{
// eslint-disable-next-line @typescript-eslint/prefer-function-type
(
e: "pickGenericDoc",
payload: { genericDoc: GenericDocForAccompanyingPeriod },
): void;
}>();
const picker = useTemplateRef<PickGenericDocType>("picker");
const modalOpened = ref<boolean>(false);
const pickeds = ref<GenericDocForAccompanyingPeriod[]>([]);
const modalClasses = { "modal-xl": true, "modal-dialog-scrollable": true };
const numberOfPicked = computed<number>(() => pickeds.value.length);
const onPicked = ({
genericDoc,
}: {
genericDoc: GenericDocForAccompanyingPeriod;
}) => {
pickeds.value.push(genericDoc);
};
const onRemove = ({
genericDoc,
}: {
genericDoc: GenericDocForAccompanyingPeriod;
}) => {
const index = pickeds.value.findIndex(
(item) => item.uniqueKey === genericDoc.uniqueKey,
);
if (index === -1) {
throw new Error("Remove generic doc that doesn't exist");
}
pickeds.value.splice(index, 1);
};
const onConfirm = () => {
for (let genericDoc of pickeds.value) {
emit("pickGenericDoc", { genericDoc });
}
pickeds.value = [];
closeModal();
};
const closeModal = function () {
modalOpened.value = false;
};
const openModal = function () {
modalOpened.value = true;
};
defineExpose({ openModal, closeModal });
</script>
<template>
<modal
v-if="modalOpened"
@close="closeModal"
:modal-dialog-class="modalClasses"
>
<template v-slot:header>
<h2 class="modal-title">Ajouter une pièce jointe</h2>
</template>
<template v-slot:body>
<pick-generic-doc
:accompanying-period-id="props.accompanyingPeriodId"
:to-remove="props.toRemove"
:picked-list="pickeds"
ref="picker"
@pickGenericDoc="onPicked"
@removeGenericDoc="onRemove"
></pick-generic-doc>
</template>
<template v-slot:footer>
<ul v-if="numberOfPicked > 0" class="record_actions">
<li>
<button
type="button"
class="btn btn-chill-green text-white"
@click="onConfirm"
>
<template v-if="numberOfPicked > 1">
<i class="fa fa-plus"></i> Ajouter
{{ numberOfPicked }} pièces jointes
</template>
<template v-else>
<i class="fa fa-plus"></i> Ajouter une pièce jointe
</template>
</button>
</li>
</ul>
</template>
</modal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,109 @@
import { createApp } from "vue";
import App from "./App.vue";
import { _createI18n } from "../_js/i18n";
import { WorkflowAttachment } from "ChillMainAssets/types";
import {
create_attachment,
delete_attachment,
find_attachments_by_workflow,
} from "ChillMainAssets/lib/workflow/attachments";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import ToastPlugin from "vue-toast-notification";
import "vue-toast-notification/dist/theme-bootstrap.css";
window.addEventListener("DOMContentLoaded", () => {
const attachments = document.querySelectorAll<HTMLDivElement>(
'div[data-app="workflow_attachments"]',
);
attachments.forEach(async (el) => {
const workflowId = parseInt(el.dataset.entityWorkflowId || "");
const accompanyingPeriodId = parseInt(
el.dataset.relatedAccompanyingPeriodId || "",
);
const attachments = await find_attachments_by_workflow(workflowId);
const app = createApp({
template:
'<app :workflowId="workflowId" :accompanyingPeriodId="accompanyingPeriodId" :attachments="attachments" @pickGenericDoc="onPickGenericDoc" @removeAttachment="onRemoveAttachment"></app>',
components: { App },
data: function () {
return { workflowId, accompanyingPeriodId, attachments };
},
methods: {
onRemoveAttachment: async function ({
attachment,
}: {
attachment: WorkflowAttachment;
}): Promise<void> {
const index = this.$data.attachments.findIndex(
(el: WorkflowAttachment) => el.id === attachment.id,
);
if (-1 === index) {
console.warn(
"this attachment is not associated with the workflow",
attachment,
);
this.$toast.error(
"This attachment is not associated with the workflow",
);
return;
}
try {
await delete_attachment(attachment);
} catch (error) {
console.error(error);
this.$toast.error("Error while removing element");
throw error;
}
this.$data.attachments.splice(index, 1);
this.$toast.success("Pièce jointe supprimée");
},
onPickGenericDoc: async function ({
genericDoc,
}: {
genericDoc: GenericDocForAccompanyingPeriod;
}): Promise<void> {
console.log("picked generic doc", genericDoc);
// prevent to create double attachment:
if (
-1 !==
this.$data.attachments.findIndex(
(el: WorkflowAttachment) =>
el.genericDoc?.key === genericDoc.key &&
JSON.stringify(el.genericDoc?.identifiers) ==
JSON.stringify(genericDoc.identifiers),
)
) {
console.warn(
"this document is already attached to the workflow",
genericDoc,
);
this.$toast.error(
"Ce document est déjà attaché au workflow",
);
return;
}
try {
const attachment = await create_attachment(
workflowId,
genericDoc,
);
this.$data.attachments.push(attachment);
} catch (error) {
console.error(error);
throw error;
}
},
},
});
const i18n = _createI18n({});
app.use(i18n);
app.use(ToastPlugin);
app.mount(el);
});
});

View File

@@ -11,21 +11,59 @@
</h2>
</div>
{% endmacro %}
{% macro insert_onthefly(type, entity, parent = null) %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
action: 'show', displayBadge: true,
targetEntity: { name: type, id: entity.id },
buttonText: entity|chill_entity_render_string,
isDead: entity.deathdate is defined and entity.deathdate is not null,
parent: parent
} %}
{% endmacro %}
{% macro relatedEntity(c) %}
{% if c.data is defined %}
{% set notification = c.data.notification %}
{% set handler = c.data.handler %}
<div class="item-row">
<h4 class="notification-subtitle">
<span>{{ handler.getTitle(notification)|trans }}</span>
</h4>
</div>
{% set associateds = handler.getAssociatedPersons(notification) %}
{% if associateds|length > 0 %}
<div class="item-row">
<ul class="notification-related-entities">
{% for ap in associateds %}
<li>
{% if ap.person is defined %}
{{ _self.insert_onthefly('person', ap.person) }}
{% else %}
{{ _self.insert_onthefly('person', ap) }}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endif %}
{% endmacro %}
{% macro header(c) %}
<div class="item-row notification-header mt-2">
<div class="item-col">
<ul class="small_in_title">
{% if c.step is not defined or c.step == 'inbox' %}
<li class="notification-from">
<span class="item-key">
<abbr title="{{ 'notification.received_from' | trans }}">
{{ "notification.from" | trans }} :
</abbr>
</span>
<span class="item-key">
<abbr title="{{ 'notification.received_from' | trans }}">
{{ "notification.from" | trans }} :
</abbr>
</span>
{% if not c.notification.isSystem %}
<span class="badge-user">
{{ c.notification.sender | chill_entity_render_string({'at_date': c.notification.date}) }}
</span>
{{ c.notification.sender | chill_entity_render_string({'at_date': c.notification.date}) }}
</span>
{% else %}
<span class="badge-user system">{{ "notification.is_system" | trans }}</span>
{% endif %}
@@ -185,6 +223,7 @@
>
{{ _self.title(_context) }}
</button>
{{ _self.relatedEntity(_context) }}
{{ _self.header(_context) }}
</div>
<div
@@ -198,6 +237,7 @@
{{ _self.actions(_context) }}
{% else %}
{{ _self.title(_context) }}
{{ _self.relatedEntity(_context) }}
{{ _self.header(_context) }}
{{ _self.content(_context) }}
{{ _self.actions(_context) }}

View File

@@ -23,7 +23,9 @@
{% include '@ChillMain/Notification/_list_item.html.twig' with {
'data': {
'template': handler.getTemplate(notification),
'template_data': handler.getTemplateData(notification)
'template_data': handler.getTemplateData(notification),
'handler': handler,
'notification': notification
},
'action_button': false,
'full_content': true,

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