Compare commits

...

68 Commits

Author SHA1 Message Date
Boris Waaub
d14c88deae Ajout de la génération du rapport vue-tsc dans le job de tests et correction de l'ordre des commandes 2026-02-04 16:34:37 +01:00
Boris Waaub
b3667b797f Mise à jour du cache de build pour inclure le répertoire var et ajout de la commande de nettoyage du cache dans le job vue-tsc 2026-02-04 16:25:36 +01:00
Boris Waaub
8b129417df Ajout de dépendances au job de build pour les étapes de tests et mise à jour du cache pour inclure les traductions 2026-02-04 15:56:16 +01:00
Boris Waaub
d762e999a2 Correction de la définition du modèle dans CommentEditor.vue et mise à jour de la configuration du pipeline CI pour inclure le rapport vue-tsc 2026-02-04 15:39:06 +01:00
Boris Waaub
b8f3b64f6d Ajout de l'affichage du rapport vue-tsc dans le pipeline de lint 2026-02-04 15:20:53 +01:00
Boris Waaub
203a0d2264 FIX: Rector 2026-02-04 15:12:41 +01:00
Boris Waaub
c66105fb67 FIX: es-lint 2026-02-04 15:03:58 +01:00
Boris Waaub
701dd44169 Nettoyage des imports inutilisés dans PersonsSelectorComponent.vue et correction de la gestion des valeurs indéfinies dans PreviousTicketsComponent.vue 2026-02-04 14:51:38 +01:00
Boris Waaub
915f4292da Nettoyage des imports inutilisés dans AddresseeSelectorComponent.vue et ajout d'une condition v-if pour le rendu du CommentEditor dans CommentEditorComponent.vue 2026-02-04 14:50:13 +01:00
Boris Waaub
23369197e7 Correction des types et amélioration de la gestion des erreurs dans les composants ThirdParty et ThirdPartyEdit 2026-02-04 14:48:33 +01:00
Boris Waaub
454aad7ed1 Nettoyage des imports inutilisés dans plusieurs composants Vue pour améliorer la lisibilité du code et gestion des valeurs nulles dans PersonRenderBox.vue. 2026-02-04 14:36:07 +01:00
Boris Waaub
6e3c3c68d2 Nettoyage des imports dans plusieurs composants Vue pour améliorer la lisibilité du code. 2026-02-04 12:06:43 +01:00
Boris Waaub
22088022f6 Ajout de conditions v-if pour afficher les dates de début et de fin uniquement si elles sont présentes dans AccompanyingPeriodWorkEvaluationItem.vue 2026-02-04 12:00:05 +01:00
Boris Waaub
d5811cc4af Correction de la définition des événements et amélioration de la condition d'affichage dans GenerateButton.vue 2026-02-04 11:58:39 +01:00
Boris Waaub
79e1300acf Nettoyage des imports dans PickEntity.vue : suppression des imports inutilisés pour améliorer la lisibilité du code. 2026-02-04 11:56:09 +01:00
Boris Waaub
3bc54f45b7 Ajout de la gestion des liens davLink et davLinkExpiration en tant que propriétés optionnelles et mise à jour de la structure du template dans App.vue pour intégrer WaitingScreen. 2026-02-04 11:55:38 +01:00
Boris Waaub
1bc370fc88 Ajout de la condition v-if pour afficher le masque modal uniquement lorsque props.show est vrai et amélioration des types dans les définitions de slots. 2026-02-04 11:52:26 +01:00
Boris Waaub
5dd031f0b4 Refactor CommentEditor and PickWorkflow components: update template structure, enhance default values, and improve type definitions. 2026-02-04 11:50:12 +01:00
Boris Waaub
9208be903c Fix null reference errors in AddressDetails components by adding optional chaining to addressReference properties. 2026-02-04 11:50:00 +01:00
Boris Waaub
93929a420e Merge branch 'ticket-app-master' into 2101-fix-vue-tsc-errors 2026-02-03 16:53:51 +01:00
Boris Waaub
ad2e0692a3 Merge branch 'master' into ticket-app-master 2026-02-03 16:53:24 +01:00
Boris Waaub
0471337888 Refactor OnTheFly and PersonEdit components: remove unused imports, enhance type safety, and add unique keys for v-for loops. 2026-02-03 16:51:35 +01:00
Boris Waaub
1c30bcd906 Refactor HistoryButton and HistoryButtonModal components: streamline template structure, enhance type handling, and improve event emission for version restoration. 2026-02-03 15:33:19 +01:00
Boris Waaub
e97c740a75 Refactor Vue components: update DropFile and DropFileWidget templates for clarity; enhance type definitions in FileIcon and types/index.ts; fix event emit structure in DropFileModal. 2026-02-03 15:01:09 +01:00
Boris Waaub
307200c5f4 Améliore les types et la gestion des propriétés dans les composants Vue :
- Modifie les types des propriétés dans DownloadButton.vue pour gérer les versions d'objet.
- Corrige les types de davLink et davLinkExpiration dans DocumentActionButtonsGroup.vue.
- Réorganise l'interface DesktopEditButtonConfig pour une meilleure clarté.
- Ajuste l'extension de fichier dans DownloadButton.vue pour éviter les erreurs de type.
2026-02-03 14:42:35 +01:00
Boris Waaub
6852dcd0f0 Refactor Vue components: streamline Answer.vue with setup syntax and remove redundant i18n object; update CalendarActive.vue template for clarity. 2026-02-03 14:24:46 +01:00
Boris Waaub
c19e6fea40 TS2322: Type '(_: any) => void' is not assignable to type '() => any'. 2026-02-03 12:26:40 +01:00
Boris Waaub
4eda457ef2 Type 'string | null' is not assignable to type 'PropertyKey | undefined'. 2026-02-03 12:26:00 +01:00
Boris Waaub
b342483c2e error TS2345: Argument of type 'string' is not assignable to parameter of type '"pending" | "accepted" | "declined" | "tentative"'. 2026-02-03 12:16:15 +01:00
Boris Waaub
f1d194d523 Améliore la configuration CI : simplifie les règles de déclenchement et ajoute un rapport de vérification TypeScript 2026-02-03 11:24:56 +01:00
3402e4863f Release v4.12.1 2026-02-01 18:52:21 +01:00
1f0974ea68 Merge branch 'cs/update-cs-fixer-3.93' into 'master'
Update PHP-CS-Fixer to version 3.93.0 in composer dependencies

See merge request Chill-Projet/chill-bundles!955
2026-01-29 12:27:56 +00:00
9997fb287a Update PHP-CS-Fixer to version 3.93.0 in composer dependencies 2026-01-29 12:27:56 +00:00
f9a9de1148 Merge branch '496-allow-remove-double-refid-ban-address-importer' into 'master'
Adding the option to deal with duplicate addresses in the BAN importer

Closes #496

See merge request Chill-Projet/chill-bundles!954
2026-01-27 10:26:57 +00:00
juminet
c34f720f94 Adding the option to deal with duplicate addresses in the BAN importer 2026-01-27 10:26:57 +00:00
e1b1f592fa Merge branch 'zimbra/use-delegated-admin' into 'master'
[Zimbra] Use admin delegated account for authenticating users against Zimbra

See merge request Chill-Projet/chill-bundles!952
2026-01-22 14:39:46 +00:00
8546f4dadc [Zimbra] Use admin delegated account for authenticating users against Zimbra 2026-01-22 14:39:46 +00:00
4028c020ee Release v4.12.0 2026-01-15 18:02:12 +01:00
0d4eef6a0c Merge branch '493-fix-stored-object-workflow-permission' into 'master'
Fix issues with permission for stored objects associated with workflows

Closes #493

See merge request Chill-Projet/chill-bundles!951
2026-01-15 16:54:37 +00:00
b6152d5356 Fix issues with permission for stored objects associated with workflows 2026-01-15 16:54:37 +00:00
8b708f8c73 fix CommentInput: replace deprecated value binding with model-value 2026-01-15 14:53:40 +01:00
8d5b200107 Restrict ux-translator version to 2.31.0 2026-01-15 14:44:05 +01:00
a9e9207d5a Update php-cs-fixer version 2026-01-15 13:41:00 +01:00
3915574ed4 phpstan error fix 2026-01-15 13:40:46 +01:00
f3217d22ef Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center 2026-01-15 13:25:54 +01:00
06c5affbe7 Increase delay for removing stale workflows from 90 to 180 days
- Updated `KEEP_INTERVAL` in `CancelStaleWorkflowCronJob` to `P180D`.
2026-01-15 10:08:40 +01:00
bf461a1211 Merge branch '473-display-bundles-version' into 'master'
Resolve "Afficher le numéro de version de Chill dans l'UX"

Closes #473

See merge request Chill-Projet/chill-bundles!947
2026-01-13 15:35:26 +00:00
3f0ad51114 Resolve "Afficher le numéro de version de Chill dans l'UX" 2026-01-13 15:35:26 +00:00
a4de8eaab3 Merge branch '489-fix-desactivation-date-goarls-results' into 'master'
Fix issue with goal/result deactivation date handling and improve formatting

Closes #489

See merge request Chill-Projet/chill-bundles!949
2026-01-13 15:32:08 +00:00
2feb137ac2 Fix issue with goal/result deactivation date handling and improve formatting 2026-01-13 15:32:07 +00:00
5ea74d118b Merge branch '490-fix-double-notification' into 'master'
Prevent notifications from being sent when the user signs a document he asked to himself

Closes #490

See merge request Chill-Projet/chill-bundles!950
2026-01-13 15:31:50 +00:00
8eb7a55ef5 Prevent notifications from being sent when the user signs a document he asked to himself 2026-01-13 15:31:49 +00:00
281887355f Fix calculation of budget balance 2026-01-12 10:34:30 +01:00
47b285b584 Fix export group by center for persons without a center in CenterAggregator.php 2025-12-30 13:01:56 +01:00
d79a1f5ed8 update eslint baseline 2025-12-22 15:39:56 +01:00
dbe4bed183 eslint fixes 2025-12-22 15:39:43 +01:00
7c9b4d02f6 Fix ordering of social actions
Actions with a closing date in the future should be considered as 'still open'.
2025-12-18 11:08:18 +01:00
3ff9bba4de Fix the condition to display concerned persons in calendar list items. 2025-12-18 10:24:24 +01:00
c0f9e953fb Update to v4.11.0 2025-12-17 16:56:35 +01:00
a49ea2b6b9 Fix translation syntax
Cannot start with %, wrap translation value in double quotes
2025-12-17 16:54:33 +01:00
a30232d3ce Merge branch '478-admin-list-filters' into 'master'
Resolve "Add filters to admin lists"

Closes #478

See merge request Chill-Projet/chill-bundles!941
2025-12-15 16:49:39 +00:00
aae55e6f8c Merge branch '466-fix-migrations' into 'master'
Fix migration to exclude null `user_id` in `activity_user` population

Closes #466

See merge request Chill-Projet/chill-bundles!943
2025-12-15 13:43:20 +00:00
c9513f2f6c Fix migration to exclude null user_id in activity_user population 2025-12-15 13:43:20 +00:00
11d7425883 php cs fixes 2025-12-15 10:48:20 +01:00
08897e0981 Fix count of total items for correct paginator display 2025-12-15 10:48:00 +01:00
98cbfed054 Add filtering methods to controllers 2025-12-15 10:48:00 +01:00
9af4d19744 Add repository methods for filtering 2025-12-15 10:48:00 +01:00
c1cf5a8bb2 Start implementation of filter within admin index pages 2025-12-15 10:48:00 +01:00
122 changed files with 3289 additions and 1779 deletions

9
.changes/v4.11.0.md Normal file
View File

@@ -0,0 +1,9 @@
## v4.11.0 - 2025-12-17
### Feature
* ([#478](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/478)) Add filtering to admin lists: social actions, social issues, goals, results, and evaluations
### Fixed
* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Fix migration query after previous fix
* Fix translation key/value
Cannot start with % and should be wrapped in "".

16
.changes/v4.12.0.md Normal file
View File

@@ -0,0 +1,16 @@
## v4.12.0 - 2026-01-15
### Feature
* ([#473](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/473)) Display version of chill bundles in application footer
* Increase the delay before removing stale workflow from 90 days to 180 days.
### Fixed
* ([#480](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/480)) Fix the condition to display concerned persons in calendar list items.
* ([#481](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/481)) Fix ordering of social actions: actions with a closing date in the future should be considered as 'still open'.
* ([#477](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/477)) Fix export group by center for persons without a center in CenterAggregator.php
* Fix the calculation of budget balance to only take into account resources and charges that are still actual
* ([#489](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/489)) Fix desactivation date for Goals and results
* ([#490](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/490)) Prevent sending a notification when the user signs the document himself
* ([#491](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/491)) Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center
* ([#492](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/492)) fix CommentInput: replace deprecated value binding with model-value
* ([#493](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/493)) fix issue with stored object permissions associated with workflows (as attachment, or through a related entity)
BC: the constructor's signature of `\Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter` has changed.

4
.changes/v4.12.1.md Normal file
View File

@@ -0,0 +1,4 @@
## v4.12.1 - 2026-02-01
### Fixed
* ([#496](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/496)) Add the option to deal with duplicate address in BAN adress importer

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
---
# Select what we should cache between builds
cache:
paths:
@@ -58,18 +57,17 @@ mirror_chill_zimbra_bundle:
rules:
# 1) Allow manual run from GitLab UI, whatever the branch
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "web"'
# 2) Auto-run on commits to master or 472-zimbra-connector
# but only if relevant files changed
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "472-zimbra-connector"'
changes:
- packages/ChillZimbraBundle/**/*
- .gitlab-ci.yml
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "472-zimbra-connector"'
changes:
- packages/ChillZimbraBundle/**/*
- .gitlab-ci.yml
# 3) Otherwise: never run
- when: never
- when: never
before_script:
- apk add --no-cache git git-subtree openssh
@@ -98,7 +96,6 @@ mirror_chill_zimbra_bundle:
build:
stage: Composer install
image: chill/base-image:8.3-edge
variables:
before_script:
- composer config -g cache-dir "$(pwd)/.cache"
script:
@@ -110,12 +107,15 @@ build:
expire_in: 1 day
paths:
- vendor/
- var/
code_style:
stage: Tests
image: chill/base-image:8.3-edge
script:
- php-cs-fixer fix --dry-run -v --show-progress=none
dependencies:
- build
cache:
paths:
- .cache/
@@ -133,6 +133,8 @@ phpstan_tests:
- bin/console cache:clear --env=dev
script:
- composer exec phpstan -- analyze --memory-limit=3G
dependencies:
- build
cache:
paths:
- .cache/
@@ -148,6 +150,8 @@ rector_tests:
- bin/console cache:clear --env=dev
script:
- composer exec rector -- process --dry-run
dependencies:
- build
cache:
paths:
- .cache/
@@ -166,10 +170,39 @@ lint:
script:
- yarn install --ignore-optional
- npx eslint-baseline "src/**/*.{js,ts,vue}"
dependencies:
- build
cache:
paths:
- node_modules/
artifacts:
expire_in: 1 day
paths:
- vendor/
vue_tsc:
stage: Tests
image: node:20-alpine
before_script:
- apk add --no-cache python3 make g++ py3-setuptools
- export PYTHON="$(which python3)"
- export PATH="./node_modules/.bin:$PATH"
script:
- yarn install --ignore-optional
- php bin/console cache:clear
- yarn build
- yarn vue-tsc --noEmit > vue-tsc-report.txt 2>&1 || true
- cat vue-tsc-report.txt
- grep -q "error" vue-tsc-report.txt && exit 2 || exit 0
dependencies:
- build
cache:
paths:
- node_modules/
artifacts:
expire_in: 1 day
paths:
- vue-tsc-report.txt
---
# psalm_tests:
# stage: Tests
# image: gitea.champs-libres.be/chill-project/chill-skeleton-basic/base-image:php82
@@ -195,6 +228,8 @@ unit_tests:
- php bin/console doctrine:fixtures:load -n --env=test
script:
- composer exec phpunit -- --colors=never --exclude-group dbIntensive,openstack-integration
dependencies:
- build
artifacts:
expire_in: 1 day
paths:
@@ -208,5 +243,5 @@ release:
script:
- echo "running release_job"
release:
tag_name: '$CI_COMMIT_TAG'
tag_name: "$CI_COMMIT_TAG"
description: "./.changes/$CI_COMMIT_TAG.md"

View File

@@ -6,6 +6,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.12.1 - 2026-02-01
### Fixed
* ([#496](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/496)) Add the option to deal with duplicate address in BAN adress importer
## v4.12.0 - 2026-01-15
### Feature
* ([#473](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/473)) Display version of chill bundles in application footer
* Increase the delay before removing stale workflow from 90 days to 180 days.
### Fixed
* ([#480](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/480)) Fix the condition to display concerned persons in calendar list items.
* ([#481](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/481)) Fix ordering of social actions: actions with a closing date in the future should be considered as 'still open'.
* ([#477](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/477)) Fix export group by center for persons without a center in CenterAggregator.php
* Fix the calculation of budget balance to only take into account resources and charges that are still actual
* ([#489](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/489)) Fix desactivation date for Goals and results
* ([#490](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/490)) Prevent sending a notification when the user signs the document himself
* ([#491](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/491)) Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center
* ([#492](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/492)) fix CommentInput: replace deprecated value binding with model-value
* ([#493](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/493)) fix issue with stored object permissions associated with workflows (as attachment, or through a related entity)
BC: the constructor's signature of `\Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter` has changed.
## v4.11.0 - 2025-12-17
### Feature
* ([#478](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/478)) Add filtering to admin lists: social actions, social issues, goals, results, and evaluations
### Fixed
* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Fix migration query after previous fix
* Fix translation key/value
Cannot start with % and should be wrapped in "".
## v4.10.1 - 2025-12-11
### Fixed
* Fix missing translation variable in NewLocation component

View File

@@ -21,6 +21,7 @@
"ext-openssl": "*",
"ext-redis": "*",
"ext-zlib": "*",
"composer-runtime-api": "*",
"champs-libres/wopi-bundle": "dev-symfony-v5@dev",
"champs-libres/wopi-lib": "dev-master@dev",
"doctrine/data-fixtures": "^1.8",
@@ -82,7 +83,7 @@
"symfony/templating": "^5.4",
"symfony/translation": "^5.4",
"symfony/twig-bundle": "^5.4",
"symfony/ux-translator": "^2.22",
"symfony/ux-translator": "2.31.0",
"symfony/validator": "^5.4",
"symfony/webpack-encore-bundle": "^1.11",
"symfony/workflow": "^5.4",
@@ -97,7 +98,7 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3",
"fakerphp/faker": "^1.13",
"friendsofphp/php-cs-fixer": "3.65.0",
"friendsofphp/php-cs-fixer": "3.93.0",
"jangregor/phpstan-prophecy": "^1.0",
"nelmio/alice": "^3.8",
"nikic/php-parser": "^4.15",

View File

@@ -0,0 +1,3 @@
kind: Added
body: Use admin delegated account for handling authentication
time: 2026-01-22T15:32:23.932994899+01:00

View File

@@ -80,12 +80,19 @@ final readonly class CreateZimbraComponent
$location = $calendar->getCalendar()->getLocation();
$hasLocation = $calendar->getCalendar()->hasLocation();
$isPrivate = $calendar->getCalendar()->getAccompanyingPeriod()?->isConfidential() ?? false;
} else {
} elseif ($calendar instanceof Calendar) {
$startDate = $calendar->getStartDate();
$endDate = $calendar->getEndDate();
$location = $calendar->getLocation();
$hasLocation = $calendar->hasLocation();
$isPrivate = $calendar->getAccompanyingPeriod()?->isConfidential() ?? false;
} else {
// Calendar range case
$startDate = $calendar->getStartDate();
$endDate = $calendar->getEndDate();
$location = $calendar->getLocation();
$hasLocation = $calendar->hasLocation();
$isPrivate = false;
}
$comp = new InviteComponent();

View File

@@ -11,48 +11,84 @@ declare(strict_types=1);
namespace Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Zimbra\Admin\AdminApi;
use Zimbra\Common\Enum\AccountBy;
use Zimbra\Common\Soap\ClientFactory;
use Zimbra\Common\Struct\AccountSelector;
use Zimbra\Common\Struct\Header\AccountInfo;
use Zimbra\Mail\MailApi;
final readonly class SoapClientBuilder
final class SoapClientBuilder
{
private string $username;
private readonly string $username;
private string $password;
private readonly string $password;
private string $url;
private readonly string $url;
public function __construct(private ParameterBagInterface $parameterBag, private HttpClientInterface $client)
{
private readonly string $adminUrl;
private readonly bool $verifyHost;
private readonly bool $verifyPeer;
private readonly bool $adminVerifyHost;
private readonly bool $adminVerifyPeer;
/**
* Keep the cache of the tokens.
*
* @var array<string, array{token: string, expirationTime: \DateTimeImmutable}>
*/
private array $tokenCache = [];
public function __construct(
private readonly ParameterBagInterface $parameterBag,
private readonly HttpClientInterface $client,
private readonly ClockInterface $clock,
) {
$dsn = $this->parameterBag->get('chill_calendar.remote_calendar_dsn');
$url = parse_url($dsn);
$this->username = urldecode($url['user']);
$this->password = urldecode($url['pass']);
if ('zimbra+http' === $url['scheme']) {
$scheme = 'http://';
$scheme = 'http';
$port = $url['port'] ?? 80;
} elseif ('zimbra+https' === $url['scheme']) {
$scheme = 'https://';
$scheme = 'https';
$port = $url['port'] ?? 443;
} else {
throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$url['scheme']);
}
$this->url = $scheme.$url['host'].':'.$port;
// get attributes for adminUrl
$query = [];
parse_str($url['query'] ?? '', $query);
$adminPort = $query['adminPort'] ?? '7071';
$adminHost = $query['adminHost'] ?? $url['host'];
$adminScheme = $query['adminScheme'] ?? $scheme;
$this->verifyPeer = (bool) ($query['verifyPeer'] ?? true);
$this->verifyHost = (bool) ($query['verifyHost'] ?? true);
$this->adminVerifyHost = (bool) ($query['adminVerifyHost'] ?? $this->verifyPeer);
$this->adminVerifyPeer = (bool) ($query['adminVerifyPeer'] ?? $this->verifyHost);
$this->url = $scheme.'://'.$url['host'].':'.$port;
$this->adminUrl = $adminScheme.'://'.$adminHost.':'.$adminPort;
}
private function buildApi(): MailApi
{
$baseClient = $this->client->withOptions([
'base_uri' => $location = $this->url.'/service/soap',
'verify_host' => false,
'verify_peer' => false,
'verify_host' => $this->verifyHost,
'verify_peer' => $this->verifyPeer,
]);
$psr18Client = new Psr18Client($baseClient);
$api = new MailApi();
@@ -62,12 +98,36 @@ final readonly class SoapClientBuilder
return $api;
}
private function buildAdminApi(): AdminApi
{
$baseClient = $this->client->withOptions([
'base_uri' => $location = $this->adminUrl.'/service/admin/soap',
'verify_host' => $this->adminVerifyHost,
'verify_peer' => $this->adminVerifyPeer,
]);
$psr18Client = new Psr18Client($baseClient);
$api = new AdminApi();
$client = ClientFactory::create($location, $psr18Client);
$api->setClient($client);
return $api;
}
public function getApiForAccount(string $accountName): MailApi
{
$api = $this->buildApi();
$response = $api->authByAccountName($this->username, $this->password);
['token' => $token, 'expirationTime' => $expirationTime] = $this->tokenCache[$accountName]
?? ['token' => null, 'expirationTime' => null];
$token = $response->getAuthToken();
if (null === $token || null === $expirationTime || $expirationTime <= $this->clock->now()) {
$adminApi = $this->buildAdminApi();
$adminApi->auth($this->username, $this->password);
$delegateResponse = $adminApi->delegateAuth(new AccountSelector(AccountBy::NAME, $accountName));
$token = $delegateResponse->getAuthToken();
$expiration = $delegateResponse->getLifetime();
$expirationTime = $this->clock->now()->add(new \DateInterval('PT'.$expiration.'S'));
$this->tokenCache[$accountName] = ['token' => $token, 'expirationTime' => $expirationTime];
}
$apiBy = $this->buildApi();
$apiBy->setAuthToken($token);

View File

@@ -69,7 +69,7 @@ class ChillActivityExtension extends Extension implements PrependExtensionInterf
}
/** (non-PHPdoc).
* @see \Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface::prepend()
* @see PrependExtensionInterface::prepend()
*/
public function prependRoutes(ContainerBuilder $container)
{

View File

@@ -1,123 +1,114 @@
<template>
<div>
<ul class="record_actions">
<li>
<a class="btn btn-sm btn-create" @click="openModal">
{{ trans(ACTIVITY_CREATE_NEW_LOCATION) }}
</a>
</li>
</ul>
<div>
<ul class="record_actions">
<li>
<a class="btn btn-sm btn-create" @click="openModal">
{{ trans(ACTIVITY_CREATE_NEW_LOCATION) }}
</a>
</li>
</ul>
<teleport to="body">
<modal
v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
@close="modal.showModal = false"
>
<template #header>
<h3 class="modal-title">
{{ trans(ACTIVITY_CREATE_NEW_LOCATION) }}
</h3>
</template>
<template #body>
<form>
<div class="alert alert-warning" v-if="errors.length">
<ul>
<li v-for="(e, i) in errors" :key="i">
{{ e }}
</li>
</ul>
</div>
<teleport to="body">
<modal
v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
@close="modal.showModal = false"
>
<template #header>
<h3 class="modal-title">
{{ trans(ACTIVITY_CREATE_NEW_LOCATION) }}
</h3>
</template>
<template #body>
<form>
<div class="alert alert-warning" v-if="errors.length">
<ul>
<li v-for="(e, i) in errors" :key="i">
{{ e }}
</li>
</ul>
</div>
<div class="form-floating mb-3">
<select
class="form-select form-select-lg"
id="type"
required
v-model="selectType"
>
<option selected disabled value="">
{{ trans(ACTIVITY_CHOOSE_LOCATION_TYPE) }}
</option>
<option
v-for="t in locationTypes"
:value="t"
:key="t.id"
>
{{ localizeString(t.title) }}
</option>
</select>
<label>{{
trans(ACTIVITY_LOCATION_FIELDS_TYPE)
}}</label>
</div>
<div class="form-floating mb-3">
<select
class="form-select form-select-lg"
id="type"
required
v-model="selectType"
>
<option selected disabled value="">
{{ trans(ACTIVITY_CHOOSE_LOCATION_TYPE) }}
</option>
<option v-for="t in locationTypes" :value="t" :key="t.id">
{{ localizeString(t.title) }}
</option>
</select>
<label>{{ trans(ACTIVITY_LOCATION_FIELDS_TYPE) }}</label>
</div>
<div class="form-floating mb-3">
<input
class="form-control form-control-lg"
id="name"
v-model="inputName"
placeholder
/>
<label for="name">{{
trans(ACTIVITY_LOCATION_FIELDS_NAME)
}}</label>
</div>
<div class="form-floating mb-3">
<input
class="form-control form-control-lg"
id="name"
v-model="inputName"
placeholder
/>
<label for="name">{{
trans(ACTIVITY_LOCATION_FIELDS_NAME)
}}</label>
</div>
<add-address
:context="addAddress.context"
:options="addAddress.options"
:addressChangedCallback="submitNewAddress"
v-if="showAddAddress"
ref="addAddress"
/>
<add-address
:context="addAddress.context"
:options="addAddress.options"
:addressChangedCallback="submitNewAddress"
v-if="showAddAddress"
ref="addAddress"
/>
<div class="form-floating mb-3" v-if="showContactData">
<input
class="form-control form-control-lg"
id="phonenumber1"
v-model="inputPhonenumber1"
placeholder
/>
<label for="phonenumber1">{{
trans(ACTIVITY_LOCATION_FIELDS_PHONENUMBER1)
}}</label>
</div>
<div class="form-floating mb-3" v-if="hasPhonenumber1">
<input
class="form-control form-control-lg"
id="phonenumber2"
v-model="inputPhonenumber2"
placeholder
/>
<label for="phonenumber2">{{
trans(ACTIVITY_LOCATION_FIELDS_PHONENUMBER2)
}}</label>
</div>
<div class="form-floating mb-3" v-if="showContactData">
<input
class="form-control form-control-lg"
id="email"
v-model="inputEmail"
placeholder
/>
<label for="email">{{
trans(ACTIVITY_LOCATION_FIELDS_EMAIL)
}}</label>
</div>
</form>
</template>
<template #footer>
<button
class="btn btn-save"
@click.prevent="saveNewLocation"
>
{{ trans(SAVE) }}
</button>
</template>
</modal>
</teleport>
</div>
<div class="form-floating mb-3" v-if="showContactData">
<input
class="form-control form-control-lg"
id="phonenumber1"
v-model="inputPhonenumber1"
placeholder
/>
<label for="phonenumber1">{{
trans(ACTIVITY_LOCATION_FIELDS_PHONENUMBER1)
}}</label>
</div>
<div class="form-floating mb-3" v-if="hasPhonenumber1">
<input
class="form-control form-control-lg"
id="phonenumber2"
v-model="inputPhonenumber2"
placeholder
/>
<label for="phonenumber2">{{
trans(ACTIVITY_LOCATION_FIELDS_PHONENUMBER2)
}}</label>
</div>
<div class="form-floating mb-3" v-if="showContactData">
<input
class="form-control form-control-lg"
id="email"
v-model="inputEmail"
placeholder
/>
<label for="email">{{
trans(ACTIVITY_LOCATION_FIELDS_EMAIL)
}}</label>
</div>
</form>
</template>
<template #footer>
<button class="btn btn-save" @click.prevent="saveNewLocation">
{{ trans(SAVE) }}
</button>
</template>
</modal>
</teleport>
</div>
</template>
<script>
@@ -128,241 +119,240 @@ import { getLocationTypes } from "../../api";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {
SAVE,
ACTIVITY_LOCATION_FIELDS_EMAIL,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER1,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER2,
ACTIVITY_LOCATION_FIELDS_NAME,
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
trans,
SAVE,
ACTIVITY_LOCATION_FIELDS_EMAIL,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER1,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER2,
ACTIVITY_LOCATION_FIELDS_NAME,
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
trans,
} from "translator";
export default {
name: "NewLocation",
components: {
Modal,
AddAddress,
name: "NewLocation",
components: {
Modal,
AddAddress,
},
setup() {
return {
trans,
SAVE,
ACTIVITY_LOCATION_FIELDS_EMAIL,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER1,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER2,
ACTIVITY_LOCATION_FIELDS_NAME,
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
};
},
props: ["availableLocations"],
data() {
return {
errors: [],
selected: {
type: null,
name: null,
addressId: null,
phonenumber1: null,
phonenumber2: null,
email: null,
},
locationTypes: [],
modal: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl",
},
addAddress: {
options: {
button: {
text: {
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
size: "btn-sm",
},
title: {
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
},
context: {
target: {
//name, id
},
edit: false,
addressId: null,
defaults: window.addaddress,
},
},
};
},
computed: {
...mapState(["activity"]),
selectType: {
get() {
return this.selected.type;
},
set(value) {
this.selected.type = value;
},
},
setup() {
return {
trans,
SAVE,
ACTIVITY_LOCATION_FIELDS_EMAIL,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER1,
ACTIVITY_LOCATION_FIELDS_PHONENUMBER2,
ACTIVITY_LOCATION_FIELDS_NAME,
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
inputName: {
get() {
return this.selected.name;
},
set(value) {
this.selected.name = value;
},
},
inputEmail: {
get() {
return this.selected.email;
},
set(value) {
this.selected.email = value;
},
},
inputPhonenumber1: {
get() {
return this.selected.phonenumber1;
},
set(value) {
this.selected.phonenumber1 = value;
},
},
inputPhonenumber2: {
get() {
return this.selected.phonenumber2;
},
set(value) {
this.selected.phonenumber2 = value;
},
},
hasPhonenumber1() {
return (
this.selected.phonenumber1 !== null && this.selected.phonenumber1 !== ""
);
},
showAddAddress() {
let cond = false;
if (this.selected.type) {
if (this.selected.type.addressRequired !== "never") {
cond = true;
}
}
return cond;
},
showContactData() {
let cond = false;
if (this.selected.type) {
if (this.selected.type.contactData !== "never") {
cond = true;
}
}
return cond;
},
},
mounted() {
this.getLocationTypesList();
},
methods: {
localizeString,
checkForm() {
let cond = true;
this.errors = [];
if (!this.selected.type) {
this.errors.push("Type de localisation requis");
cond = false;
} else {
if (
this.selected.type.addressRequired === "required" &&
!this.selected.addressId
) {
this.errors.push("Adresse requise");
cond = false;
}
if (
this.selected.type.contactData === "required" &&
!this.selected.phonenumber1
) {
this.errors.push("Numéro de téléphone requis");
cond = false;
}
if (
this.selected.type.contactData === "required" &&
!this.selected.email
) {
this.errors.push("Adresse email requise");
cond = false;
}
}
return cond;
},
getLocationTypesList() {
getLocationTypes().then((results) => {
this.locationTypes = results.filter(
(t) => t.availableForUsers === true,
);
});
},
openModal() {
this.modal.showModal = true;
},
saveNewLocation() {
if (this.checkForm()) {
let body = {
type: "location",
name: this.selected.name,
locationType: {
id: this.selected.type.id,
type: "location-type",
},
phonenumber1: this.selected.phonenumber1,
phonenumber2: this.selected.phonenumber2,
email: this.selected.email,
};
},
props: ["availableLocations"],
data() {
return {
errors: [],
selected: {
type: null,
name: null,
addressId: null,
phonenumber1: null,
phonenumber2: null,
email: null,
if (this.selected.addressId) {
body = Object.assign(body, {
address: {
id: this.selected.addressId,
},
locationTypes: [],
modal: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl",
},
addAddress: {
options: {
button: {
text: {
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
size: "btn-sm",
},
title: {
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
},
context: {
target: {
//name, id
},
edit: false,
addressId: null,
defaults: window.addaddress,
},
},
};
},
computed: {
...mapState(["activity"]),
selectType: {
get() {
return this.selected.type;
},
set(value) {
this.selected.type = value;
},
},
inputName: {
get() {
return this.selected.name;
},
set(value) {
this.selected.name = value;
},
},
inputEmail: {
get() {
return this.selected.email;
},
set(value) {
this.selected.email = value;
},
},
inputPhonenumber1: {
get() {
return this.selected.phonenumber1;
},
set(value) {
this.selected.phonenumber1 = value;
},
},
inputPhonenumber2: {
get() {
return this.selected.phonenumber2;
},
set(value) {
this.selected.phonenumber2 = value;
},
},
hasPhonenumber1() {
return (
this.selected.phonenumber1 !== null &&
this.selected.phonenumber1 !== ""
);
},
showAddAddress() {
let cond = false;
if (this.selected.type) {
if (this.selected.type.addressRequired !== "never") {
cond = true;
}
}
return cond;
},
showContactData() {
let cond = false;
if (this.selected.type) {
if (this.selected.type.contactData !== "never") {
cond = true;
}
}
return cond;
},
},
mounted() {
this.getLocationTypesList();
},
methods: {
localizeString,
checkForm() {
let cond = true;
this.errors = [];
if (!this.selected.type) {
this.errors.push("Type de localisation requis");
cond = false;
} else {
if (
this.selected.type.addressRequired === "required" &&
!this.selected.addressId
) {
this.errors.push("Adresse requise");
cond = false;
}
if (
this.selected.type.contactData === "required" &&
!this.selected.phonenumber1
) {
this.errors.push("Numéro de téléphone requis");
cond = false;
}
if (
this.selected.type.contactData === "required" &&
!this.selected.email
) {
this.errors.push("Adresse email requise");
cond = false;
}
}
return cond;
},
getLocationTypesList() {
getLocationTypes().then((results) => {
this.locationTypes = results.filter(
(t) => t.availableForUsers === true,
);
});
},
openModal() {
this.modal.showModal = true;
},
saveNewLocation() {
if (this.checkForm()) {
let body = {
type: "location",
name: this.selected.name,
locationType: {
id: this.selected.type.id,
type: "location-type",
},
phonenumber1: this.selected.phonenumber1,
phonenumber2: this.selected.phonenumber2,
email: this.selected.email,
};
if (this.selected.addressId) {
body = Object.assign(body, {
address: {
id: this.selected.addressId,
},
});
}
});
}
makeFetch("POST", "/api/1.0/main/location.json", body)
.then((response) => {
this.$store.dispatch("addAvailableLocationGroup", {
locationGroup: "Localisations nouvellement créées",
locations: [response],
});
this.$store.dispatch("updateLocation", response);
this.modal.showModal = false;
})
.catch((error) => {
if (error.name === "ValidationException") {
for (let v of error.violations) {
this.errors.push(v);
}
} else {
this.errors.push("An error occurred");
}
});
makeFetch("POST", "/api/1.0/main/location.json", body)
.then((response) => {
this.$store.dispatch("addAvailableLocationGroup", {
locationGroup: "Localisations nouvellement créées",
locations: [response],
});
this.$store.dispatch("updateLocation", response);
this.modal.showModal = false;
})
.catch((error) => {
if (error.name === "ValidationException") {
for (let v of error.violations) {
this.errors.push(v);
}
} else {
this.errors.push("An error occurred");
}
},
submitNewAddress(payload) {
this.selected.addressId = payload.addressId;
this.addAddress.context.addressId = payload.addressId;
this.addAddress.context.edit = true;
},
});
}
},
submitNewAddress(payload) {
this.selected.addressId = payload.addressId;
this.addAddress.context.addressId = payload.addressId;
this.addAddress.context.edit = true;
},
},
};
</script>

View File

@@ -16,7 +16,8 @@ use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use Symfony\Component\Security\Core\Security;
class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
@@ -24,9 +25,10 @@ class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
public function __construct(
private readonly ActivityRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -39,7 +39,7 @@ final class Version20251118124241 extends AbstractMigration
$this->addSql("COMMENT ON COLUMN activity_user.by_migration IS 'For backup purpose - can be safely deleted after a while. See migration \\Chill\\Migrations\\Activity\\Version20251118124241'");
$this->addSql('INSERT INTO activity_user (activity_id, user_id, by_migration)
SELECT id, user_id, true FROM activity
SELECT id, user_id, true FROM activity WHERE user_id is not null
ON CONFLICT DO NOTHING');
}

View File

@@ -56,7 +56,7 @@ class ChillBudgetExtension extends Extension implements PrependExtensionInterfac
}
/** (non-PHPdoc).
* @see \Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface::prepend()
* @see PrependExtensionInterface::prepend()
*/
public function prependRoutes(ContainerBuilder $container)
{

View File

@@ -72,14 +72,20 @@
{% macro table_results(actualCharges, actualResources, results) %}
{% set now = date() %}
{% set totalCharges = 0 %}
{% for c in actualCharges %}
{% set totalCharges = totalCharges + c.amount %}
{% if c.startDate <= now and (c.endDate is null or c.endDate >= now) %}
{% set totalCharges = totalCharges + c.amount %}
{% endif %}
{% endfor %}
{% set totalResources = 0 %}
{% for r in actualResources %}
{% set totalResources = totalResources + r.amount %}
{% if r.startDate <= now and (r.endDate is null or r.endDate >= now) %}
{% set totalResources = totalResources + r.amount %}
{% endif %}
{% endfor %}
{% set result = (totalResources - totalCharges) %}

View File

@@ -71,4 +71,11 @@ export function isEventInputCalendarRange(
return typeof toBeDetermined.is === "string" && toBeDetermined.is === "range";
}
export enum AnswerStatus {
ACCEPTED = "accepted",
DECLINED = "declined",
PENDING = "pending",
TENTATIVE = "tentative",
}
export {};

View File

@@ -7,7 +7,7 @@
<i v-else-if="invite.status === 'declined'" class="fa fa-times" />
<i v-else-if="invite.status === 'pending'" class="fa fa-question-o" />
<i v-else-if="invite.status === 'tentative'" class="fa fa-question" />
<span v-else="">{{ invite.status }}</span>
<span v-else>{{ invite.status }}</span>
</template>
</span>
<span class="form-check-inline form-switch">
@@ -42,8 +42,6 @@
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "CalendarActive",
props: {

View File

@@ -24,6 +24,14 @@ const appMessages = {
list_three_days: "Liste 3 jours",
current_selected: "Rendez-vous fixé",
main_user: "Utilisateur principal",
Give_an_answer: "Répondre",
Accepted: "Accepté",
Declined: "Refusé",
Tentative: "Accepté provisoirement",
Accept: "Accepter",
Decline: "Refuser",
Tentatively_accept: "Accepter provisoirement",
Set_pending: "Ne pas répondre",
},
};

View File

@@ -47,77 +47,38 @@
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
<script lang="ts" setup>
import { AnswerStatus } from "../../types";
const ACCEPTED = "accepted";
const DECLINED = "declined";
const PENDING = "pending";
const TENTATIVELY_ACCEPTED = "tentative";
const props = defineProps<{
calendarId: number;
status: AnswerStatus;
}>();
const i18n = {
messages: {
fr: {
Give_an_answer: "Répondre",
Accepted: "Accepté",
Declined: "Refusé",
Tentative: "Accepté provisoirement",
Accept: "Accepter",
Decline: "Refuser",
Tentatively_accept: "Accepter provisoirement",
Set_pending: "Ne pas répondre",
},
},
const emit =
defineEmits<(e: "statusChanged", newStatus: AnswerStatus) => void>();
const Statuses = {
ACCEPTED: AnswerStatus.ACCEPTED,
DECLINED: AnswerStatus.DECLINED,
PENDING: AnswerStatus.PENDING,
TENTATIVELY_ACCEPTED: AnswerStatus.TENTATIVE,
};
export default defineComponent({
name: "Answer",
i18n,
props: {
calendarId: { type: Number, required: true },
status: {
type: String as PropType<
"accepted" | "declined" | "pending" | "tentative"
>,
required: true,
},
},
emits: {
statusChanged(payload: "accepted" | "declined" | "pending" | "tentative") {
return true;
},
},
data() {
return {
Statuses: {
ACCEPTED,
DECLINED,
PENDING,
TENTATIVELY_ACCEPTED,
},
};
},
methods: {
changeStatus: function (
newStatus: "accepted" | "declined" | "pending" | "tentative",
) {
console.log("changeStatus", newStatus);
const url = `/api/1.0/calendar/calendar/${this.$props.calendarId}/answer/${newStatus}.json`;
window
.fetch(url, {
method: "POST",
})
.then((r: Response) => {
if (!r.ok) {
console.error("could not confirm answer", newStatus);
return;
}
console.log("answer sent", newStatus);
this.$emit("statusChanged", newStatus);
});
},
},
});
function changeStatus(newStatus: AnswerStatus) {
const url = `/api/1.0/calendar/calendar/${props.calendarId}/answer/${newStatus}.json`;
window
.fetch(url, {
method: "POST",
})
.then((r: Response) => {
if (!r.ok) {
console.error("could not confirm answer", newStatus);
return;
}
emit("statusChanged", newStatus);
});
}
</script>
<style scoped></style>

View File

@@ -146,7 +146,11 @@
id="copyFromWeek"
class="form-select"
>
<option v-for="w in lastWeeks" :value="w.value" :key="w.value">
<option
v-for="w in lastWeeks"
:value="w.value"
:key="w.value || ''"
>
{{ w.text }}
</option>
</select>
@@ -156,7 +160,11 @@
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<select v-model="copyToWeek" id="copyToWeek" class="form-select">
<option v-for="w in nextWeeks" :value="w.value" :key="w.value">
<option
v-for="w in nextWeeks"
:value="w.value"
:key="w.value || ''"
>
{{ w.text }}
</option>
</select>

View File

@@ -74,12 +74,12 @@ const saveAndClose = function (e: Event): void {
location: location.value,
calendarRangeId: calendarRangeId.value,
})
.then((_) => {
.then(() => {
showModal.value = false;
});
};
const closeModal = function (_: any): void {
const closeModal = function (): void {
showModal.value = false;
};

View File

@@ -78,7 +78,7 @@
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.persons|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">

View File

@@ -52,7 +52,7 @@ class ChillCustomFieldsExtension extends Extension implements PrependExtensionIn
}
/** (non-PHPdoc).
* @see \Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface::prepend()
* @see PrependExtensionInterface::prepend()
*/
public function prepend(ContainerBuilder $container)
{

View File

@@ -25,7 +25,7 @@ class ChoiceWithOtherType extends AbstractType
private string $otherValueLabel = 'Other value';
/** (non-PHPdoc).
* @see \Symfony\Component\Form\AbstractType::buildForm()
* @see AbstractType::buildForm()
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
@@ -42,7 +42,7 @@ class ChoiceWithOtherType extends AbstractType
}
/** (non-PHPdoc).
* @see \Symfony\Component\Form\AbstractType::configureOptions()
* @see AbstractType::configureOptions()
*/
public function configureOptions(OptionsResolver $resolver)
{

View File

@@ -22,7 +22,7 @@ use Symfony\Component\Form\FormEvents;
class ChoicesListType extends AbstractType
{
/** (non-PHPdoc).
* @see \Symfony\Component\Form\AbstractType::buildForm()
* @see AbstractType::buildForm()
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{

View File

@@ -82,7 +82,7 @@ class CustomFieldProvider implements ContainerAwareInterface
/**
* (non-PHPdoc).
*
* @see \Symfony\Component\DependencyInjection\ContainerAwareInterface::setContainer()
* @see ContainerAwareInterface::setContainer()
*/
public function setContainer(?ContainerInterface $container = null)
{

View File

@@ -2,12 +2,11 @@ import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import { createApp } from "vue";
import { StoredObject, StoredObjectStatusChange } from "../../types";
import { is_object_ready } from "../../vuejs/StoredObjectButton/helpers";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({});
window.addEventListener("DOMContentLoaded", function (e) {
window.addEventListener("DOMContentLoaded", function () {
document
.querySelectorAll<HTMLDivElement>("div[data-download-buttons]")
.forEach((el) => {

View File

@@ -9,6 +9,7 @@ export interface StoredObject {
uuid: string;
prefix: string;
status: StoredObjectStatus;
type: string;
currentVersion:
| null
| StoredObjectVersionCreated

View File

@@ -26,8 +26,8 @@
<li v-if="isEditableOnDesktop">
<desktop-edit-button
:classes="{ 'dropdown-item': true }"
:edit-link="props.davLink"
:expiration-link="props.davLinkExpiration"
:edit-link="props.davLink ?? ''"
:expiration-link="props.davLinkExpiration ?? 0"
></desktop-edit-button>
</li>
<li v-if="isConvertibleToPdf">
@@ -75,7 +75,6 @@ import {
import {
StoredObject,
StoredObjectStatusChange,
StoredObjectVersion,
WopiEditButtonExecutableBeforeLeaveFunction,
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
@@ -206,10 +205,6 @@ const checkForReady = function (): void {
};
const onObjectNewStatusCallback = async function (): Promise<void> {
if (props.storedObject.status === "stored_object_created") {
return Promise.resolve();
}
const new_status = await is_object_ready(props.storedObject);
if (props.storedObject.status !== new_status.status) {
emit("onStoredObjectStatusChange", new_status);

View File

@@ -1,3 +1,35 @@
<template>
<div class="drop-file">
<div
v-if="!uploading"
:class="{ area: true, dragging: is_dragging }"
@click="onZoneClick"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<p v-if="has_existing_doc" class="file-icon">
<file-icon :type="props.existingDoc?.type ?? ''"></file-icon>
</p>
<p v-if="display_filename !== null" class="display-filename">
{{ display_filename }}
</p>
<!-- todo i18n -->
<p v-if="has_existing_doc">
Déposez un document ou cliquez ici pour remplacer le document existant
</p>
<p v-else>
Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier
</p>
</div>
<div v-else class="waiting">
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
</template>
<script setup lang="ts">
import { StoredObject, StoredObjectVersionCreated } from "../../types";
import {
@@ -9,24 +41,23 @@ import { computed, ref, Ref } from "vue";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
interface DropFileConfig {
existingDoc?: StoredObject;
existingDoc: StoredObject | null;
}
const props = withDefaults(defineProps<DropFileConfig>(), {
existingDoc: null,
});
const emit =
defineEmits<
(
e: "addDocument",
{
stored_object_version: StoredObjectVersionCreated,
stored_object: StoredObject,
file_name: string,
},
) => void
>();
const emit = defineEmits<
(
e: "addDocument",
payload: {
stored_object_version: StoredObjectVersionCreated;
stored_object: StoredObject;
file_name: string;
},
) => void
>();
const is_dragging: Ref<boolean> = ref(false);
const uploading: Ref<boolean> = ref(false);
@@ -134,38 +165,6 @@ const handleFile = async (file: File): Promise<void> => {
};
</script>
<template>
<div class="drop-file">
<div
v-if="!uploading"
:class="{ area: true, dragging: is_dragging }"
@click="onZoneClick"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<p v-if="has_existing_doc" class="file-icon">
<file-icon :type="props.existingDoc?.type"></file-icon>
</p>
<p v-if="display_filename !== null" class="display-filename">
{{ display_filename }}
</p>
<!-- todo i18n -->
<p v-if="has_existing_doc">
Déposez un document ou cliquez ici pour remplacer le document existant
</p>
<p v-else>
Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier
</p>
</div>
<div v-else class="waiting">
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
</template>
<style scoped lang="scss">
.drop-file {
width: 100%;

View File

@@ -18,10 +18,10 @@ const props = withDefaults(defineProps<DropFileConfig>(), {
const emit = defineEmits<{
(
e: "addDocument",
{
stored_object: StoredObject,
stored_object_version: StoredObjectVersion,
file_name: string,
payload: {
stored_object: StoredObject;
stored_object_version: StoredObjectVersion;
file_name: string;
},
): void;
(e: "removeDocument"): void;

View File

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

View File

@@ -1,11 +1,3 @@
<script setup lang="ts">
interface FileIconConfig {
type: string;
}
const props = defineProps<FileIconConfig>();
</script>
<template>
<i class="fa fa-file-pdf-o" v-if="props.type === 'application/pdf'"></i>
<i
@@ -43,4 +35,12 @@ const props = defineProps<FileIconConfig>();
<i class="fa fa-file-code-o" v-else></i>
</template>
<script setup lang="ts">
interface FileIconConfig {
type: string;
}
const props = defineProps<FileIconConfig>();
</script>
<style scoped lang="scss"></style>

View File

@@ -1,42 +1,3 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, reactive } from "vue";
export interface DesktopEditButtonConfig {
editLink: null;
classes: Record<string, boolean>;
expirationLink: number | Date;
}
interface DesktopEditButtonState {
modalOpened: boolean;
}
const state: DesktopEditButtonState = reactive({ modalOpened: false });
const props = defineProps<DesktopEditButtonConfig>();
const buildCommand = computed<string>(
() => "vnd.libreoffice.command:ofe|u|" + props.editLink,
);
const editionUntilFormatted = computed<string>(() => {
let d;
if (props.expirationLink instanceof Date) {
d = props.expirationLink;
} else {
d = new Date(props.expirationLink * 1000);
}
console.log(props.expirationLink);
return new Intl.DateTimeFormat(undefined, {
dateStyle: "long",
timeStyle: "medium",
}).format(d);
});
</script>
<template>
<teleport to="body">
<modal v-if="state.modalOpened" @close="state.modalOpened = false">
@@ -90,3 +51,41 @@ i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
</style>
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, reactive } from "vue";
export interface DesktopEditButtonConfig {
editLink: string;
classes: Record<string, boolean>;
expirationLink: number | Date;
}
interface DesktopEditButtonState {
modalOpened: boolean;
}
const state: DesktopEditButtonState = reactive({ modalOpened: false });
const props = defineProps<DesktopEditButtonConfig>();
const buildCommand = computed<string>(
() => "vnd.libreoffice.command:ofe|u|" + props.editLink,
);
const editionUntilFormatted = computed<string>(() => {
let d;
if (props.expirationLink instanceof Date) {
d = props.expirationLink;
} else {
d = new Date(props.expirationLink * 1000);
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "long",
timeStyle: "medium",
}).format(d);
});
</script>

View File

@@ -12,7 +12,7 @@
v-else
:class="props.classes"
target="_blank"
:type="props.atVersion.type"
:type="props.atVersion?.type"
:download="buildDocumentName()"
:href="state.href_url"
ref="open_button"
@@ -27,11 +27,15 @@
import { reactive, ref, nextTick, onMounted } from "vue";
import { download_and_decrypt_doc } from "./helpers";
import mime from "mime";
import { StoredObject, StoredObjectVersion } from "../../types";
import {
StoredObject,
StoredObjectVersionCreated,
StoredObjectVersionPersisted,
} from "../../types";
interface DownloadButtonConfig {
storedObject: StoredObject;
atVersion: StoredObjectVersion;
atVersion: null | StoredObjectVersionCreated | StoredObjectVersionPersisted;
classes: Record<string, boolean>;
filename?: string;
/**
@@ -70,7 +74,7 @@ function buildDocumentName(): string {
document_name = "document";
}
const ext = mime.getExtension(props.atVersion.type);
const ext = mime.getExtension(props.atVersion?.type ?? "");
if (null !== ext) {
return document_name + "." + ext;

View File

@@ -1,10 +1,24 @@
<template>
<a @click="download_version_and_open_modal" class="dropdown-item">
<history-button-modal
ref="modal"
:versions="state.versions"
:stored-object="storedObject"
:can-edit="canEdit"
@restore-version="onRestoreVersion"
></history-button-modal>
<i class="fa fa-history"></i>
Historique
</a>
</template>
<script setup lang="ts">
import HistoryButtonModal from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue";
import {
StoredObject,
StoredObjectVersionWithPointInTime,
} from "./../../types";
import { computed, reactive, ref, useTemplateRef } from "vue";
import { reactive, useTemplateRef } from "vue";
import { get_versions } from "./HistoryButton/api";
interface HistoryButtonConfig {
@@ -38,29 +52,11 @@ const download_version_and_open_modal = async function (): Promise<void> {
}
};
const onRestoreVersion = ({
newVersion,
}: {
newVersion: StoredObjectVersionWithPointInTime;
}) => {
const onRestoreVersion = (newVersion: StoredObjectVersionWithPointInTime) => {
state.versions.unshift(newVersion);
};
</script>
<template>
<a @click="download_version_and_open_modal" class="dropdown-item">
<history-button-modal
ref="modal"
:versions="state.versions"
:stored-object="storedObject"
:can-edit="canEdit"
@restore-version="onRestoreVersion"
></history-button-modal>
<i class="fa fa-history"></i>
Historique
</a>
</template>
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);

View File

@@ -1,3 +1,22 @@
<template>
<template v-if="props.versions.length > 0">
<div class="container">
<template v-for="v in props.versions" :key="v.id">
<history-button-list-item
:version="v"
:can-edit="canEdit"
:is-current="higher_version === v.version"
:stored-object="storedObject"
@restore-version="onRestored"
></history-button-list-item>
</template>
</div>
</template>
<template v-else>
<p>Chargement des versions</p>
</template>
</template>
<script setup lang="ts">
import {
StoredObject,
@@ -40,33 +59,10 @@ const higher_version = computed<number>(() =>
*
* internally, keep track of the newly restored version
*/
const onRestored = ({
newVersion,
}: {
newVersion: StoredObjectVersionWithPointInTime;
}) => {
const onRestored = (newVersion: StoredObjectVersionWithPointInTime) => {
state.restored = newVersion.version;
emit("restoreVersion", { newVersion });
emit("restoreVersion", newVersion);
};
</script>
<template>
<template v-if="props.versions.length > 0">
<div class="container">
<template v-for="v in props.versions" :key="v.id">
<history-button-list-item
:version="v"
:can-edit="canEdit"
:is-current="higher_version === v.version"
:stored-object="storedObject"
@restore-version="onRestored"
></history-button-list-item>
</template>
</div>
</template>
<template v-else>
<p>Chargement des versions</p>
</template>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,3 +1,70 @@
<template>
<div :class="classes">
<div
class="col-12 tags"
v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated"
>
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion"
>Conservée avant conversion dans un autre format</span
>
<span class="badge bg-info" v-if="isRestored"
>Restaurée depuis la version
{{
version["from-restored"]?.version
? version["from-restored"]?.version + 1
: ""
}}</span
>
<span class="badge bg-info" v-if="isDuplicated"
>Dupliqué depuis un autre document</span
>
</div>
<div class="col-12">
<file-icon :type="version.type"></file-icon>
<span
><strong>&nbsp;#{{ version.version + 1 }}&nbsp;</strong></span
>
<template v-if="version.createdBy !== null && version.createdAt !== null">
<strong v-if="version.version == 0">créé par</strong>
<strong v-else>modifié par</strong>
<span class="badge-user">
<UserRenderBoxBadge :user="version.createdBy" />
</span>
<strong>à</strong>
{{ $d(ISOToDatetime(version.createdAt.datetime8601) ?? 0, "long") }}
</template>
<template v-if="version.createdBy === null && version.createdAt !== null">
<strong v-if="version.version == 0">Créé le</strong>
<strong v-else>modifié le</strong>
{{ $d(ISOToDatetime(version.createdAt.datetime8601) ?? 0, "long") }}
</template>
</div>
<div class="col-12">
<ul class="record_actions small slim on-version-actions">
<li v-if="canEdit && !isCurrent">
<restore-version-button
:stored-object-version="props.version"
@restore-version="onRestore"
></restore-version-button>
</li>
<li>
<download-button
:stored-object="storedObject"
:at-version="version"
:classes="{
btn: true,
'btn-outline-primary': true,
'btn-sm': true,
}"
:display-action-string-in-button="false"
></download-button>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import {
StoredObject,
@@ -24,12 +91,8 @@ const emit = defineEmits<{
const props = defineProps<HistoryButtonListItemConfig>();
const onRestore = ({
newVersion,
}: {
newVersion: StoredObjectVersionWithPointInTime;
}) => {
emit("restoreVersion", { newVersion });
const onRestore = (newVersion: StoredObjectVersionWithPointInTime) => {
emit("restoreVersion", newVersion);
};
const isKeptBeforeConversion = computed<boolean>(() => {
@@ -60,77 +123,11 @@ const classes = computed<{
}>(() => ({
row: true,
"row-hover": true,
"blinking-1": props.isRestored && 0 === props.version.version % 2,
"blinking-2": props.isRestored && 1 === props.version.version % 2,
"blinking-1": isRestored.value && 0 === props.version.version % 2,
"blinking-2": isRestored.value && 1 === props.version.version % 2,
}));
</script>
<template>
<div :class="classes">
<div
class="col-12 tags"
v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated"
>
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion"
>Conservée avant conversion dans un autre format</span
>
<span class="badge bg-info" v-if="isRestored"
>Restaurée depuis la version
{{ version["from-restored"]?.version + 1 }}</span
>
<span class="badge bg-info" v-if="isDuplicated"
>Dupliqué depuis un autre document</span
>
</div>
<div class="col-12">
<file-icon :type="version.type"></file-icon>
<span
><strong>&nbsp;#{{ version.version + 1 }}&nbsp;</strong></span
>
<template v-if="version.createdBy !== null && version.createdAt !== null"
><strong v-if="version.version == 0">créé par</strong
><strong v-else>modifié par</strong>
<span class="badge-user"
><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge
></span>
<strong>à</strong>
{{
$d(ISOToDatetime(version.createdAt.datetime8601), "long")
}}</template
><template v-if="version.createdBy === null && version.createdAt !== null"
><strong v-if="version.version == 0">Créé le</strong
><strong v-else>modifié le</strong>
{{
$d(ISOToDatetime(version.createdAt.datetime8601), "long")
}}</template
>
</div>
<div class="col-12">
<ul class="record_actions small slim on-version-actions">
<li v-if="canEdit && !isCurrent">
<restore-version-button
:stored-object-version="props.version"
@restore-version="onRestore"
></restore-version-button>
</li>
<li>
<download-button
:stored-object="storedObject"
:at-version="version"
:classes="{
btn: true,
'btn-outline-primary': true,
'btn-sm': true,
}"
:display-action-string-in-button="false"
></download-button>
</li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
div.tags {
span.badge:not(:last-child) {

View File

@@ -1,3 +1,22 @@
<template>
<Teleport to="body">
<modal v-if="state.opened" @close="state.opened = false">
<template v-slot:header>
<h3>Historique des versions du document</h3>
</template>
<template v-slot:body>
<p>Les versions sont conservées pendant 90 jours.</p>
<history-button-list
:versions="props.versions"
:can-edit="canEdit"
:stored-object="storedObject"
@restore-version="onRestoreVersion"
></history-button-list>
</template>
</modal>
</Teleport>
</template>
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { reactive } from "vue";
@@ -28,29 +47,10 @@ const open = () => {
state.opened = true;
};
const onRestoreVersion = (payload: {
newVersion: StoredObjectVersionWithPointInTime;
}) => emit("restoreVersion", payload);
const onRestoreVersion = (newVersion: StoredObjectVersionWithPointInTime) =>
emit("restoreVersion", newVersion);
defineExpose({ open });
</script>
<template>
<Teleport to="body">
<modal v-if="state.opened" @close="state.opened = false">
<template v-slot:header>
<h3>Historique des versions du document</h3>
</template>
<template v-slot:body>
<p>Les versions sont conservées pendant 90 jours.</p>
<history-button-list
:versions="props.versions"
:can-edit="canEdit"
:stored-object="storedObject"
@restore-version="onRestoreVersion"
></history-button-list>
</template>
</modal>
</Teleport>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,3 +1,13 @@
<template>
<button
class="btn btn-outline-action"
@click="restore_version_fn"
title="Restaurer"
>
<i class="fa fa-rotate-left"></i> Restaurer
</button>
</template>
<script setup lang="ts">
import {
StoredObjectVersionPersisted,
@@ -22,18 +32,8 @@ const restore_version_fn = async () => {
const newVersion = await restore_version(props.storedObjectVersion);
$toast.success("Version restaurée");
emit("restoreVersion", { newVersion });
emit("restoreVersion", newVersion);
};
</script>
<template>
<button
class="btn btn-outline-action"
@click="restore_version_fn"
title="Restaurer"
>
<i class="fa fa-rotate-left"></i> Restaurer
</button>
</template>
<style scoped lang="scss"></style>

View File

@@ -15,7 +15,10 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
@@ -34,7 +37,8 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function __construct(
private readonly Security $security,
private readonly ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
@@ -46,16 +50,6 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
// we first try to get the permission from the workflow, as attachement (this is the less intensive query)
$workflowPermissionAsAttachment = match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject),
};
if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) {
return false;
}
// Retrieve the related entity
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
@@ -65,7 +59,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
$regularPermission = $this->security->isGranted($voterAttribute, $entity);
if (!$this->canBeAssociatedWithWorkflow()) {
return $regularPermission;
return $this->voteOnStoredObjectAsAttachementOfAWorkflow($attribute, $regularPermission, $subject);
}
$workflowPermission = match ($attribute) {
@@ -74,9 +68,41 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
};
return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN => $this->voteOnStoredObjectAsAttachementOfAWorkflow($attribute, $regularPermission, $subject),
};
}
private function voteOnStoredObjectAsAttachementOfAWorkflow(StoredObjectRoleEnum $attribute, bool $regularPermission, StoredObject $storedObject): bool
{
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($storedObject);
// we get all the entity workflows where the stored object is attached
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
// we compute all the permission for each entity workflow
$permissions = array_map(fn (EntityWorkflow $entityWorkflow): string => match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($entityWorkflow),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($entityWorkflow),
}, $entityWorkflows);
// now, we reduce the permissions: abstain are ignored. Between DENIED and and GRANT, DENIED takes precedence
$computedPermission = WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN;
foreach ($permissions as $permission) {
if (WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED === $permission) {
return false;
}
if (WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT === $permission) {
$computedPermission = WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT;
}
}
if (WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN === $computedPermission) {
return $regularPermission;
}
// this is the case where WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT is returned
return true;
}
}

View File

@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Symfony\Component\Security\Core\Security;
@@ -25,8 +26,9 @@ final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredOb
private readonly AccompanyingCourseDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Symfony\Component\Security\Core\Security;
@@ -25,8 +26,9 @@ class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
private readonly PersonDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -16,8 +16,11 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Security;
@@ -31,21 +34,31 @@ class AbstractStoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @param array<int, EntityWorkflowAttachment> $attachments
*
* @return void
*/
private function buildStoredObjectVoter(
bool $canBeAssociatedWithWorkflow,
AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
?WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService = null,
array $attachments = [],
): AbstractStoredObjectVoter {
$attachmentsRepository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachmentsRepository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
// Anonymous class extending the abstract class
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter {
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $attachmentsRepository->reveal(), $workflowDocumentService) extends AbstractStoredObjectVoter {
public function __construct(
private readonly bool $canBeAssociatedWithWorkflow,
private readonly AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
EntityWorkflowAttachmentRepository $attachmentRepository,
WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function attributeToRole($attribute): string
@@ -72,28 +85,29 @@ class AbstractStoredObjectVoterTest extends TestCase
public function testSupportsOnAttribute(): void
{
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), null);
$entityWorkflowService = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), null);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), null);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
}
/**
* @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission
* @dataProvider dataProviderVoteOnAttributeWithWorkflow
*/
public function testVoteOnAttributeWithStoredObjectPermission(
public function testVoteOnAttributeWithWorkflow(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $isGrantedRegularPermission,
string $isGrantedWorkflowPermission,
string $isGrantedStoredObjectAttachment,
): void {
$storedObject = new StoredObject();
$repository = new DummyRepository($related = new \stdClass());
@@ -102,31 +116,28 @@ class AbstractStoredObjectVoterTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$attachementRepository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachementRepository->findByStoredObject($storedObject)->willReturn([]);
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermission);
} elseif (StoredObjectRoleEnum::EDIT === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($isGrantedWorkflowPermission);
} else {
throw new \LogicException('Invalid attribute for StoredObjectVoter');
}
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter {
public function __construct(private $repository, $helper, $security)
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal(), $attachementRepository->reveal()) extends AbstractStoredObjectVoter {
public function __construct(private $repository, $helper, $security, EntityWorkflowAttachmentRepository $attachmentRepository)
{
parent::__construct($security, $helper);
parent::__construct($security, $attachmentRepository, $helper);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
@@ -155,96 +166,64 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $actual);
}
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable
public static function dataProviderVoteOnAttributeWithWorkflow(): iterable
{
foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) {
yield 'Not related to any workflow nor attachment ('.$action.')' => [
$attribute,
true,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
true,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
];
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
];
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
}
}
/**
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
* @dataProvider dataProviderVoteOnAttribute
*/
public function testVoteOnAttributeWithoutStoredObjectPermission(
public function testVoteOnAttribute(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $canBeAssociatedWithWorkflow,
@@ -260,10 +239,7 @@ class AbstractStoredObjectVoterTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
@@ -283,27 +259,155 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
public static function dataProviderVoteOnAttribute(): iterable
{
// not associated on a workflow
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];
yield [StoredObjectRoleEnum::SEE, false, false, false, null, null, 'not associated on a workflow, denied by regular access, must not rely on helper'];
// associated on a workflow, read operation
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied'];
// association on a workflow, write operation
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied'];
}
/**
* @dataProvider dataProviderPrecedenceOfDirectAssociationOverWorkflowAttachments
*/
public function testPrecedenceOfDirectAssociationOverWorkflowAttachments(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $regularPermission,
string $directWorkflowPermission,
string $attachmentWorkflowPermission,
string $message,
): void {
$storedObject = new StoredObject();
$repository = new DummyRepository($related = new \stdClass());
$token = new UsernamePasswordToken(new User(), 'dummy');
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($regularPermission);
$workflowHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
// Direct association permission
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($directWorkflowPermission);
} else {
$workflowHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($directWorkflowPermission);
}
// Attachment permission
$entityWorkflow = $this->prophesize(\Chill\MainBundle\Entity\Workflow\EntityWorkflow::class)->reveal();
$attachment = $this->prophesize(EntityWorkflowAttachment::class);
$attachment->getEntityWorkflow()->willReturn($entityWorkflow);
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowHelper->isAllowedByWorkflowForReadOperation($entityWorkflow)
->willReturn($attachmentWorkflowPermission);
} else {
$workflowHelper->isAllowedByWorkflowForWriteOperation($entityWorkflow)
->willReturn($attachmentWorkflowPermission);
}
$voter = $this->buildStoredObjectVoter(
true,
$repository,
$security->reveal(),
$workflowHelper->reveal(),
[$attachment->reveal()]
);
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderPrecedenceOfDirectAssociationOverWorkflowAttachments(): iterable
{
$cases = [
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'message' => 'Direct FORCE_GRANT should win over attachment FORCE_DENIED',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'message' => 'Direct FORCE_DENIED should win over attachment FORCE_GRANT',
],
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Direct FORCE_GRANT should win over attachment ABSTAIN',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Direct FORCE_DENIED should win over attachment ABSTAIN',
],
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'message' => 'Direct ABSTAIN should let attachment FORCE_GRANT win',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'message' => 'Direct ABSTAIN should let attachment FORCE_DENIED win',
],
[
'expected' => true,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Both ABSTAIN should let regular permission (true) win',
],
[
'expected' => false,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Both ABSTAIN should let regular permission (false) win',
],
];
foreach ([StoredObjectRoleEnum::SEE, StoredObjectRoleEnum::EDIT] as $attribute) {
foreach ($cases as $case) {
yield sprintf('%s - %s', $attribute->name, $case['message']) => [
$attribute,
$case['expected'],
$case['regular'],
$case['direct'],
$case['attachment'],
$case['message'],
];
}
}
}
}

View File

@@ -52,7 +52,7 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface
}
/** (non-PHPdoc).
* @see \Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface::prepend()
* @see PrependExtensionInterface::prepend()
*/
public function prepend(ContainerBuilder $container): void
{

View File

@@ -14,6 +14,7 @@ namespace Chill\EventBundle\Security\Authorization;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Repository\EventRepository;
@@ -26,8 +27,9 @@ class EventStoredObjectVoter extends AbstractStoredObjectVoter
private readonly EventRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -189,14 +189,14 @@ crud:
title_edit: Rapport "belemmering" bewerken
title_delete: Belemmering verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
cscv:
title_new: Nieuw CV voor %person%
title_view: CV voor %person%
title_edit: CV bewerken
title_delete: CV verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
no_date: Geen datum aangegeven
no_end_date: einddatum onbekend
no_start_date: startdatum onbekend
@@ -206,7 +206,7 @@ crud:
title_edit: Immersie bewerken
title_delete: Immersie verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
projet_prof:
title_new: Nieuw professioneel project voor %person%
title_view: Professioneel project voor %person%

View File

@@ -31,7 +31,8 @@ class LoadAddressesFRFromBANCommand extends Command
{
$this->setName('chill:main:address-ref-from-ban')
->addArgument('departementNo', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'a list of departement numbers')
->addOption('send-report-email', 's', InputOption::VALUE_REQUIRED, 'Email address where a list of unimported addresses can be send');
->addOption('send-report-email', 's', InputOption::VALUE_REQUIRED, 'Email address where a list of unimported addresses can be send')
->addOption('allow-remove-double-refid', 'd', InputOption::VALUE_NONE, 'Should the address importer be allowed to remove same refid in the source data, if any');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -40,7 +41,7 @@ class LoadAddressesFRFromBANCommand extends Command
foreach ($input->getArgument('departementNo') as $departementNo) {
$output->writeln('Import addresses for '.$departementNo);
$this->addressReferenceFromBAN->import($departementNo, $input->hasOption('send-report-email') ? $input->getOption('send-report-email') : null);
$this->addressReferenceFromBAN->import($departementNo, $input->hasOption('send-report-email') ? $input->getOption('send-report-email') : null, allowRemoveDoubleRefId: $input->hasOption('allow-remove-double-refid') ? $input->getOption('allow-remove-double-refid') : false);
}
return Command::SUCCESS;

View File

@@ -48,7 +48,7 @@ class LoadAndUpdateLanguagesCommand extends Command
/**
* (non-PHPdoc).
*
* @see \Symfony\Component\Console\Command\Command::configure()
* @see Command::configure()
*/
protected function configure()
{
@@ -73,7 +73,7 @@ class LoadAndUpdateLanguagesCommand extends Command
/**
* (non-PHPdoc).
*
* @see \Symfony\Component\Console\Command\Command::execute()
* @see Command::execute()
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{

View File

@@ -51,7 +51,7 @@ class LoadCountriesCommand extends Command
/**
* (non-PHPdoc).
*
* @see \Symfony\Component\Console\Command\Command::configure()
* @see Command::configure()
*/
protected function configure()
{
@@ -61,7 +61,7 @@ class LoadCountriesCommand extends Command
/**
* (non-PHPdoc).
*
* @see \Symfony\Component\Console\Command\Command::execute()
* @see Command::execute()
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{

View File

@@ -20,7 +20,7 @@ class SearchableServicesCompilerPass implements CompilerPassInterface
/**
* (non-PHPdoc).
*
* @see \Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface::process()
* @see CompilerPassInterface::process()
*/
public function process(ContainerBuilder $container)
{

View File

@@ -32,7 +32,7 @@ abstract class AbstractWidgetFactory implements WidgetFactoryInterface
* Will create the definition by returning the definition from the `services.yml`
* file (or `services.xml` or `what-you-want.yml`).
*
* @see \Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface::createDefinition()
* @see WidgetFactoryInterface::createDefinition()
*/
public function createDefinition(ContainerBuilder $containerBuilder, $place, $order, array $config)
{

View File

@@ -1,3 +1,38 @@
<template>
<WaitingScreen :state="state">
<template v-slot:pending>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p>
</template>
<template v-slot:stopped>
<p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p>
</template>
<template v-slot:failure>
<p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p>
</template>
<template v-slot:ready>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p>
<p v-if="storedObject !== null">
<document-action-buttons-group
:stored-object="storedObject"
:filename="filename"
></document-action-buttons-group>
</p>
</template>
</WaitingScreen>
</template>
<script setup lang="ts">
import {
trans,
@@ -87,38 +122,3 @@ onMounted(() => {
onObjectNewStatusCallback();
});
</script>
<template>
<WaitingScreen :state="state">
<template v-slot:pending>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p>
</template>
<template v-slot:stopped>
<p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p>
</template>
<template v-slot:failure>
<p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p>
</template>
<template v-slot:ready>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p>
<p v-if="storedObject !== null">
<document-action-buttons-group
:stored-object="storedObject"
:filename="filename"
></document-action-buttons-group>
</p>
</template>
</WaitingScreen>
</template>

View File

@@ -115,7 +115,7 @@
</teleport>
</template>
<script setup lang="ts">
import { ref, computed, defineEmits, defineProps, useTemplateRef } from "vue";
import { ref, computed, useTemplateRef } from "vue";
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import OnTheFlyCreate from "./Create.vue";
import OnTheFlyPerson from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
@@ -144,6 +144,7 @@ import {
} from "translator";
import PersonEdit from "ChillPersonAssets/vuejs/_components/OnTheFly/PersonEdit.vue";
import ThirdPartyEdit from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdPartyEdit.vue";
import { Person } from "ChillPersonAssets/types";
// Types
type EntityType = "person" | "thirdparty";
@@ -181,7 +182,7 @@ const emit =
defineEmits<
(
e: "saveFormOnTheFly",
payload: { type: string | undefined; data: any },
payload: { type: string | undefined; data: Person },
) => void
>();
@@ -331,12 +332,12 @@ function buildLocation(
async function saveAction() {
if (props.type === "person") {
const person = await castEditPerson.value?.postPerson();
if (null !== person) {
if (person) {
emit("saveFormOnTheFly", { type: props.type, data: person });
}
} else if (props.type === "thirdparty") {
const thirdParty = await castEditThirdParty.value?.postThirdParty();
if (null !== thirdParty) {
if (thirdParty) {
emit("saveFormOnTheFly", { type: props.type, data: thirdParty });
}
}

View File

@@ -76,14 +76,7 @@
</template>
<script lang="ts" setup>
import {
ref,
computed,
defineProps,
defineEmits,
defineComponent,
withDefaults,
} from "vue";
import { ref, computed, defineComponent } from "vue";
import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue";
import {
Entities,
@@ -102,7 +95,6 @@ import {
USER_CURRENT_USER,
trans,
} from "translator";
import { addNewEntities } from "ChillMainAssets/types";
defineComponent({
components: {

View File

@@ -23,9 +23,7 @@ interface SavedExportButtonGenerateConfig {
}
const props = defineProps<SavedExportButtonGenerateConfig>();
const emits = defineEmits<{
(e: "generate");
}>();
const emits = defineEmits<(e: "generate") => void>();
const toast = useToast();
const exportGeneration = ref<ExportGeneration | null>(null);
@@ -157,7 +155,7 @@ const onClickGenerate = async (): Promise<void> => {
<span>{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}</span>
</div>
<download-button
v-else-if="isReady && storedObject?.currentVersion !== null"
v-else-if="isReady && storedObject && storedObject?.currentVersion !== null"
:classes="buttonClasses"
:stored-object="storedObject"
:at-version="storedObject?.currentVersion"

View File

@@ -17,7 +17,8 @@
:address="data.working_address"
@update-address="onUpdateAddress"
ref="address_modal"
></AddressModal>
v-else-if="data.working_address"
/>
</template>
<script lang="ts" setup>

View File

@@ -20,8 +20,8 @@
<template
v-if="
props.address.addressReference.street !== props.address.street ||
props.address.addressReference.streetNumber !==
props.address.addressReference?.street !== props.address.street ||
props.address.addressReference?.streetNumber !==
props.address.streetNumber
"
>
@@ -31,8 +31,8 @@
>{{ props.address.street }} {{ props.address.streetNumber }}</span
>
<span class="new"
>{{ props.address.addressReference.street }}
{{ props.address.addressReference.streetNumber }}</span
>{{ props.address.addressReference?.street }}
{{ props.address.addressReference?.streetNumber }}</span
>
</div>
</template>
@@ -42,8 +42,8 @@
>{{ props.address.streetNumber }} {{ props.address.street }}</span
>
<span class="new"
>{{ props.address.addressReference.streetNumber }}
{{ props.address.addressReference.street }}</span
>{{ props.address.addressReference?.streetNumber }}
{{ props.address.addressReference?.street }}</span
>
</div>
</template>
@@ -51,7 +51,7 @@
<template
v-if="
props.address.addressReference.postcode.id !==
props.address.addressReference?.postcode.id !==
props.address.postcode.id
"
>
@@ -61,8 +61,8 @@
{{ props.address.postcode.name }}</span
>
<span class="new"
>{{ props.address.addressReference.postcode.code }}
{{ props.address.addressReference.postcode.name }}</span
>{{ props.address.addressReference?.postcode.code }}
{{ props.address.addressReference?.postcode.name }}</span
>
</div>
</template>
@@ -71,9 +71,9 @@
v-if="
props.address.point !== null &&
(props.address.point.coordinates[0] !==
props.address.addressReference.point.coordinates[0] ||
props.address.addressReference?.point.coordinates[0] ||
props.address.point.coordinates[1] !==
props.address.addressReference.point.coordinates[1])
props.address.addressReference?.point.coordinates[1])
"
>
<div class="difference">
@@ -82,8 +82,8 @@
{{ props.address.point.coordinates[1] }}</span
>
<span class="new"
>{{ props.address.addressReference.point.coordinates[0] }}
{{ props.address.addressReference.point.coordinates[1] }}</span
>{{ props.address.addressReference?.point.coordinates[0] }}
{{ props.address.addressReference?.point.coordinates[1] }}</span
>
</div>
</template>

View File

@@ -1,3 +1,26 @@
<template>
<div>
<div v-if="'rich' === kind">
<Ckeditor
:editor="ClassicEditor"
:config="classicEditorConfig"
v-model="value"
/>
</div>
<div v-else>
<textarea v-model="value" class="form-control simple-editor"></textarea>
</div>
<button :class="toggleButtonClass" type="button" @click="toggleEditor">
{{
isSimple
? trans(EDITOR_SWITCH_TO_COMPLEX)
: trans(EDITOR_SWITCH_TO_SIMPLE)
}}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
@@ -63,29 +86,6 @@ onUnmounted(function () {
});
</script>
<template>
<div>
<div v-if="'rich' === kind">
<Ckeditor
:editor="ClassicEditor"
:config="classicEditorConfig"
v-model="value"
/>
</div>
<div v-else>
<textarea v-model="value" class="form-control simple-editor"></textarea>
</div>
<button :class="toggleButtonClass" type="button" @click="toggleEditor">
{{
isSimple
? trans(EDITOR_SWITCH_TO_COMPLEX)
: trans(EDITOR_SWITCH_TO_SIMPLE)
}}
</button>
</div>
</template>
<style scoped lang="scss">
@import "ChillMainAssets/module/bootstrap/bootstrap";

View File

@@ -104,19 +104,19 @@ interface PickWorkflowConfig {
const props = withDefaults(defineProps<PickWorkflowConfig>(), {
preventDefaultMoveToGenerate: false,
goToGenerateWorkflowPayload: {},
goToGenerateWorkflowPayload: () => ({}),
allowCreateWorkflow: false,
});
const emit = defineEmits<{
(
e: "goToGenerateWorkflow",
{
event: MouseEvent,
workflowName: string,
isLinkValid: boolean,
link: string,
payload: object,
payload: {
event: MouseEvent;
workflowName: string;
isLinkValid: boolean;
link: string;
payload: object;
},
): void;
(e: "clickOpenList"): void;
@@ -159,11 +159,6 @@ const goToGenerateWorkflow = (
payload: props.goToGenerateWorkflowPayload,
});
};
const goToDuplicateRelatedEntity = (
event: MouseEvent,
workflowName: string,
): void => {};
</script>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<template>
<transition name="modal">
<div class="modal-mask">
<div class="modal-mask" v-if="props.show">
<!-- :: styles bootstrap :: -->
<div
class="modal fade show"
@@ -47,13 +47,14 @@
* [+] modal design can be configured using css classes (size, scroll)
*/
import { trans, MODAL_ACTION_CLOSE } from "translator";
import { defineProps } from "vue";
import type { VNode } from "vue";
defineSlots<{
header?: () => any;
body?: () => any;
footer?: () => any;
"body-head"?: () => any;
header?: () => VNode;
body?: () => VNode;
footer?: () => VNode;
"body-head"?: () => VNode;
}>();
export interface ModalProps {

View File

@@ -2,6 +2,10 @@
<p>
{{ 'This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>'|trans|raw }}
<br/>
{% if get_chill_version() %}
{{ 'footer.Running chill version %version%'|trans({ '%version%': get_chill_version() }) }}
{% endif %}
<br/>
<a name="bottom" class="btn text-white" href="https://gitea.champs-libres.be/Chill-project/manuals/releases" target="_blank">
{{ 'User manual'|trans }}
</a>

View File

@@ -33,7 +33,7 @@ final readonly class ScopeResolverDispatcher
}
/**
* @return Scope|iterable<Scope>|Scope|null
* @return Scope|iterable<Scope>|null
*/
public function resolveScope(mixed $entity, ?array $options = []): iterable|Scope|null
{

View File

@@ -23,7 +23,7 @@ class AddressReferenceFromBAN
private readonly AddressToReferenceMatcher $addressToReferenceMatcher,
) {}
public function import(string $departementNo, ?string $sendAddressReportToEmail = null): void
public function import(string $departementNo, ?string $sendAddressReportToEmail = null, ?bool $allowRemoveDoubleRefId = false): void
{
if (!is_numeric($departementNo)) {
throw new \UnexpectedValueException('Could not parse this department number');
@@ -96,7 +96,7 @@ class AddressReferenceFromBAN
);
}
$this->baseImporter->finalize(sendAddressReportToEmail: $sendAddressReportToEmail);
$this->baseImporter->finalize(allowRemoveDoubleRefId: $allowRemoveDoubleRefId, sendAddressReportToEmail: $sendAddressReportToEmail);
$this->addressToReferenceMatcher->checkAddressesMatchingReferences();

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\MainBundle\Service;
use Composer\InstalledVersions;
readonly class VersionProvider
{
public function __construct(private string $packageName) {}
public function getVersion(): string
{
try {
$version = InstalledVersions::getPrettyVersion($this->packageName);
if (null === $version) {
return 'unknown';
}
return $version;
} catch (\OutOfBoundsException) {
return 'unknown';
}
}
public function getFormattedVersion(): string
{
$version = $this->getVersion();
if ('unknown' === $version) {
return 'Version unavailable';
}
return $version;
}
}

View File

@@ -23,7 +23,7 @@ class CancelStaleWorkflowCronJob implements CronJobInterface
{
public const KEY = 'remove-stale-workflow';
public const KEEP_INTERVAL = 'P90D';
public const KEEP_INTERVAL = 'P180D';
private const LAST_CANCELED_WORKFLOW = 'last-canceled-workflow-id';

View File

@@ -0,0 +1,35 @@
<?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\Templating;
use Chill\MainBundle\Service\VersionProvider;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class VersionRenderExtension extends AbstractExtension
{
public function __construct(
private readonly VersionProvider $versionProvider,
) {}
public function getFunctions(): array
{
return [
new TwigFunction('get_chill_version', $this->getChillVersion(...)),
];
}
public function getChillVersion(): string
{
return $this->versionProvider->getFormattedVersion();
}
}

View File

@@ -42,14 +42,14 @@ class CancelStaleWorkflowHandlerTest extends TestCase
{
use ProphecyTrait;
public function testWorkflowWithOneStepOlderThan90DaysIsCanceled(): void
public function testWorkflowWithOneStepOlderThan180DaysIsCanceled(): void
{
$clock = new MockClock('2024-01-01');
$daysAgos = new \DateTimeImmutable('2023-09-01');
$daysAgos = new \DateTimeImmutable('2023-06-01');
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$workflow->setCreatedAt(new \DateTimeImmutable('2023-06-01'));
$workflow->setStep('step1', new WorkflowTransitionContextDTO($workflow), 'to_step1', $daysAgos, new User());
$em = $this->prophesize(EntityManagerInterface::class);
@@ -94,7 +94,7 @@ class CancelStaleWorkflowHandlerTest extends TestCase
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$workflow->setCreatedAt(new \DateTimeImmutable('2023-06-01'));
$em = $this->prophesize(EntityManagerInterface::class);
$em->flush()->shouldBeCalled();

View File

@@ -15,7 +15,9 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EventSubscriber\NotificationOnTransition;
@@ -87,7 +89,7 @@ final class NotificationOnTransitionTest extends TestCase
->willReturn([]);
$registry = $this->prophesize(Registry::class);
$registry->get(Argument::type(EntityWorkflow::class), Argument::type('string'))
$registry->get(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn($workflow);
$security = $this->prophesize(Security::class);
@@ -111,4 +113,74 @@ final class NotificationOnTransitionTest extends TestCase
$notificationOnTransition->onCompletedSendNotification($event);
}
public function testOnCompleteDoNotSendNotificationIfStepCreatedByPreviousSignature(): void
{
$dest = new User();
$currentUser = new User();
$workflowProphecy = $this->prophesize(WorkflowInterface::class);
$workflow = $workflowProphecy->reveal();
$entityWorkflow = new EntityWorkflow();
$entityWorkflow
->setWorkflowName('workflow_name')
->setRelatedEntityClass(\stdClass::class)
->setRelatedEntityId(1);
// force an id to entityWorkflow:
$reflection = new \ReflectionClass($entityWorkflow);
$id = $reflection->getProperty('id');
$id->setValue($entityWorkflow, 1);
$previousStep = new EntityWorkflowStep();
$previousStep->addSignature($signature = new EntityWorkflowStepSignature($previousStep, $dest));
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
$currentStep = new EntityWorkflowStep();
$currentStep->addDestUser($dest);
$currentStep->setCurrentStep('to_state');
$entityWorkflow->addStep($previousStep);
$entityWorkflow->addStep($currentStep);
$em = $this->prophesize(EntityManagerInterface::class);
// we check that NO notification has been persisted for $dest
$em->persist(Argument::that(
fn ($notificationCandidate) => $notificationCandidate instanceof Notification && $notificationCandidate->getAddressees()->contains($dest)
))->shouldNotBeCalled();
$engine = $this->prophesize(\Twig\Environment::class);
$engine->render(Argument::type('string'), Argument::type('array'))
->willReturn('dummy text');
$extractor = $this->prophesize(MetadataExtractor::class);
$extractor->buildArrayPresentationForPlace(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn([]);
$extractor->buildArrayPresentationForWorkflow(Argument::any())
->willReturn([]);
$registry = $this->prophesize(Registry::class);
$registry->get(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn($workflow);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn(null);
$entityWorkflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$entityWorkflowHandler->getEntityTitle($entityWorkflow)->willReturn('workflow title');
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getHandler($entityWorkflow)->willReturn($entityWorkflowHandler->reveal());
$notificationOnTransition = new NotificationOnTransition(
$em->reveal(),
$engine->reveal(),
$extractor->reveal(),
$security->reveal(),
$registry->reveal(),
$entityWorkflowManager->reveal(),
);
$event = new Event($entityWorkflow, new Marking(), new Transition('dummy_transition', ['from_state'], ['to_state']), $workflow);
$notificationOnTransition->onCompletedSendNotification($event);
}
}

View File

@@ -11,9 +11,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
@@ -269,217 +266,7 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
}
/**
* @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowReadByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'Abstain: there is a signature for person, but the attachment is not concerned'];
}
/**
* @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowWriteByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because there is no workflow'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user (and attachment)'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user was not a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: user was a previous user, but it is finalized positive'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: user was a previous user, it is finalized, but finalized negative'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but the signature is not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature on a canceled workflow'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: the workflow is sent to an external user'];
}
/**
* @param list<EntityWorkflow> $entityWorkflows
*/
private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled();
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachments = [];
foreach ($entityWorkflows as $entityWorkflow) {
$attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject());
}
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
}
private static function buildRegistry(): Registry

View File

@@ -103,7 +103,10 @@ class NotificationOnTransition implements EventSubscriberInterface
foreach ($dests as $subscriber) {
if (
// prevent to send a notification to the one who created the step
$this->security->getUser() === $subscriber
// prevent to send a notification if the user applyied a signature on the previous step
|| $this->isStepCreatedByPreviousSignature($entityWorkflow, $subscriber)
) {
continue;
}
@@ -131,4 +134,31 @@ class NotificationOnTransition implements EventSubscriberInterface
$this->entityManager->persist($notification);
}
}
/**
* Checks if the current step in the workflow was created by a previous signature of the specified user.
*
* This method retrieves the current step of the workflow and its preceding step. It iterates through
* the signatures of the preceding step to verify if the provided user is the signer of any of those
* signatures. Returns true if the user matches any signer; otherwise, returns false.
*
* @param EntityWorkflow $entityWorkflow the workflow entity containing the current step and its details
* @param User $user the user to check against the signatures of the previous step in the workflow
*
* @return bool true if the specified user created the step via a previous signature, false otherwise
*/
private function isStepCreatedByPreviousSignature(EntityWorkflow $entityWorkflow, User $user): bool
{
$step = $entityWorkflow->getCurrentStepChained();
$previous = $step->getPrevious();
foreach ($previous->getSignatures() as $signature) {
if ($signature->getSigner() === $user) {
return true;
}
}
return false;
}
}

View File

@@ -11,12 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security;
@@ -52,48 +49,28 @@ use Symfony\Component\Workflow\Registry;
* the workflow denys write operations;
* - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted;
*/
class WorkflowRelatedEntityPermissionHelper
final readonly class WorkflowRelatedEntityPermissionHelper implements WorkflowRelatedEntityPermissionHelperInterface
{
public const FORCE_GRANT = 'FORCE_GRANT';
public const FORCE_DENIED = 'FORCE_DENIED';
public const ABSTAIN = 'ABSTAIN';
public function __construct(
private readonly Security $security,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly Registry $registry,
private readonly ClockInterface $clock,
private Security $security,
private EntityWorkflowManager $entityWorkflowManager,
private Registry $registry,
private ClockInterface $clock,
) {}
/**
* @param object $entity The entity may be an
* @param object $entity The entity may be an object that is a related entity of a workflow, or an EntityWorkflow itself
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForReadOperation(object $entity): string
{
if ($entity instanceof StoredObject) {
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
$isAttached = true;
} else {
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$isAttached = false;
}
if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
$entityWorkflows = $entity instanceof EntityWorkflow ? [$entity] : $this->entityWorkflowManager->findByRelatedEntity($entity);
if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) {
return self::FORCE_GRANT;
}
if ($isAttached) {
return self::ABSTAIN;
}
// give a view permission if there is a Person signature pending, or in the 12 hours following
// the signature last state
foreach ($entityWorkflows as $workflow) {
@@ -117,24 +94,20 @@ class WorkflowRelatedEntityPermissionHelper
}
/**
* @param object $entity the entity may be an object which is the related entity of a workflow
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForWriteOperation(object $entity): string
{
if ($entity instanceof StoredObject) {
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
$isAttached = true;
} else {
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$isAttached = false;
}
$entityWorkflows = $entity instanceof EntityWorkflow ? [$entity] : $this->entityWorkflowManager->findByRelatedEntity($entity);
if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
// if a workflow is finalized positive, anyone is allowed to edit the document anymore
// if a workflow is finalized positive or isSentExternal, no one is allowed to edit the document anymore
foreach ($entityWorkflows as $entityWorkflow) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
@@ -147,39 +120,64 @@ class WorkflowRelatedEntityPermissionHelper
// the workflow is final, and final positive, or is sentExternal, so we stop here.
return self::FORCE_DENIED;
}
if (
// if not finalized positive
$entityWorkflow->isFinal() && !($placeMetadata['isFinalPositive'] ?? false)
) {
return self::ABSTAIN;
}
}
}
$runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal());
// if there is a signature on a **running workflow**, no one is allowed edit anymore, except if the workflow is canceled
foreach ($entityWorkflows as $entityWorkflow) {
// if the workflow is canceled, we ignore it
$isFinalNegative = false;
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (isset($placeMetadata['isFinalPositive']) && false === $placeMetadata['isFinalPositive']) {
$isFinalNegative = true;
}
}
}
if ($isFinalNegative) {
continue;
}
// if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore
if (!$isAttached) {
foreach ($runningWorkflows as $entityWorkflow) {
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return self::FORCE_DENIED;
}
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return self::FORCE_DENIED;
}
}
}
}
// if all workflows are finalized negative (= canceled), we should abstain
$runningWorkflows = [];
foreach ($entityWorkflows as $entityWorkflow) {
$isFinalNegative = false;
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (isset($placeMetadata['isFinalPositive']) && false === $placeMetadata['isFinalPositive']) {
$isFinalNegative = true;
}
}
}
if (!$isFinalNegative) {
$runningWorkflows[] = $entityWorkflow;
}
}
if ([] === $runningWorkflows) {
return self::ABSTAIN;
}
// allow only the users involved
if ($this->isUserInvolvedInAWorkflow($runningWorkflows)) {
return self::FORCE_GRANT;
}
if ($isAttached) {
return self::ABSTAIN;
}
return self::FORCE_DENIED;
}

View File

@@ -0,0 +1,63 @@
<?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\Workflow\Helper;
/**
* Helper to give supplementary permissions to a related entity.
*
* If a related entity is associated within a workflow, the logic of the workflow can give more permissions, or
* remove some permissions.
*
* The methods of this helper return either:
*
* - FORCE_GRANT, which means that a permission can be given, even if it would be denied when the related
* entity is not associated with a workflow;
* - FORCE_DENIED, which means that a permission should be denied, even if it would be granted when the related entity
* is not associated with a workflow
* - ABSTAIN, if there is no workflow logic to add or remove permission
*
* For read operations:
*
* - if the user is involved in the workflow (is part of the current step, of a step before), the user is granted read
* operation;
* - if there is a pending signature for a person, the workflow grant access to the related entity;
* - if there a signature applyied in less than 12 hours, the workflow grant access to the related entity. This allow to
* show the related entity to the person during this time frame.
*
*
* For write operation:
*
* - if the workflow is finalized "positive" (means "not canceled"), the workflow denys write operations;
* - if there isn't any finalized "positive" workflow, and if there is a signature appliyed for a running workflow (not finalized nor canceled),
* the workflow denys write operations;
* - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted;
*/
interface WorkflowRelatedEntityPermissionHelperInterface
{
public const FORCE_GRANT = 'FORCE_GRANT';
public const FORCE_DENIED = 'FORCE_DENIED';
public const ABSTAIN = 'ABSTAIN';
/**
* @param object $entity The entity may be an object that is a related entity of a workflow, or an EntityWorkflow itself
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForReadOperation(object $entity): string;
/**
* @param object $entity the entity may be an object which is the related entity of a workflow
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForWriteOperation(object $entity): string;
}

View File

@@ -115,3 +115,7 @@ services:
$vienEntityInfoProviders: !tagged_iterator chill_main.entity_info_provider
Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler: ~
Chill\MainBundle\Service\VersionProvider:
arguments:
$packageName: 'chill-project/chill-bundles'

View File

@@ -66,3 +66,7 @@ services:
resource: './../../Templating/Listing'
Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface: '@Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory'
Chill\MainBundle\Templating\VersionRenderExtension:
tags:
- { name: twig.extension }

View File

@@ -48,6 +48,9 @@ See: Voir
Name: Nom
Label: Nom
footer:
Running chill version %version%: "Version de Chill: %version%"
user:
current_user: Utilisateur courant
profile:

View File

@@ -13,14 +13,63 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\EvaluationRepository;
use Symfony\Component\HttpFoundation\Request;
class EvaluationController extends CRUDController
{
public function __construct(private readonly EvaluationRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredEvaluations($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredEvaluations($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredEvaluations(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\GoalRepository;
use Symfony\Component\HttpFoundation\Request;
class GoalController extends CRUDController
{
public function __construct(private readonly GoalRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredGoals($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredGoals(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredGoals(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\ResultRepository;
use Symfony\Component\HttpFoundation\Request;
class ResultController extends CRUDController
{
public function __construct(private readonly ResultRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredResults($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredResults(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredResults(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\SocialActionRepository;
use Symfony\Component\HttpFoundation\Request;
class SocialActionController extends CRUDController
{
public function __construct(private readonly SocialActionRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.ordering', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredSocialActions($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredSocialActions(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredSocialActions(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,11 +13,15 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\SocialIssueRepository;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
class SocialIssueController extends CRUDController
{
public function __construct(private readonly SocialIssueRepository $repository) {}
protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface
{
if ('new' === $action) {
@@ -37,4 +41,54 @@ class SocialIssueController extends CRUDController
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredSocialIssues($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredSocialIssues(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredSocialIssues(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -38,7 +38,7 @@ class SocialWorkEvaluationApiController extends AbstractController
$pagination->getCurrentPageFirstItemNumber(),
$pagination->getItemsPerPage()
);
$collection = new Collection($evaluations, $pagination);
$collection = new Collection(array_values($evaluations), $pagination);
return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['read']]);
}

View File

@@ -25,14 +25,15 @@ class SocialWorkGoalApiController extends ApiController
public function listBySocialAction(Request $request, SocialAction $action): Response
{
$totalItems = $this->goalRepository->countBySocialActionWithDescendants($action);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$totalItems = $this->goalRepository->countBySocialActionWithDescendants($action, true);
$paginator = $this->paginator->create($totalItems);
$entities = $this->goalRepository->findBySocialActionWithDescendants(
$action,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true
);
$model = new Collection($entities, $paginator);

View File

@@ -25,14 +25,15 @@ class SocialWorkResultApiController extends ApiController
public function listByGoal(Request $request, Goal $goal): Response
{
$totalItems = $this->resultRepository->countByGoal($goal);
$totalItems = $this->resultRepository->countByGoal($goal, true);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$entities = $this->resultRepository->findByGoal(
$goal,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true,
);
$model = new Collection($entities, $paginator);
@@ -42,14 +43,15 @@ class SocialWorkResultApiController extends ApiController
public function listBySocialAction(Request $request, SocialAction $action): Response
{
$totalItems = $this->resultRepository->countBySocialActionWithDescendants($action);
$totalItems = $this->resultRepository->countBySocialActionWithDescendants($action, true);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$entities = $this->resultRepository->findBySocialActionWithDescendants(
$action,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true
);
$model = new Collection($entities, $paginator);

View File

@@ -19,6 +19,7 @@ use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class CenterAggregator implements AggregatorInterface
{
@@ -27,6 +28,7 @@ final readonly class CenterAggregator implements AggregatorInterface
public function __construct(
private CenterRepositoryInterface $centerRepository,
private RollingDateConverterInterface $rollingDateConverter,
private TranslatorInterface $translator,
) {}
public function buildForm(FormBuilderInterface $builder): void
@@ -62,7 +64,7 @@ final readonly class CenterAggregator implements AggregatorInterface
{
return function (int|string|null $value) {
if (null === $value || '' === $value) {
return '';
return $this->translator->trans('person.export.aggregator.by_center.no_center');
}
if ('_header' === $value) {
@@ -94,15 +96,18 @@ final readonly class CenterAggregator implements AggregatorInterface
$atDate = 'pers_center_agg_at_date';
$qb->leftJoin('person.centerHistory', $alias);
$qb
->andWhere(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
)->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.id'),
$qb->expr()->andX(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
)
)
);
)
);
$qb->setParameter($atDate, $this->rollingDateConverter->convert($data['at_date']));
$qb->addSelect("IDENTITY({$alias}.center) AS ".self::COLUMN_NAME);

View File

@@ -44,12 +44,10 @@ final readonly class FilterListAccompanyingPeriodHelper implements FilterListAcc
};
// add filtering on confidential accompanying period. The confidential is applyed on the current status of
// the accompanying period (we do not use the 'calc_date' here
$aclConditionsOrX = $qb->expr()->orX(
// either the current user is the refferer for the course
'acp.user = :list_acp_current_user',
);
$qb->setParameter('list_acp_current_user', $user);
// the accompanying period (we do not use the 'calc_date' here)
//
// IMPORTANT: we must NOT bypass selected centers just because the current user is the referrer.
$aclConditionsOrX = $qb->expr()->orX();
$i = 0;
foreach ($centers as $center) {
@@ -93,6 +91,12 @@ final readonly class FilterListAccompanyingPeriodHelper implements FilterListAcc
++$i;
}
$qb->andWhere($aclConditionsOrX);
// Prevent invalid/empty WHERE when no conditions were added (e.g., no centers available)
if (0 === count($aclConditionsOrX->getParts())) {
// No allowed conditions => return no rows
$qb->andWhere('1 = 0');
} else {
$qb->andWhere($aclConditionsOrX);
}
}
}

View File

@@ -100,9 +100,9 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
$rsm->addRootEntityFromClassMetadata(AccompanyingPeriodWork::class, 'w');
$sql = "SELECT {$rsm} FROM chill_person_accompanying_period_work w
LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id
AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE)
WHERE accompanyingPeriod_id = :periodId";
LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id
AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE)
WHERE accompanyingPeriod_id = :periodId";
// implement filters
@@ -136,11 +136,14 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
}
// set limit and offset
$sql .= " ORDER BY
CASE WHEN w.enddate IS NULL THEN '-infinity'::timestamp ELSE 'infinity'::timestamp END ASC,
w.startdate DESC,
w.enddate DESC,
w.id DESC";
$sql .= ' ORDER BY
CASE
WHEN w.enddate IS NULL OR w.enddate > CURRENT_DATE THEN 0
ELSE 1
END ASC,
w.startdate DESC,
w.enddate DESC,
w.id DESC';
$sql .= ' LIMIT :limit OFFSET :offset';

View File

@@ -14,12 +14,16 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\Evaluation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class EvaluationRepository implements EvaluationRepositoryInterface
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(Evaluation::class);
}
@@ -65,4 +69,86 @@ final readonly class EvaluationRepository implements EvaluationRepositoryInterfa
{
return Evaluation::class;
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('e');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('e.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Evaluation::class, 'e');
// Extract the current locale's value from the JSON `title` and search on it
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(e.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Evaluation::class, 'e');
}
// Add condition based on active/inactive status
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere('e.active = true');
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere('e.active = false');
}
return $qb;
}
public function findFilteredEvaluations(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['title' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredEvaluations(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(e)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
}

View File

@@ -15,21 +15,28 @@ use Chill\PersonBundle\Entity\SocialWork\Goal;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Clock\ClockInterface;
final readonly class GoalRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
public function __construct(
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private RequestStack $requestStack,
) {
$this->repository = $entityManager->getRepository(Goal::class);
}
public function countBySocialActionWithDescendants(SocialAction $action): int
public function countBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): int
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('COUNT(g)');
return $qb
@@ -64,9 +71,9 @@ final readonly class GoalRepository implements ObjectRepository
/**
* @return Goal[]
*/
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null): array
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('g');
$qb->andWhere(
@@ -101,7 +108,103 @@ final readonly class GoalRepository implements ObjectRepository
return Goal::class;
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action): QueryBuilder
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('g');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('g.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Goal::class, 'g');
// search across locales by extracting the localized value
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(g.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Goal::class, 'g');
}
// Active when desactivationDate is null or in the future
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('g.desactivationDate'),
$qb->expr()->gt('g.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('g.desactivationDate'),
$qb->expr()->lte('g.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, Goal>
*/
public function findFilteredGoals(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['id' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredGoals(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(g)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action, bool $onlyActive): QueryBuilder
{
$actions = $action->getDescendantsWithThis();
@@ -116,6 +219,11 @@ final readonly class GoalRepository implements ObjectRepository
}
$qb->where($orx);
if ($onlyActive) {
$qb->andWhere('g.desactivationDate > :now OR g.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
}

View File

@@ -16,21 +16,28 @@ use Chill\PersonBundle\Entity\SocialWork\Result;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Clock\ClockInterface;
final readonly class ResultRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
public function __construct(
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private RequestStack $requestStack,
) {
$this->repository = $entityManager->getRepository(Result::class);
}
public function countByGoal(Goal $goal): int
public function countByGoal(Goal $goal, bool $onlyActive = false): int
{
$qb = $this->buildQueryByGoal($goal);
$qb = $this->buildQueryByGoal($goal, $onlyActive);
$qb->select('COUNT(r)');
return $qb
@@ -38,9 +45,9 @@ final readonly class ResultRepository implements ObjectRepository
->getSingleScalarResult();
}
public function countBySocialActionWithDescendants(SocialAction $action): int
public function countBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): int
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('COUNT(r)');
return $qb
@@ -75,9 +82,9 @@ final readonly class ResultRepository implements ObjectRepository
/**
* @return array<Result>
*/
public function findByGoal(Goal $goal, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
public function findByGoal(Goal $goal, ?array $orderBy = null, ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryByGoal($goal);
$qb = $this->buildQueryByGoal($goal, $onlyActive);
if (null !== $orderBy) {
foreach ($orderBy as $sort => $order) {
@@ -96,9 +103,9 @@ final readonly class ResultRepository implements ObjectRepository
/**
* @return Result[]
*/
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null): array
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('r');
foreach ($orderBy as $sort => $order) {
@@ -125,17 +132,116 @@ final readonly class ResultRepository implements ObjectRepository
return Result::class;
}
private function buildQueryByGoal(Goal $goal): QueryBuilder
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('r');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('r.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Result::class, 'r');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(r.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Result::class, 'r');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('r.desactivationDate'),
$qb->expr()->gt('r.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('r.desactivationDate'),
$qb->expr()->lte('r.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, Result>
*/
public function findFilteredResults(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['id' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredResults(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(r)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryByGoal(Goal $goal, bool $onlyActive): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('r');
$qb->where(':goal MEMBER OF r.goals')
->setParameter('goal', $goal);
if ($onlyActive) {
$qb->andWhere('r.desactivationDate > :now OR r.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action): QueryBuilder
private function buildQueryBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): QueryBuilder
{
$actions = $action->getDescendantsWithThis();
@@ -150,6 +256,11 @@ final readonly class ResultRepository implements ObjectRepository
}
$qb->where($orx);
if ($onlyActive) {
$qb->andWhere('r.desactivationDate > :now OR r.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
}

View File

@@ -14,14 +14,17 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class SocialActionRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(SocialAction::class);
}
@@ -84,6 +87,100 @@ final readonly class SocialActionRepository implements ObjectRepository
return SocialAction::class;
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('sa');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('sa.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(SocialAction::class, 'sa');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(sa.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(SocialAction::class, 'sa');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('sa.desactivationDate'),
$qb->expr()->gt('sa.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('sa.desactivationDate'),
$qb->expr()->lte('sa.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, SocialAction>
*/
public function findFilteredSocialActions(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['ordering' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredSocialActions(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(sa)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryWithDesactivatedDateCriteria(): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('sa');

View File

@@ -14,14 +14,17 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class SocialIssueRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(SocialIssue::class);
}
@@ -79,6 +82,100 @@ final readonly class SocialIssueRepository implements ObjectRepository
return SocialIssue::class;
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('si');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('si.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(SocialIssue::class, 'si');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(si.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(SocialIssue::class, 'si');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('si.desactivationDate'),
$qb->expr()->gt('si.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('si.desactivationDate'),
$qb->expr()->lte('si.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, SocialIssue>
*/
public function findFilteredSocialIssues(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['ordering' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredSocialIssues(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(si)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryWithDesactivatedDateCriteria(): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('si');

View File

@@ -308,7 +308,7 @@ export interface AccompanyingPeriodWorkEvaluation {
documents: AccompanyingPeriodWorkEvaluationDocument[];
endDate: DateTime | null;
evaluation: Evaluation | null;
id: number | null;
id: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
key: any;
maxDate: DateTime | null;
@@ -445,31 +445,31 @@ export type EntitiesOrMe = "me" | Entities;
export function isSuggestionForUserGroup(
s: Suggestion,
): s is Suggestion & { result: UserGroup } {
return (s as any)?.result?.type === "user_group";
return (s as Suggestion)?.result?.type === "user_group";
}
export function isSuggestionForUser(
s: Suggestion,
): s is Suggestion & { result: User } {
return (s as any)?.result?.type === "user";
return (s as Suggestion)?.result?.type === "user";
}
export function isSuggestionForPerson(
s: Suggestion,
): s is Suggestion & { result: Person } {
return (s as any)?.result?.type === "person";
return (s as Suggestion)?.result?.type === "person";
}
export function isSuggestionForThirdParty(
s: Suggestion,
): s is Suggestion & { result: Thirdparty } {
return (s as any)?.result?.type === "thirdparty";
return (s as Suggestion)?.result?.type === "thirdparty";
}
export function isSuggestionForHousehold(
s: Suggestion,
): s is Suggestion & { result: Household } {
return (s as any)?.result?.type === "household";
return (s as Suggestion)?.result?.type === "household";
}
export type AddPersonResult = Entities & {

View File

@@ -8,8 +8,8 @@
:editor="ClassicEditor"
:config="classicEditorConfig"
:placeholder="trans(EVALUATION_COMMENT_PLACEHOLDER)"
:value="comment"
@input="$emit('update:comment', $event)"
:model-value="comment"
@update:model-value="$emit('update:comment', $event)"
tag-name="textarea"
></ckeditor>
</div>

View File

@@ -18,7 +18,7 @@
{{ trans(ACCOMPANYING_COURSE_WORK_START_DATE) }}
:
</span>
<b>{{ formatDate(eval.startDate) }}</b>
<b v-if="eval.startDate">{{ formatDate(eval.startDate) }}</b>
</li>
<li v-if="eval.endDate">
@@ -26,7 +26,7 @@
{{ trans(ACCOMPANYING_COURSE_WORK_END_DATE) }}
:
</span>
<b>{{ formatDate(eval.endDate) }}</b>
<b v-if="eval.endDate">{{ formatDate(eval.endDate) }}</b>
</li>
</ul>
</span>

View File

@@ -23,7 +23,7 @@
<script setup lang="ts">
import { AccompanyingPeriodWorkEvaluation } from "../../../types";
import { defineProps, ref, watch } from "vue";
import { ref, watch } from "vue";
import AccompanyingPeriodWorkEvaluationItem from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationItem.vue";
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -12,7 +12,7 @@
</span>
<ul class="small_in_title columns mt-1">
<li>
<li v-if="acpw.startDate">
<span class="item-key">
{{ trans(ACCOMPANYING_COURSE_WORK_START_DATE) }}
:
@@ -47,9 +47,11 @@ import { AccompanyingPeriodWork } from "../../../types";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const props = defineProps<{ acpw: AccompanyingPeriodWork }>();
const formatDate = (dateObject: DateTime) => {
const formatDate = (dateObject: DateTime | string) => {
if (dateObject) {
const parsedDate = ISOToDate(dateObject.datetime);
let isoString =
typeof dateObject === "string" ? dateObject : dateObject.datetime;
const parsedDate = ISOToDate(isoString);
if (parsedDate) {
return new Intl.DateTimeFormat("default", {
dateStyle: "short",

View File

@@ -24,7 +24,7 @@
<script setup lang="ts">
import AccompanyingPeriodWorkItem from "./AccompanyingPeriodWorkItem.vue";
import { AccompanyingPeriodWork } from "../../../types";
import { defineProps, ref, watch } from "vue";
import { ref, watch } from "vue";
const props = defineProps<{
accompanyingPeriodWorks: AccompanyingPeriodWork[];

View File

@@ -12,7 +12,6 @@
</template>
<script setup lang="ts">
import { defineProps } from "vue";
import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
import HouseholdRenderBox from "ChillPersonAssets/vuejs/_components/Entity/HouseholdRenderBox.vue";
import { Suggestion } from "ChillPersonAssets/types";

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