Compare commits

..

7 Commits

Author SHA1 Message Date
74c9eb5585 Rector corrections 2025-09-30 16:23:27 +02:00
f93c7e014f Add test for MyInvitationsController.php 2025-09-30 15:45:26 +02:00
e6a799abc4 Add translation for invitation list page title 2025-09-30 15:30:38 +02:00
68a0ef7115 Reorganize templates to allow re-use of _list.html.twig within listByUser.html.twig template 2025-09-30 15:30:20 +02:00
1675c56f3d Fix order of paginator parameters passed to findBy method 2025-09-30 15:29:41 +02:00
675e8450fc WIP: switch from ACLAware to normal repository usage 2025-09-30 14:34:47 +02:00
4ffd7034d0 feat: add invitation list
- Introduced `MyInvitationsController` for managing user invitations
- Added `InviteACLAwareRepository` and its interface for handling invite data operations
- Created views for listing and displaying user-specific invitations
- Updated user menu to include "My invitations list" option
2025-09-30 14:34:47 +02:00
81 changed files with 1062 additions and 1942 deletions

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Create invitation list in user menu
time: 2025-08-08T12:08:02.446361367+02:00
custom:
Issue: "385"
SchemaChange: No schema change

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
time: 2025-09-18T11:40:44.858533536+02:00
custom:
Issue: "426"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
time: 2025-10-03T22:40:44.685474863+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: 'Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists'
time: 2025-10-06T12:13:15.45905994+02:00
custom:
Issue: "434"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: UX
body: Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively.
time: 2025-10-06T12:39:32.514056818+02:00
custom:
Issue: "425"
SchemaChange: No schema change

View File

@@ -16,5 +16,5 @@
- ajout d'un filtre et regroupement par usager participant sur les échanges - ajout d'un filtre et regroupement par usager participant sur les échanges
- ajout d'un regroupement: par type d'activité associé au parcours; - ajout d'un regroupement: par type d'activité associé au parcours;
- trie les filtre et regroupements par ordre alphabétique dans els exports - trie les filtre et regroupements par ordre alphabétique dans els exports
- ajout d'un paramètre qui permet de désactiver le filtre par territoire dans les exports - ajout d'un paramètre qui permet de désactiver le filtre par centre dans les exports
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date" - correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"

View File

@@ -29,7 +29,7 @@
- ajout d'un regroupement par métier des intervenants sur un parcours; - ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours; - ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours - ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par territoire de l'usager"; - ajout d'un regroupement "par centre de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours"; - ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours"; - ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot); - création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);

View File

@@ -1,13 +0,0 @@
## v4.5.0 - 2025-10-03
### Feature
* Only allow delete of attachment on workflows that are not final
* Move up signature buttons on index workflow page for easier access
* Filter out document from attachment list if it is the same as the workflow document
* Block edition on attached document on workflow, if the workflow is finalized or sent external
* Convert workflow's attached document to pdf while sending them external
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
### Fixed
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
* Fix permissions on storedObject which are subject by a workflow
### DX
* Introduce a WaitingScreen component to display a waiting screen

View File

@@ -1,4 +0,0 @@
## v4.5.1 - 2025-10-03
### Fixed
* Add missing javascript dependency
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf

View File

@@ -6,25 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.5.1 - 2025-10-03
### Fixed
* Add missing javascript dependency
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf
## v4.5.0 - 2025-10-03
### Feature
* Only allow delete of attachment on workflows that are not final
* Move up signature buttons on index workflow page for easier access
* Filter out document from attachment list if it is the same as the workflow document
* Block edition on attached document on workflow, if the workflow is finalized or sent external
* Convert workflow's attached document to pdf while sending them external
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
### Fixed
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
* Fix permissions on storedObject which are subject by a workflow
### DX
* Introduce a WaitingScreen component to display a waiting screen
## v4.4.2 - 2025-09-12 ## v4.4.2 - 2025-09-12
### Fixed ### Fixed
* Fix document generation and workflow generation do not work on accompanying period work documents * Fix document generation and workflow generation do not work on accompanying period work documents
@@ -761,7 +742,7 @@ Fix color of Chill footer
- ajout d'un filtre et regroupement par usager participant sur les échanges - ajout d'un filtre et regroupement par usager participant sur les échanges
- ajout d'un regroupement: par type d'activité associé au parcours; - ajout d'un regroupement: par type d'activité associé au parcours;
- trie les filtre et regroupements par ordre alphabétique dans els exports - trie les filtre et regroupements par ordre alphabétique dans els exports
- ajout d'un paramètre qui permet de désactiver le filtre par territoire dans les exports - ajout d'un paramètre qui permet de désactiver le filtre par centre dans les exports
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date" - correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"
## v2.9.2 - 2023-10-17 ## v2.9.2 - 2023-10-17
@@ -941,7 +922,7 @@ error when trying to reedit a saved export
- ajout d'un regroupement par métier des intervenants sur un parcours; - ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours; - ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours - ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par territoire de l'usager"; - ajout d'un regroupement "par centre de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours"; - ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours"; - ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot); - création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);

View File

@@ -23,8 +23,8 @@ class "Document" {
- text description - text description
- ArrayCollection_DocumentCategory categories - ArrayCollection_DocumentCategory categories
- varchar_150 content #link to openstack - varchar_150 content #link to openstack
- Territoire territoire - Center center
- Service service - Cercle cercle
- User user - User user
- DateTime date # Creation date - DateTime date # Creation date
} }

View File

@@ -38,7 +38,7 @@ Certaines données sont historisées:
- les référents d'un parcours; - les référents d'un parcours;
- les statuts d'un parcours; - les statuts d'un parcours;
- la liaison entre les territoires et les usagers; - la liaison entre les centres et les usagers;
- etc. - etc.
Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable. Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable.

View File

@@ -1,6 +1,6 @@
order,table_schema,table_name,commentaire order,table_schema,table_name,commentaire
1,chill_3party,party_category,Catégorie de tiers 1,chill_3party,party_category,Catégorie de tiers
2,chill_3party,party_center,Association entre les tiers et les territoires (déprécié) 2,chill_3party,party_center,Association entre les tiers et les centres (déprécié)
3,chill_3party,party_profession,Profession du tiers (déprécié) 3,chill_3party,party_profession,Profession du tiers (déprécié)
4,chill_3party,third_party,Tiers 4,chill_3party,third_party,Tiers
5,chill_3party,thirdparty_category,association tiers - catégories 5,chill_3party,thirdparty_category,association tiers - catégories
@@ -54,7 +54,7 @@ order,table_schema,table_name,commentaire
53,public,activitytpresence,Présence aux échanges 53,public,activitytpresence,Présence aux échanges
54,public,activitytype,Types d'échanges 54,public,activitytype,Types d'échanges
55,public,activitytypecategory,Catégories de types d'échanges 55,public,activitytypecategory,Catégories de types d'échanges
56,public,centers,"Territoires (territoires, agences, etc.)" 56,public,centers,"Centres (territoires, agences, etc.)"
57,public,chill_activity_activity_chill_person_socialaction, 57,public,chill_activity_activity_chill_person_socialaction,
58,public,chill_activity_activity_chill_person_socialissue 58,public,chill_activity_activity_chill_person_socialissue
59,public,chill_docgen_template,Gabarits de documents 59,public,chill_docgen_template,Gabarits de documents
@@ -111,7 +111,7 @@ order,table_schema,table_name,commentaire
110,public,chill_person_marital_status,Etats civils 110,public,chill_person_marital_status,Etats civils
111,public,chill_person_not_duplicate, 111,public,chill_person_not_duplicate,
112,public,chill_person_person,Usagers 112,public,chill_person_person,Usagers
113,public,chill_person_person_center_history,Historique des territoires d'un usagers 113,public,chill_person_person_center_history,Historique des centres d'un usagers
114,public,chill_person_persons_to_addresses,Déprécié 114,public,chill_person_persons_to_addresses,Déprécié
115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager 115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager
116,public,chill_person_relations,Types de relations de filiation 116,public,chill_person_relations,Types de relations de filiation
@@ -142,7 +142,7 @@ order,table_schema,table_name,commentaire
141,public,permission_groups 141,public,permission_groups
142,public,permissionsgroup_rolescope 142,public,permissionsgroup_rolescope
143,public,persons_spoken_languages 143,public,persons_spoken_languages
144,public,regroupment,Regroupement de territoires 144,public,regroupment,Regroupement de centres
145,public,regroupment_center, 145,public,regroupment_center,
146,public,role_scopes, 146,public,role_scopes,
147,public,scopes,Services 147,public,scopes,Services
Can't render this file because it has a wrong number of fields in line 28.

View File

@@ -55,7 +55,6 @@
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.3", "@types/leaflet": "^1.9.3",
"@vueuse/core": "^13.9.0",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"dropzone": "^5.7.6", "dropzone": "^5.7.6",
"es6-promise": "^4.2.8", "es6-promise": "^4.2.8",

View File

@@ -10,7 +10,7 @@ Attendee: Présence de l'usager
attendee: présence de l'usager attendee: présence de l'usager
list_reasons: liste des sujets list_reasons: liste des sujets
user_username: nom de l'utilisateur user_username: nom de l'utilisateur
circle_name: nom du service circle_name: nom du cercle
Remark: Commentaire Remark: Commentaire
No comments: Aucun commentaire No comments: Aucun commentaire
Add a new activity: Ajouter une nouvel échange Add a new activity: Ajouter une nouvel échange
@@ -20,7 +20,7 @@ not present: absent
Delete: Supprimer Delete: Supprimer
Update: Mettre à jour Update: Mettre à jour
Update activity: Modifier l'échange Update activity: Modifier l'échange
Scope: Service Scope: Cercle
Activity data: Données de l'échange Activity data: Données de l'échange
Activity location: Localisation de l'échange Activity location: Localisation de l'échange
No reason associated: Aucun sujet No reason associated: Aucun sujet
@@ -398,7 +398,7 @@ export:
sent received: Envoyé ou reçu sent received: Envoyé ou reçu
emergency: Urgence emergency: Urgence
accompanying course id: Identifiant du parcours accompanying course id: Identifiant du parcours
course circles: Services du parcours course circles: Cercles du parcours
travelTime: Durée de déplacement travelTime: Durée de déplacement
durationTime: Durée durationTime: Durée
id: Identifiant id: Identifiant

View File

@@ -177,7 +177,7 @@ export:
agent_id: Utilisateur agent_id: Utilisateur
creator_id: Créateur creator_id: Créateur
main_scope: Service principal de l'utilisateur main_scope: Service principal de l'utilisateur
main_center: Territoire principal de l'utilisateur main_center: Centre principal de l'utilisateur
aside_activity_type: Catégorie d'activité annexe aside_activity_type: Catégorie d'activité annexe
date: Date date: Date
duration: Durée duration: Durée

View File

@@ -266,7 +266,7 @@ class CalendarController extends AbstractController
} }
if (!$this->getUser() instanceof User) { if (!$this->getUser() instanceof User) {
throw new UnauthorizedHttpException('you are not an user'); throw new UnauthorizedHttpException('you are not a user');
} }
$view = '@ChillCalendar/Calendar/listByUser.html.twig'; $view = '@ChillCalendar/Calendar/listByUser.html.twig';

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\Annotation\Route;
class MyInvitationsController extends AbstractController
{
public function __construct(private readonly InviteRepository $inviteRepository, private readonly PaginatorFactory $paginator, private readonly DocGeneratorTemplateRepositoryInterface $docGeneratorTemplateRepository) {}
#[Route(path: '/{_locale}/calendar/invitations/my', name: 'chill_calendar_invitations_list_my')]
public function myInvitations(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new UnauthorizedHttpException('you are not a user');
}
$total = count($this->inviteRepository->findBy(['user' => $user]));
$paginator = $this->paginator->create($total);
$invitations = $this->inviteRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$view = '@ChillCalendar/Invitations/listByUser.html.twig';
return $this->render($view, [
'invitations' => $invitations,
'paginator' => $paginator,
'templates' => $this->docGeneratorTemplateRepository->findByEntity(Calendar::class),
]);
}
}

View File

@@ -30,6 +30,13 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
'order' => 9, 'order' => 9,
'icon' => 'tasks', 'icon' => 'tasks',
]); ]);
$menu->addChild('My invitations list', [
'route' => 'chill_calendar_invitations_list_my',
])
->setExtras([
'order' => 9,
'icon' => 'tasks',
]);
} }
} }

View File

@@ -41,7 +41,7 @@ class InviteRepository implements ObjectRepository
/** /**
* @return array|Invite[] * @return array|Invite[]
*/ */
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{ {
return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset); return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset);
} }

View File

@@ -1,240 +1,229 @@
{# list used in context of person or accompanyingPeriod #} {# list used in context of person, accompanyingPeriod or user #}
{% if calendarItems|length > 0 %} <div class="item-bloc">
<div class="flex-table list-records context-accompanyingCourse"> <div class="item-row main">
<div class="item-col">
<div class="wrap-header">
<div class="wl-row">
<div class="wl-col title">
<p class="date-label">
{% if context == 'person' and calendar.context == 'accompanying_period' %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_course_index', {'accompanying_period_id': calendar.accompanyingPeriod.id}) }}" style="text-decoration: none;">
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ calendar.accompanyingPeriod.id }}
</span>
</a>
{% endif %}
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('short', 'short') }}
{% else %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('none', 'short') }}
{% endif %}
</p>
{% for calendar in calendarItems %} <div class="duration short-message">
<i class="fa fa-fw fa-hourglass-end"></i>
<div class="item-bloc"> {{ calendar.duration|date('%H:%I') }}
<div class="item-row main"> {% if false == calendar.sendSMS or null == calendar.sendSMS %}
<div class="item-col"> <!-- no sms will be send -->
<div class="wrap-header"> {% else %}
<div class="wl-row"> {% if calendar.smsStatus == 'sms_sent' %}
<div class="wl-col title"> <span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<p class="date-label"> <i class="fa fa-check "></i>
{% if context == 'person' and calendar.context == 'accompanying_period' %} <i class="fa fa-envelope "></i>
<a href="{{ chill_path_add_return_path('chill_person_accompanying_course_index', {'accompanying_period_id': calendar.accompanyingPeriod.id}) }}" style="text-decoration: none;"> </span>
<span class="badge bg-primary"> {% else %}
<i class="fa fa-random"></i> {{ calendar.accompanyingPeriod.id }} <span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
</span> <i class="fa fa-envelope "></i>
</a> <i class="fa fa-hourglass-end "></i>
{% endif %} </span>
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %} {% endif %}
{{ calendar.startDate|format_datetime('short', 'short') }} {% endif %}
- {{ calendar.endDate|format_datetime('short', 'short') }}
{% else %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('none', 'short') }}
{% endif %}
</p>
<div class="duration short-message">
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I') }}
{% if false == calendar.sendSMS or null == calendar.sendSMS %}
<!-- no sms will be send -->
{% else %}
{% if calendar.smsStatus == 'sms_sent' %}
<span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<i class="fa fa-check "></i>
<i class="fa fa-envelope "></i>
</span>
{% else %}
<span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
<i class="fa fa-envelope "></i>
<i class="fa fa-hourglass-end "></i>
</span>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div> </div>
<div class="item-col"> </div>
<ul class="list-content"> </div>
{% if calendar.mainUser is not empty %} </div>
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
{% endif %}
</ul>
</div>
</div>
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">
<div class="item-col">
{% include '@ChillActivity/Activity/concernedGroups.html.twig' with {
'context': calendar.context == 'person' ? 'calendar_person' : 'calendar_accompanyingCourse',
'render': 'wrap-list',
'entity': calendar
} %}
</div>
</div>
{% endif %}
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div>
</div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %}
{% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
{% if calendar.activity is not null %}
<div class="item-row separator">
<div class="item-col">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div>
<div class="wl-col list activity-linked">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ calendar.activity.type.name | localize_translatable_string }}
{% if calendar.activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
<ul class="record_actions">
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a>
</li>
{% endif %} {% endif %}
</ul> </ul>
</div>
</div>
</div> </div>
</div> </div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">
<div class="item-col">
{% include '@ChillActivity/Activity/concernedGroups.html.twig' with {
'context': calendar.context == 'person' ? 'calendar_person' : 'calendar_accompanyingCourse',
'render': 'wrap-list',
'entity': calendar
} %}
</div>
</div>
{% endif %}
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div>
</div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %}
{% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
{% if calendar.activity is not null %}
<div class="item-row separator">
<div class="item-col">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div>
<div class="wl-col list activity-linked">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ calendar.activity.type.name | localize_translatable_string }}
{% if calendar.activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
<ul class="record_actions">
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="item-row separator">
<ul class="record_actions">
{% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) %}
{% if templates|length == 0 %}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Add a document'|trans }}
</a>
</li>
{% else %}
<li>
<div class="dropdown">
<button class="btn btn-create dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'chill_calendar.Add a document'|trans }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Upload a document'|trans }}
</a>
</li>
{% for template in templates %}
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_docgenerator_generate_from_template', {'template': template.id, 'entityClassName': 'Chill\\CalendarBundle\\Entity\\Calendar', 'entityId': calendar.id}) }}"
>
{{ template.name|localize_translatable_string }}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endif %}
{% endif %}
{% if calendar.activity is null and (
(calendar.context == 'accompanying_period' and is_granted('CHILL_ACTIVITY_CREATE', calendar.accompanyingPeriod))
or
(calendar.context == 'person' and is_granted('CHILL_ACTIVITY_CREATE', calendar.person))
)
%}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendar_to_activity', { 'id': calendar.id }) }}">
{{ 'Transform to activity'|trans }}
</a>
</li>
{% endif %}
{% if (calendar.isInvited(app.user)) %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}"
data-calendar-id="{{ calendar.id|e('html_attr') }}"></div>
</li>
{% endif %}
{% if false %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_show', { 'id': calendar.id}) }}"
class="btn btn-show "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { 'id': calendar.id }) }}"
class="btn btn-update "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_delete', { 'id': calendar.id } ) }}"
class="btn btn-delete "></a>
</li>
{% endif %}
</ul>
</div>
</div> </div>
{% endfor %} </div>
{% endif %}
{% if calendarItems|length < paginator.getTotalItems %} <div class="item-row separator">
{{ chill_pagination(paginator) }} <ul class="record_actions">
{% endif %} {% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) %}
{% if templates|length == 0 %}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Add a document'|trans }}
</a>
</li>
{% else %}
<li>
<div class="dropdown">
<button class="btn btn-create dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'chill_calendar.Add a document'|trans }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Upload a document'|trans }}
</a>
</li>
{% for template in templates %}
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_docgenerator_generate_from_template', {'template': template.id, 'entityClassName': 'Chill\\CalendarBundle\\Entity\\Calendar', 'entityId': calendar.id}) }}"
>
{{ template.name|localize_translatable_string }}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endif %}
{% endif %}
{% if calendar.activity is null and (
(calendar.context == 'accompanying_period' and is_granted('CHILL_ACTIVITY_CREATE', calendar.accompanyingPeriod))
or
(calendar.context == 'person' and is_granted('CHILL_ACTIVITY_CREATE', calendar.person))
)
%}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendar_to_activity', { 'id': calendar.id }) }}">
{{ 'Transform to activity'|trans }}
</a>
</li>
{% endif %}
{% if (calendar.isInvited(app.user)) %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}"
data-calendar-id="{{ calendar.id|e('html_attr') }}"></div>
</li>
{% endif %}
{% if false %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_show', { 'id': calendar.id}) }}"
class="btn btn-show "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { 'id': calendar.id }) }}"
class="btn btn-update "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_delete', { 'id': calendar.id } ) }}"
class="btn btn-delete "></a>
</li>
{% endif %}
</ul>
</div> </div>
{% endif %}
</div>

View File

@@ -34,7 +34,18 @@
{% endif %} {% endif %}
</p> </p>
{% else %} {% else %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }} {% if calendarItems|length > 0 %}
<div class="flex-table list-records context-accompanyingCourse">
{% for calendar in calendarItems %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }}
{% endfor %}
</div>
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endif %} {% endif %}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">

View File

@@ -33,7 +33,17 @@
{% endif %} {% endif %}
</p> </p>
{% else %} {% else %}
{{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }} {% if calendarItems|length > 0 %}
<div class="flex-table list-records context-person">
{% for calendar in calendarItems %}
{{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }}
{% endfor %}
</div>
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endif %} {% endif %}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">

View File

@@ -0,0 +1,40 @@
{% extends "@ChillMain/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_invitations_list' %}
{% block title %}{{ 'My invitations list' |trans }}{% endblock title %}
{% block content %}
<h1>{{ 'invite.list.title'|trans }}</h1>
{% if invitations|length == 0 %}
<p class="chill-no-data-statement">
{{ "invite.list.none"|trans }}
</p>
{% else %}
<div class="flex-table list-records">
{% for invitation in invitations %}
{% set calendar = invitation.getCalendar %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'user'}) }}
{% endfor %}
</div>
{% if invitations|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_answer') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_answer') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Tests\Controller;
use Chill\CalendarBundle\Controller\MyInvitationsController;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
final class MyInvitationsControllerTest extends TestCase
{
use ProphecyTrait;
private MyInvitationsController $controller;
protected function setUp(): void
{
// Create prophecies for dependencies
$inviteRepository = $this->prophesize(InviteRepository::class);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
// Create controller instance
$this->controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up necessary services for AbstractController
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$tokenStorage = $this->prophesize(TokenStorageInterface::class);
$twig = $this->prophesize(Environment::class);
// Use reflection to set the container
$reflection = new \ReflectionClass($this->controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
// Create a mock container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
$container->has('security.token_storage')->willReturn(true);
$container->get('security.token_storage')->willReturn($tokenStorage->reveal());
$container->has('twig')->willReturn(true);
$container->get('twig')->willReturn($twig->reveal());
$containerProperty->setValue($this->controller, $container->reveal());
}
public function testMyInvitationsReturnsCorrectAmountOfInvitations(): void
{
// Create test user
$user = new User();
$user->setUsername('testuser');
// Create test invitations
$invite1 = new Invite();
$invite1->setUser($user);
$invite1->setStatus(Invite::PENDING);
$invite2 = new Invite();
$invite2->setUser($user);
$invite2->setStatus(Invite::ACCEPTED);
$invite3 = new Invite();
$invite3->setUser($user);
$invite3->setStatus(Invite::DECLINED);
$allInvitations = [$invite1, $invite2, $invite3];
$paginatedInvitations = [$invite1, $invite2]; // First page with 2 items per page
// Set up repository prophecies
$inviteRepository = $this->prophesize(InviteRepository::class);
$inviteRepository->findBy(['user' => $user])->willReturn($allInvitations);
$inviteRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
2, // items per page
0 // offset
)->willReturn($paginatedInvitations);
// Set up paginator prophecies
$paginator = $this->prophesize(PaginatorInterface::class);
$paginator->getItemsPerPage()->willReturn(2);
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$paginatorFactory->create(3)->willReturn($paginator->reveal());
// Set up doc generator repository
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$docGeneratorTemplateRepository->findByEntity(Calendar::class)->willReturn([]);
// Create controller with mocked dependencies
$controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up authorization checker to return true for ROLE_USER
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationChecker->isGranted('ROLE_USER', null)->willReturn(true);
// Set up token storage to return user
$token = $this->prophesize(TokenInterface::class);
$token->getUser()->willReturn($user);
$tokenStorage = $this->prophesize(TokenStorageInterface::class);
$tokenStorage->getToken()->willReturn($token->reveal());
// Set up twig to return a response
$twig = $this->prophesize(Environment::class);
$twig->render('@ChillCalendar/Invitations/listByUser.html.twig', [
'invitations' => $paginatedInvitations,
'paginator' => $paginator->reveal(),
'templates' => [],
])->willReturn('rendered content');
// Set up container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
$container->has('security.token_storage')->willReturn(true);
$container->get('security.token_storage')->willReturn($tokenStorage->reveal());
$container->has('twig')->willReturn(true);
$container->get('twig')->willReturn($twig->reveal());
// Use reflection to set the container
$reflection = new \ReflectionClass($controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
$containerProperty->setValue($controller, $container->reveal());
// Create request
$request = new Request();
// Execute the action
$response = $controller->myInvitations($request);
// Assert that response is successful
self::assertInstanceOf(Response::class, $response);
self::assertSame(200, $response->getStatusCode());
self::assertSame('rendered content', $response->getContent());
}
public function testMyInvitationsPageLoads(): void
{
// Create test user
$user = new User();
$user->setUsername('testuser');
// Set up repository prophecies - no invitations
$inviteRepository = $this->prophesize(InviteRepository::class);
$inviteRepository->findBy(['user' => $user])->willReturn([]);
$inviteRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
20, // default items per page
0 // offset
)->willReturn([]);
// Set up paginator prophecies
$paginator = $this->prophesize(PaginatorInterface::class);
$paginator->getItemsPerPage()->willReturn(20);
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$paginatorFactory->create(0)->willReturn($paginator->reveal());
// Set up doc generator repository
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$docGeneratorTemplateRepository->findByEntity(Calendar::class)->willReturn([]);
// Create controller with mocked dependencies
$controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up authorization checker to return true for ROLE_USER
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationChecker->isGranted('ROLE_USER', null)->willReturn(true);
// Set up token storage to return user
$token = $this->prophesize(TokenInterface::class);
$token->getUser()->willReturn($user);
$tokenStorage = $this->prophesize(TokenStorageInterface::class);
$tokenStorage->getToken()->willReturn($token->reveal());
// Set up twig to return a response
$twig = $this->prophesize(Environment::class);
$twig->render('@ChillCalendar/Invitations/listByUser.html.twig', [
'invitations' => [],
'paginator' => $paginator->reveal(),
'templates' => [],
])->willReturn('empty page content');
// Set up container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
$container->has('security.token_storage')->willReturn(true);
$container->get('security.token_storage')->willReturn($tokenStorage->reveal());
$container->has('twig')->willReturn(true);
$container->get('twig')->willReturn($twig->reveal());
// Use reflection to set the container
$reflection = new \ReflectionClass($controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
$containerProperty->setValue($controller, $container->reveal());
// Create request
$request = new Request();
// Execute the action
$response = $controller->myInvitations($request);
// Assert that page loads successfully
self::assertInstanceOf(Response::class, $response);
self::assertSame(200, $response->getStatusCode());
self::assertSame('empty page content', $response->getContent());
}
public function testMyInvitationsRequiresAuthentication(): void
{
// Create controller with minimal dependencies
$inviteRepository = $this->prophesize(InviteRepository::class);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up authorization checker to return false for ROLE_USER
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationChecker->isGranted('ROLE_USER')->willReturn(false);
$authorizationChecker->isGranted('ROLE_USER', null)->willReturn(false);
// Set up container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
// Use reflection to set the container
$reflection = new \ReflectionClass($controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
$containerProperty->setValue($controller, $container->reveal());
// Create request
$request = new Request();
// Expect AccessDeniedException
$this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class);
// Execute the action
$controller->myInvitations($request);
}
}

View File

@@ -86,6 +86,9 @@ invite:
declined: Refusé declined: Refusé
pending: En attente pending: En attente
tentative: Accepté provisoirement tentative: Accepté provisoirement
list:
none: Il n'y aucun invitation
title: Mes invitations
# exports # exports
Exports of calendar: Exports des rendez-vous Exports of calendar: Exports des rendez-vous

View File

@@ -20,4 +20,9 @@ use Doctrine\Persistence\ObjectRepository;
interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository
{ {
public function countByEntity(string $entity): int; public function countByEntity(string $entity): int;
/**
* @return array|DocGeneratorTemplate[]
*/
public function findByEntity(string $entity, ?int $start = 0, ?int $limit = 50): array;
} }

View File

@@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController
return new JsonResponse( return new JsonResponse(
$this->serializer->serialize( $this->serializer->serialize(
new Collection(array_values($items->toArray()), $paginator), new Collection($items, $paginator),
'json', 'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]] [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
), ),

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Exception;
class ConversionWithSameMimeTypeException extends \RuntimeException
{
public function __construct(string $mimeType, ?\Throwable $previous = null)
{
parent::__construct("Conversion to same MIME type '{$mimeType}' is not allowed: already at the same MIME type", 0, $previous);
}
}

View File

@@ -25,7 +25,7 @@ export interface GenericDoc {
type: "doc_store_generic_doc"; type: "doc_store_generic_doc";
uniqueKey: string; uniqueKey: string;
key: string; key: string;
identifiers: { id: number }; identifiers: object;
context: "person" | "accompanying-period"; context: "person" | "accompanying-period";
doc_date: DateTime; doc_date: DateTime;
metadata: GenericDocMetadata; metadata: GenericDocMetadata;

View File

@@ -3,9 +3,9 @@ import {
StoredObject, StoredObject,
StoredObjectPointInTime, StoredObjectPointInTime,
StoredObjectVersionWithPointInTime, StoredObjectVersionWithPointInTime,
} from "ChillDocStoreAssets/types"; } from "./../../../types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import { ISOToDatetime } from "ChillMainAssets/chill/js/date"; import { ISOToDatetime } from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue"; import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue"; import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue"; import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";

View File

@@ -46,16 +46,6 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool 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 // Retrieve the related entity
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
@@ -76,7 +66,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
return match ($workflowPermission) { return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission, WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission,
}; };
} }
} }

View File

@@ -14,12 +14,6 @@ namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* Interface for voting on stored object permissions.
*
* Each time a stored object is attached to a document, the voter is responsible for determining
* whether the user has the necessary permissions to access or modify the stored object.
*/
interface StoredObjectVoterInterface interface StoredObjectVoterInterface
{ {
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;

View File

@@ -15,7 +15,6 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\WopiBundle\Service\WopiConverter; use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Component\Mime\MimeTypesInterface; use Symfony\Component\Mime\MimeTypesInterface;
@@ -42,10 +41,9 @@ class StoredObjectToPdfConverter
* *
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true * @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true
* *
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found * @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
* @throws \RuntimeException if the conversion or storage of the new version fails * @throws \RuntimeException if the conversion or storage of the new version fails
* @throws StoredObjectManagerException * @throws StoredObjectManagerException
* @throws ConversionWithSameMimeTypeException if the document has already the same mime type79*
*/ */
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array
{ {
@@ -58,7 +56,7 @@ class StoredObjectToPdfConverter
$currentVersion = $storedObject->getCurrentVersion(); $currentVersion = $storedObject->getCurrentVersion();
if ($currentVersion->getType() === $newMimeType) { if ($currentVersion->getType() === $newMimeType) {
throw new ConversionWithSameMimeTypeException($newMimeType); throw new \UnexpectedValueException('Already at the same mime type');
} }
$content = $this->storedObjectManager->read($currentVersion); $content = $this->storedObjectManager->read($currentVersion);

View File

@@ -40,10 +40,6 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
$storedObject->registerVersion(); $storedObject->registerVersion();
} }
// remove one version in the history
$v5 = $storedObject->getVersions()->get(5);
$storedObject->removeVersion($v5);
$security = $this->prophesize(Security::class); $security = $this->prophesize(Security::class);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject) $security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
->willReturn(true) ->willReturn(true)
@@ -57,7 +53,6 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
self::assertEquals($response->getStatusCode(), 200); self::assertEquals($response->getStatusCode(), 200);
self::assertIsArray($body); self::assertIsArray($body);
self::assertArrayHasKey('results', $body); self::assertArrayHasKey('results', $body);
self::assertIsList($body['results']);
self::assertCount(10, $body['results']); self::assertCount(10, $body['results']);
} }

View File

@@ -86,165 +86,9 @@ class AbstractStoredObjectVoterTest extends TestCase
} }
/** /**
* @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission * @dataProvider dataProviderVoteOnAttribute
*/ */
public function testVoteOnAttributeWithStoredObjectPermission( public function testVoteOnAttribute(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $isGrantedRegularPermission,
string $isGrantedWorkflowPermission,
string $isGrantedStoredObjectAttachment,
): 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($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
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)
{
parent::__construct($security, $helper);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return \stdClass::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return 'SOME_ROLE';
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
};
$actual = $storedObjectVoter->voteOnAttribute($attribute, $storedObject, $token);
self::assertEquals($expected, $actual);
}
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): 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,
];
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
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,
];
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
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,
];
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
}
}
/**
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
*/
public function testVoteOnAttributeWithoutStoredObjectPermission(
StoredObjectRoleEnum $attribute, StoredObjectRoleEnum $attribute,
bool $expected, bool $expected,
bool $canBeAssociatedWithWorkflow, bool $canBeAssociatedWithWorkflow,
@@ -261,10 +105,6 @@ class AbstractStoredObjectVoterTest extends TestCase
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
if (null !== $isGrantedWorkflowPermissionRead) { if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
@@ -283,7 +123,7 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
} }
public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable public static function dataProviderVoteOnAttribute(): iterable
{ {
// not associated on a workflow // 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, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];

View File

@@ -246,7 +246,7 @@ final class EventController extends AbstractController
'class' => Center::class, 'class' => Center::class,
'choices' => $centers, 'choices' => $centers,
'placeholder' => $this->translator->trans('Pick a center'), 'placeholder' => $this->translator->trans('Pick a center'),
'label' => 'To which territory should the event be associated ?', 'label' => 'To which centre should the event be associated ?',
]) ])
->add('submit', SubmitType::class, [ ->add('submit', SubmitType::class, [
'label' => 'Next step', 'label' => 'Next step',

View File

@@ -64,7 +64,7 @@ CHILL_EVENT_PARTICIPATION_SEE_DETAILS: Voir le détail d'une participation
# TODO check place to put this # TODO check place to put this
Next step: Étape suivante Next step: Étape suivante
To which territory should the event be associated ?: À quel territoire doit être associé l'événement ? To which centre should the event be associated ?: À quel centre doit être associé l'événement ?
# timeline # timeline
past: passé past: passé
@@ -151,7 +151,7 @@ event:
filter: filter:
event_types: Par types d'événement event_types: Par types d'événement
event_dates: Par date d'événement event_dates: Par date d'événement
center: Par territoire center: Par centre
by_responsable: Par responsable by_responsable: Par responsable
pick_responsable: Filtrer par responsables pick_responsable: Filtrer par responsables
budget: budget:
@@ -188,7 +188,7 @@ event_id: Identifiant
event_name: Nom event_name: Nom
event_date: Date event_date: Date
event_type: Type d'évenement event_type: Type d'évenement
event_center: Territoire event_center: Centre
event_moderator: Responsable event_moderator: Responsable
event_participants_count: Nombre de participants event_participants_count: Nombre de participants
event_location: Localisation event_location: Localisation

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
@@ -28,7 +27,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
class WorkflowApiController extends ApiController class WorkflowApiController
{ {
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {} public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {}

View File

@@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController
$signature, $signature,
$request, $request,
EntityWorkflowStepSignatureVoter::CANCEL, EntityWorkflowStepSignatureVoter::CANCEL,
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature), function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); },
'@ChillMain/WorkflowSignature/cancel.html.twig', '@ChillMain/WorkflowSignature/cancel.html.twig',
); );
} }
@@ -56,18 +56,11 @@ final readonly class WorkflowSignatureStateChangeController
$signature, $signature,
$request, $request,
EntityWorkflowStepSignatureVoter::REJECT, EntityWorkflowStepSignatureVoter::REJECT,
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature), function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); },
'@ChillMain/WorkflowSignature/reject.html.twig', '@ChillMain/WorkflowSignature/reject.html.twig',
); );
} }
/**
* @param callable(EntityWorkflowStepSignature): string $markSignature
*
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
private function markSignatureAction( private function markSignatureAction(
EntityWorkflowStepSignature $signature, EntityWorkflowStepSignature $signature,
Request $request, Request $request,
@@ -86,13 +79,12 @@ final readonly class WorkflowSignatureStateChangeController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature)); $this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) {
$markSignature($signature);
});
return new RedirectResponse( return new RedirectResponse(
$this->chillUrlGenerator->forwardReturnPath( $this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()])
'chill_main_workflow_wait',
['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep]
)
); );
} }

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class WorkflowWaitStepChangeController
{
public function __construct(
private ChillUrlGeneratorInterface $chillUrlGenerator,
private Environment $twig,
) {}
#[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])]
public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response
{
if ($entityWorkflow->getStep() === $expectedStep) {
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
);
}
return new Response(
$this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep])
);
}
}

View File

@@ -30,7 +30,6 @@ use Chill\MainBundle\Controller\UserGroupAdminController;
use Chill\MainBundle\Controller\UserGroupApiController; use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController; use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController; use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\Controller\WorkflowApiController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Chill\MainBundle\Doctrine\DQL\Age; use Chill\MainBundle\Doctrine\DQL\Age;
use Chill\MainBundle\Doctrine\DQL\Extract; use Chill\MainBundle\Doctrine\DQL\Extract;
@@ -67,7 +66,6 @@ use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Form\CenterType; use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType; use Chill\MainBundle\Form\CivilityType;
use Chill\MainBundle\Form\CountryType; use Chill\MainBundle\Form\CountryType;
@@ -81,7 +79,6 @@ use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType; use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType; use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Security\Authorization\ChillExportVoter; use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
use Ramsey\Uuid\Doctrine\UuidType; use Ramsey\Uuid\Doctrine\UuidType;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
@@ -943,21 +940,6 @@ class ChillMainExtension extends Extension implements
], ],
], ],
], ],
[
'class' => EntityWorkflow::class,
'name' => 'workflow',
'base_path' => '/api/1.0/main/workflow',
'base_role' => EntityWorkflowVoter::SEE,
'controller' => WorkflowApiController::class,
'actions' => [
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
], ],
]); ]);
} }

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Routing\RouterInterface;
/** /**
* Create paginator instances. * Create paginator instances.
*/ */
final readonly class PaginatorFactory implements PaginatorFactoryInterface class PaginatorFactory implements PaginatorFactoryInterface
{ {
final public const DEFAULT_CURRENT_PAGE_KEY = 'page'; final public const DEFAULT_CURRENT_PAGE_KEY = 'page';
@@ -29,16 +29,16 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface
/** /**
* the request stack. * the request stack.
*/ */
private RequestStack $requestStack, private readonly RequestStack $requestStack,
/** /**
* the router and generator for url. * the router and generator for url.
*/ */
private RouterInterface $router, private readonly RouterInterface $router,
/** /**
* the default item per page. This may be overriden by * the default item per page. This may be overriden by
* the request or inside the paginator. * the request or inside the paginator.
*/ */
private int $itemPerPage = 20, private readonly int $itemPerPage = 20,
) {} ) {}
/** /**

View File

@@ -1,13 +0,0 @@
/**
* Extracts the "returnPath" parameter from the current URL's query string and returns it.
* If the parameter is not present, returns the provided fallback path.
*
* @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string.
* @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present.
*/
export function returnPathOr(fallbackPath: string): string {
const urlParams = new URLSearchParams(window.location.search);
const returnPath = urlParams.get("returnPath");
return returnPath ?? fallbackPath;
}

View File

@@ -1,16 +0,0 @@
import { EntityWorkflow } from "ChillMainAssets/types";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export const fetchWorkflow = async (
workflowId: number,
): Promise<EntityWorkflow> => {
try {
return await makeFetch<null, EntityWorkflow>(
"GET",
`/api/1.0/main/workflow/${workflowId}.json`,
);
} catch (error) {
console.error(`Failed to fetch workflow ${workflowId}:`, error);
throw error;
}
};

View File

@@ -1,6 +1,5 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
export interface DateTime { export interface DateTime {
datetime: string; datetime: string;
@@ -203,58 +202,6 @@ export interface WorkflowAttachment {
genericDoc: null | GenericDoc; genericDoc: null | GenericDoc;
} }
export interface Workflow {
name: string;
text: string;
}
export interface EntityWorkflowStep {
type: "entity_workflow_step";
id: number;
comment: string;
currentStep: StepDefinition;
isFinal: boolean;
isFreezed: boolean;
isFinalized: boolean;
transitionPrevious: Transition | null;
transitionAfter: Transition | null;
previousId: number | null;
nextId: number | null;
transitionPreviousBy: User | null;
transitionPreviousAt: DateTime | null;
}
export interface Transition {
name: string;
text: string;
isForward: boolean;
}
export interface StepDefinition {
name: string;
text: string;
}
export interface EntityWorkflow {
type: "entity_workflow";
id: number;
relatedEntityClass: string;
relatedEntityId: number;
workflow: Workflow;
currentStep: EntityWorkflowStep;
steps: EntityWorkflowStep[];
datas: WorkflowData;
title: string;
isOnHoldAtCurrentStep: boolean;
_permissions: {
CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean;
};
}
export interface WorkflowData {
persons: Person[];
}
export interface ExportGeneration { export interface ExportGeneration {
id: string; id: string;
type: "export_generation"; type: "export_generation";
@@ -268,8 +215,3 @@ export interface ExportGeneration {
export interface PrivateCommentEmbeddable { export interface PrivateCommentEmbeddable {
comments: Record<number, string>; comments: Record<number, string>;
} }
/**
* Possible states for the WaitingScreen Component.
*/
export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready";

View File

@@ -10,8 +10,7 @@ import { computed, onMounted, ref } from "vue";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export"; import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import WaitingScreen from "../_components/WaitingScreen.vue"; import { ExportGeneration } from "ChillMainAssets/types";
import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types";
interface AppProps { interface AppProps {
exportGenerationId: string; exportGenerationId: string;
@@ -35,16 +34,13 @@ const storedObject = computed<null | StoredObject>(() => {
}); });
const isPending = computed<boolean>(() => status.value === "pending"); const isPending = computed<boolean>(() => status.value === "pending");
const isFetching = computed<boolean>(
() => tryiesForReady.value < maxTryiesForReady,
);
const isReady = computed<boolean>(() => status.value === "ready");
const isFailure = computed<boolean>(() => status.value === "failure");
const filename = computed<string>(() => `${props.title}-${props.createdDate}`); const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
const state = computed<WaitingScreenState>((): WaitingScreenState => {
if (status.value === "empty") {
return "pending";
}
return status.value;
});
/** /**
* counter for the number of times that we check for a new status * counter for the number of times that we check for a new status
*/ */
@@ -89,36 +85,57 @@ onMounted(() => {
</script> </script>
<template> <template>
<WaitingScreen :state="state"> <div id="waiting-screen">
<template v-slot:pending> <div
<p> v-if="isPending && isFetching"
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} class="alert alert-danger text-center"
</p> >
</template> <div>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p>
</div>
<template v-slot:stopped> <div>
<p> <i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} <span class="sr-only">Loading...</span>
</p> </div>
</template> </div>
<div v-if="isPending && !isFetching" class="alert alert-info">
<div>
<p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p>
</div>
</div>
<div v-if="isFailure" class="alert alert-danger text-center">
<div>
<p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p>
</div>
</div>
<div v-if="isReady" class="alert alert-success text-center">
<div>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p>
<template v-slot:failure> <p v-if="storedObject !== null">
<p> <document-action-buttons-group
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} :stored-object="storedObject"
</p> :filename="filename"
</template> ></document-action-buttons-group>
</p>
<template v-slot:ready> </div>
<p> </div>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }} </div>
</p>
<p v-if="storedObject !== null">
<document-action-buttons-group
:stored-object="storedObject"
:filename="filename"
></document-action-buttons-group>
</p>
</template>
</WaitingScreen>
</template> </template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -1,75 +0,0 @@
<script setup lang="ts">
import { useIntervalFn } from "@vueuse/core";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper";
import { ref } from "vue";
import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue";
import { WaitingScreenState } from "ChillMainAssets/types";
import {
trans,
WORKFLOW_WAIT_TITLE,
WORKFLOW_WAIT_ERROR_WHILE_WAITING,
WORKFLOW_WAIT_SUCCESS,
} from "translator";
interface WaitPostProcessWorkflowComponentProps {
workflowId: number;
expectedStep: string;
}
const props = defineProps<WaitPostProcessWorkflowComponentProps>();
const counter = ref<number>(0);
const MAX_TRYIES = 50;
const state = ref<WaitingScreenState>("pending");
const { pause, resume } = useIntervalFn(
async () => {
try {
const workflow = await fetchWorkflow(props.workflowId);
counter.value++;
if (workflow.currentStep.currentStep.name === props.expectedStep) {
window.location.assign(
returnPathOr("/fr/main/workflow" + workflow.id + "/show"),
);
resume();
state.value = "ready";
}
if (counter.value > MAX_TRYIES) {
pause();
state.value = "failure";
}
} catch (error) {
console.error(error);
pause();
}
},
2000,
{ immediate: true },
);
</script>
<template>
<div class="container">
<WaitingScreen :state="state">
<template v-slot:pending>
<p>
{{ trans(WORKFLOW_WAIT_TITLE) }}
</p>
</template>
<template v-slot:failure>
<p>
{{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }}
</p>
</template>
<template v-slot:ready>
<p>
{{ trans(WORKFLOW_WAIT_SUCCESS) }}
</p>
</template>
</WaitingScreen>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,51 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
function mountApp(): void {
const el = document.querySelector<HTMLDivElement>(".screen-wait");
if (!el) {
console.error(
"WaitPostProcessWorkflow: mount element .screen-wait not found",
);
return;
}
const workflowIdAttr = el.getAttribute("data-workflow-id");
const expectedStep = el.getAttribute("data-expected-step") || "";
if (!workflowIdAttr) {
console.error(
"WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element",
);
return;
}
if (!expectedStep) {
console.error(
"WaitPostProcessWorkflow: data-expected-step attribute missing on mount element",
);
return;
}
const workflowId = Number(workflowIdAttr);
if (Number.isNaN(workflowId)) {
console.error(
"WaitPostProcessWorkflow: data-workflow-id is not a valid number:",
workflowIdAttr,
);
return;
}
const app = createApp(App, {
workflowId,
expectedStep,
});
app.mount(el);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountApp);
} else {
mountApp();
}

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, useTemplateRef } from "vue"; import { computed, useTemplateRef } from "vue";
import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types"; import type { WorkflowAttachment } from "ChillMainAssets/types";
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue"; import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
import { GenericDoc } from "ChillDocStoreAssets/types"; import { GenericDoc } from "ChillDocStoreAssets/types";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
interface AppConfig { interface AppConfig {
workflowId: number; workflowId: number;
@@ -35,13 +34,6 @@ const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
) as GenericDocForAccompanyingPeriod[], ) as GenericDocForAccompanyingPeriod[],
); );
const workflow = ref<EntityWorkflow | null>(null);
onMounted(async () => {
workflow.value = await fetchWorkflow(Number(props.workflowId));
console.log("workflow", workflow.value);
});
const openModal = function () { const openModal = function () {
pickDocModal.value?.openModal(); pickDocModal.value?.openModal();
}; };
@@ -57,30 +49,20 @@ const onPickGenericDoc = ({
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => { const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
emit("removeAttachment", payload); emit("removeAttachment", payload);
}; };
const canEditAttachement = computed<boolean>(() => {
if (null === workflow.value) {
return false;
}
return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
});
</script> </script>
<template> <template>
<pick-generic-doc-modal <pick-generic-doc-modal
:workflow="workflow"
:accompanying-period-id="props.accompanyingPeriodId" :accompanying-period-id="props.accompanyingPeriodId"
:to-remove="attachedGenericDoc" :to-remove="attachedGenericDoc"
ref="pickDocModal" ref="pickDocModal"
@pickGenericDoc="onPickGenericDoc" @pickGenericDoc="onPickGenericDoc"
></pick-generic-doc-modal> ></pick-generic-doc-modal>
<attachment-list <attachment-list
:workflow="workflow"
:attachments="props.attachments" :attachments="props.attachments"
@removeAttachment="onRemoveAttachment" @removeAttachment="onRemoveAttachment"
></attachment-list> ></attachment-list>
<ul v-if="canEditAttachement" class="record_actions"> <ul class="record_actions">
<li> <li>
<button type="button" class="btn btn-create" @click="openModal"> <button type="button" class="btn btn-create" @click="openModal">
Ajouter une pièce jointe Ajouter une pièce jointe

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types"; import { WorkflowAttachment } from "ChillMainAssets/types";
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue"; import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface AttachmentListProps { interface AttachmentListProps {
attachments: WorkflowAttachment[]; attachments: WorkflowAttachment[];
workflow: EntityWorkflow | null;
} }
const emit = defineEmits<{ const emit = defineEmits<{
@@ -37,12 +36,7 @@ const props = defineProps<AttachmentListProps>();
:stored-object="a.genericDoc.storedObject" :stored-object="a.genericDoc.storedObject"
></document-action-buttons-group> ></document-action-buttons-group>
</li> </li>
<li <li>
v-if="
!workflow?._permissions
.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
"
>
<button <button
type="button" type="button"
class="btn btn-delete" class="btn btn-delete"

View File

@@ -6,10 +6,8 @@ import {
import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue"; import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue";
import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api"; import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { EntityWorkflow } from "ChillMainAssets/types";
interface PickGenericDocProps { interface PickGenericDocProps {
workflow: EntityWorkflow | null;
accompanyingPeriodId: number; accompanyingPeriodId: number;
pickedList: GenericDocForAccompanyingPeriod[]; pickedList: GenericDocForAccompanyingPeriod[];
toRemove: GenericDocForAccompanyingPeriod[]; toRemove: GenericDocForAccompanyingPeriod[];
@@ -38,21 +36,9 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean =>
) !== -1; ) !== -1;
onMounted(async () => { onMounted(async () => {
const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period( genericDocs.value = await fetch_generic_docs_by_accompanying_period(
props.accompanyingPeriodId, props.accompanyingPeriodId,
); );
const documentClasses = [
"Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument",
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
"Chill\\DocStoreBundle\\Entity\\PersonDocument",
];
genericDocs.value = fetchedGenericDocs.filter(
(doc) =>
!documentClasses.includes(
props.workflow?.relatedEntityClass || "",
) || props.workflow?.relatedEntityId !== doc.identifiers.id,
);
loaded.value = true; loaded.value = true;
}); });

View File

@@ -3,10 +3,8 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, ref, useTemplateRef } from "vue"; import { computed, ref, useTemplateRef } from "vue";
import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue"; import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import { EntityWorkflow } from "ChillMainAssets/types";
interface PickGenericDocModalProps { interface PickGenericDocModalProps {
workflow: EntityWorkflow | null;
accompanyingPeriodId: number; accompanyingPeriodId: number;
toRemove: GenericDocForAccompanyingPeriod[]; toRemove: GenericDocForAccompanyingPeriod[];
} }
@@ -82,7 +80,6 @@ defineExpose({ openModal, closeModal });
</template> </template>
<template v-slot:body> <template v-slot:body>
<pick-generic-doc <pick-generic-doc
:workflow="props.workflow"
:accompanying-period-id="props.accompanyingPeriodId" :accompanying-period-id="props.accompanyingPeriodId"
:to-remove="props.toRemove" :to-remove="props.toRemove"
:picked-list="pickeds" :picked-list="pickeds"

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import { WaitingScreenState } from "ChillMainAssets/types";
interface Props {
state: WaitingScreenState;
}
const props = defineProps<Props>();
</script>
<template>
<div id="waiting-screen">
<div
v-if="props.state === 'pending' && !!$slots.pending"
class="alert alert-danger text-center"
>
<div>
<slot name="pending"></slot>
</div>
<div>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div
v-if="props.state === 'stopped' && !!$slots.stopped"
class="alert alert-info"
>
<div>
<slot name="stopped"></slot>
</div>
</div>
<div
v-if="props.state === 'failure' && !!$slots.failure"
class="alert alert-danger text-center"
>
<div>
<slot name="failure"></slot>
</div>
</div>
<div
v-if="props.state === 'ready' && !!$slots.ready"
class="alert alert-success text-center"
>
<div>
<slot name="ready"></slot>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -21,6 +21,8 @@
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
{{ form_row(form.addressesEmails) }}
{% include handler.template(notification) with handler.templateData(notification) %} {% include handler.template(notification) with handler.templateData(notification) %}
<div class="mb-3 row"> <div class="mb-3 row">

View File

@@ -61,7 +61,7 @@
{% endif %} {% endif %}
</li> </li>
<li> <li>
<span class="dt">service/territoire:</span> <span class="dt">cercle/centre:</span>
{% if entity.mainScope %} {% if entity.mainScope %}
{{ entity.mainScope.name|localize_translatable_string }} {{ entity.mainScope.name|localize_translatable_string }}
{% endif %} {% endif %}

View File

@@ -58,14 +58,12 @@
{% endif %} {% endif %}
</section> </section>
{% if signatures|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% endif %}
<section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section>
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
{% if entity_workflow.currentStep.sends|length > 0 %} {% if signatures|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% elseif entity_workflow.currentStep.sends|length > 0 %}
<section class="step my-4"> <section class="step my-4">
<h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2> <h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2>
{% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %} {% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %}

View File

@@ -1,18 +0,0 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title %}{{ 'workflow.signature.waiting_for'|trans }}{% endblock %}
{% block css %}
{{ encore_entry_link_tags('page_workflow_waiting_post_process') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('page_workflow_waiting_post_process') }}
{% endblock %}
{% block content %}
<h1>{{ block('title') }}</h1>
<div class="screen-wait" data-workflow-id="{{ workflow.id|e('html_attr') }}" data-expected-step="{{ expectedStep|e('html_attr') }}"></div>
{% endblock %}

View File

@@ -49,7 +49,7 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface
'route' => 'chill_crud_center_index', 'route' => 'chill_crud_center_index',
])->setExtras(['order' => 1010]); ])->setExtras(['order' => 1010]);
$menu->addChild('Regroupements des territoires', [ $menu->addChild('Regroupements des centres', [
'route' => 'chill_crud_regroupment_index', 'route' => 'chill_crud_regroupment_index',
])->setExtras(['order' => 1015]); ])->setExtras(['order' => 1015]);

View File

@@ -1,53 +0,0 @@
<?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\Security\Authorization;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Workflow\Registry;
final class EntityWorkflowAttachmentVoter extends Voter
{
public function __construct(
private readonly Registry $registry,
) {}
public const EDIT = 'CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT';
protected function supports(string $attribute, $subject): bool
{
return $subject instanceof EntityWorkflow && self::EDIT === $attribute;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
if (!$subject instanceof EntityWorkflow) {
throw new \UnexpectedValueException('Subject must be an instance of EntityWorkflow');
}
if ($subject->isFinal()) {
return false;
}
$workflow = $this->registry->get($subject, $subject->getWorkflowName());
$marking = $workflow->getMarking($subject);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if ($placeMetadata['isSentExternal'] ?? false) {
return false;
}
}
return true;
}
}

View File

@@ -12,25 +12,18 @@ declare(strict_types=1);
namespace Chill\MainBundle\Serializer\Normalizer; namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Registry;
final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface
{ {
use NormalizerAwareTrait; use NormalizerAwareTrait;
public function __construct( public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {}
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly MetadataExtractor $metadataExtractor,
private readonly Registry $registry,
private readonly Security $security,
) {}
/** /**
* @param EntityWorkflow $object * @param EntityWorkflow $object
@@ -53,9 +46,6 @@ final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerA
'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context), 'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context),
'title' => $handler->getEntityTitle($object), 'title' => $handler->getEntityTitle($object),
'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(), 'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(),
'_permissions' => [
EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object),
],
]; ];
} }

View File

@@ -35,7 +35,7 @@ final class ScopeControllerTest extends WebTestCase
$client->getResponse()->getStatusCode(), $client->getResponse()->getStatusCode(),
'Unexpected HTTP status code for GET /fr/admin/scope/' 'Unexpected HTTP status code for GET /fr/admin/scope/'
); );
$crawler = $client->click($crawler->selectLink('Créer un nouveau service')->link()); $crawler = $client->click($crawler->selectLink('Créer un nouveau cercle')->link());
// Fill in the form and submit it // Fill in the form and submit it
$form = $crawler->selectButton('Créer')->form([ $form = $crawler->selectButton('Créer')->form([
'chill_mainbundle_scope[name][fr]' => 'Test en fr', 'chill_mainbundle_scope[name][fr]' => 'Test en fr',

View File

@@ -1,173 +0,0 @@
<?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\Tests\Security\Authorization;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class EntityWorkflowAttachmentVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider dataVoteOnAttribute
*/
public function testVoteOnAttribute(EntityWorkflow $entityWorkflow, int $expected): void
{
$voter = new EntityWorkflowAttachmentVoter($this->buildRegistry());
$actual = $voter->vote(
new UsernamePasswordToken(new User(), 'default'),
$entityWorkflow,
['CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'],
);
$this->assertEquals($expected, $actual);
}
public static function dataVoteOnAttribute(): iterable
{
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_final_positive',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_final_positive',
'transitionAt' => new \DateTimeImmutable()],
);
// we need to mark manually as final, as the listener is not registered
$entity->getCurrentStep()->setIsFinal(true);
yield 'on final positive' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_final_negative',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_final_negative',
'transitionAt' => new \DateTimeImmutable()],
);
// we need to mark manually as final, as the listener is not registered
$entity->getCurrentStep()->setIsFinal(true);
yield 'on final negative' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_sent_external',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_sent_external',
'transitionAt' => new \DateTimeImmutable()],
);
yield 'on sent_external' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
yield 'on initial' => [
$entity,
VoterInterface::ACCESS_GRANTED,
];
}
private static function buildRegistry(): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces(['initial'])
->addPlaces(['initial', 'sent_external', 'final_positive', 'final_negative'])
->addTransitions([
new Transition('to_final_positive', ['initial'], 'final_positive'),
new Transition('to_sent_external', ['initial'], 'sent_external'),
new Transition('to_final_negative', ['initial'], 'final_negative'),
])
->setMetadataStore(
new InMemoryMetadataStore(
placesMetadata: [
'sent_external' => [
'isSentExternal' => true,
],
'final_positive' => [
'isFinal' => true,
'isFinalPositive' => true,
],
'final_negative' => [
'isFinal' => true,
'isFinalPositive' => false,
],
]
)
);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
$registry = new Registry();
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
});
return $registry;
}
}

View File

@@ -11,9 +11,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Helper; 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\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
@@ -151,11 +148,8 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because there is no workflow']; 'abstain because the user is not present as a dest user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force deny because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow(); $entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
@@ -177,9 +171,6 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user']; 'force grant because the user was a previous user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied because the user was not a previous user'];
$entityWorkflow = new EntityWorkflow(); $entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User(); $dto->futureDestUsers[] = $user = new User();
@@ -241,13 +232,6 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature on a canceled workflow']; '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'];
} }
public function testNoWorkflow(): void public function testNoWorkflow(): void
@@ -269,217 +253,7 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows); $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class); return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
$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()));
} }
private static function buildRegistry(): Registry private static function buildRegistry(): Registry
@@ -487,13 +261,10 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$builder = new DefinitionBuilder(); $builder = new DefinitionBuilder();
$builder $builder
->setInitialPlaces(['initial']) ->setInitialPlaces(['initial'])
->addPlaces(['initial', 'test', 'sent_external', 'final_positive', 'final_negative']) ->addPlaces(['initial', 'test', 'final_positive', 'final_negative'])
->setMetadataStore( ->setMetadataStore(
new InMemoryMetadataStore( new InMemoryMetadataStore(
placesMetadata: [ placesMetadata: [
'sent_external' => [
'isSentExternal' => true,
],
'final_positive' => [ 'final_positive' => [
'isFinal' => true, 'isFinal' => true,
'isFinalPositive' => true, 'isFinalPositive' => true,

View File

@@ -11,11 +11,8 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Messenger; namespace Chill\MainBundle\Tests\Workflow\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
@@ -26,7 +23,6 @@ use Chill\ThirdPartyBundle\Entity\ThirdParty;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Address;
@@ -43,54 +39,24 @@ class PostSendExternalMessageHandlerTest extends TestCase
public function testSendMessageHappyScenario(): void public function testSendMessageHappyScenario(): void
{ {
$entityWorkflow = $this->buildEntityWorkflow(); $entityWorkflow = $this->buildEntityWorkflow();
// Prepare attachments (2 attachments)
$attachmentStoredObject1 = new StoredObject();
$attachmentStoredObject2 = new StoredObject();
new EntityWorkflowAttachment('generic_doc', ['id' => 1], $entityWorkflow, $attachmentStoredObject1);
new EntityWorkflowAttachment('generic_doc', ['id' => 2], $entityWorkflow, $attachmentStoredObject2);
// Prepare transition DTO and sends
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestineeEmails = ['external@example.com']; $dto->futureDestineeEmails = ['external@example.com'];
$dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')]; $dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')];
$entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User()); $entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User());
// Repository returns our workflow
$repository = $this->prophesize(EntityWorkflowRepository::class); $repository = $this->prophesize(EntityWorkflowRepository::class);
$repository->find(1)->willReturn($entityWorkflow); $repository->find(1)->willReturn($entityWorkflow);
// Mailer must send to both recipients
$mailer = $this->prophesize(MailerInterface::class); $mailer = $this->prophesize(MailerInterface::class);
$mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce(); $mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce();
$mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce(); $mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce();
// Workflow manager and handler
$workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class); $workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title'); $workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title');
$workflowManager = $this->prophesize(EntityWorkflowManager::class); $workflowManager = $this->prophesize(EntityWorkflowManager::class);
$workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal()); $workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal());
// Associated stored object for the workflow $handler = new PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $workflowManager->reveal());
$associatedStoredObject = new StoredObject();
$workflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($associatedStoredObject);
// Converter should be called for each attachment and the associated stored object
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($attachmentStoredObject1, 'fr')->shouldBeCalledOnce();
$converter->addConvertedVersion($attachmentStoredObject2, 'fr')->shouldBeCalledOnce();
$converter->addConvertedVersion($associatedStoredObject, 'fr')->shouldBeCalledOnce();
// Logger (not used in happy path, but required by handler)
$logger = $this->prophesize(LoggerInterface::class);
$handler = new PostSendExternalMessageHandler(
$repository->reveal(),
$mailer->reveal(),
$workflowManager->reveal(),
$converter->reveal(),
$logger->reveal(),
);
$handler(new PostSendExternalMessage(1, 'fr')); $handler(new PostSendExternalMessage(1, 'fr'));

View File

@@ -11,12 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\Helper; namespace Chill\MainBundle\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
@@ -61,39 +58,21 @@ class WorkflowRelatedEntityPermissionHelper
public function __construct( public function __construct(
private readonly Security $security, private readonly Security $security,
private readonly EntityWorkflowManager $entityWorkflowManager, private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly Registry $registry, private readonly Registry $registry,
private readonly ClockInterface $clock, private readonly ClockInterface $clock,
) {} ) {}
/** /**
* @param object $entity The entity may be an
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/ */
public function isAllowedByWorkflowForReadOperation(object $entity): string public function isAllowedByWorkflowForReadOperation(object $entity): string
{ {
if ($entity instanceof StoredObject) { $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$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;
}
if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) { if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) {
return self::FORCE_GRANT; 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 // give a view permission if there is a Person signature pending, or in the 12 hours following
// the signature last state // the signature last state
foreach ($entityWorkflows as $workflow) { foreach ($entityWorkflows as $workflow) {
@@ -121,51 +100,33 @@ class WorkflowRelatedEntityPermissionHelper
*/ */
public function isAllowedByWorkflowForWriteOperation(object $entity): string public function isAllowedByWorkflowForWriteOperation(object $entity): string
{ {
if ($entity instanceof StoredObject) { $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity); $runningWorkflows = [];
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
$isAttached = true;
} else {
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$isAttached = false;
}
if ([] === $entityWorkflows) { // if a workflow is finalized positive, we are not allowed to edit to document any more
return self::ABSTAIN;
}
// if a workflow is finalized positive, anyone is allowed to edit the document anymore
foreach ($entityWorkflows as $entityWorkflow) { foreach ($entityWorkflows as $entityWorkflow) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); if ($entityWorkflow->isFinal()) {
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
foreach ($marking->getPlaces() as $place => $int) { $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); foreach ($marking->getPlaces() as $place => $int) {
if ( $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
($entityWorkflow->isFinal() && ($placeMetadata['isFinalPositive'] ?? false)) if (true === ($placeMetadata['isFinalPositive'] ?? false)) {
|| ($placeMetadata['isSentExternal'] ?? false) // the workflow is final, and final positive, so we stop here.
) { return self::FORCE_DENIED;
// 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;
} }
} else {
$runningWorkflows[] = $entityWorkflow;
} }
} }
$runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal()); // if there is a signature on a **running workflow**, no one can edit the workflow any more
foreach ($runningWorkflows as $entityWorkflow) {
// if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore foreach ($entityWorkflow->getSteps() as $step) {
if (!$isAttached) { foreach ($step->getSignatures() as $signature) {
foreach ($runningWorkflows as $entityWorkflow) { if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
foreach ($entityWorkflow->getSteps() as $step) { return self::FORCE_DENIED;
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return self::FORCE_DENIED;
}
} }
} }
} }
@@ -176,11 +137,7 @@ class WorkflowRelatedEntityPermissionHelper
return self::FORCE_GRANT; return self::FORCE_GRANT;
} }
if ($isAttached) { return self::ABSTAIN;
return self::ABSTAIN;
}
return self::FORCE_DENIED;
} }
/** /**

View File

@@ -11,14 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\Messenger; namespace Chill\MainBundle\Workflow\Messenger;
use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
@@ -30,8 +25,6 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt
private EntityWorkflowRepository $entityWorkflowRepository, private EntityWorkflowRepository $entityWorkflowRepository,
private MailerInterface $mailer, private MailerInterface $mailer,
private EntityWorkflowManager $workflowManager, private EntityWorkflowManager $workflowManager,
private StoredObjectToPdfConverter $storedObjectToPdfConverter,
private LoggerInterface $logger,
) {} ) {}
public function __invoke(PostSendExternalMessage $message): void public function __invoke(PostSendExternalMessage $message): void
@@ -42,38 +35,11 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt
throw new UnrecoverableMessageHandlingException(sprintf('Entity workflow with id %d not found', $message->entityWorkflowId)); throw new UnrecoverableMessageHandlingException(sprintf('Entity workflow with id %d not found', $message->entityWorkflowId));
} }
$this->convertToPdf($entityWorkflow, $message->lang);
foreach ($entityWorkflow->getCurrentStep()->getSends() as $send) { foreach ($entityWorkflow->getCurrentStep()->getSends() as $send) {
$this->sendEmailToDestinee($send, $message); $this->sendEmailToDestinee($send, $message);
} }
} }
private function convertToPdf(EntityWorkflow $entityWorkflow, string $locale): void
{
foreach ($entityWorkflow->getAttachments() as $attachment) {
try {
$this->storedObjectToPdfConverter->addConvertedVersion($attachment->getProxyStoredObject(), $locale);
} catch (StoredObjectManagerException $e) {
$this->logger->error('Error converting attachment to PDF', ['backtrace' => $e->getTraceAsString(), 'attachment_id' => $attachment->getId()]);
} catch (ConversionWithSameMimeTypeException $e) {
$this->logger->error('Error converting attachment to PDF: already at the same MIME type', ['backtrace' => $e->getTraceAsString(), 'attachment_id' => $attachment->getId()]);
}
}
$storedObject = $this->workflowManager->getAssociatedStoredObject($entityWorkflow);
if (null !== $storedObject) {
try {
$this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $locale);
} catch (StoredObjectManagerException $e) {
$this->logger->error('Error converting stored object to PDF', ['backtrace' => $e->getTraceAsString(), 'stored_object_id' => $storedObject->getId(), 'workflow_id' => $entityWorkflow->getId()]);
} catch (ConversionWithSameMimeTypeException $e) {
$this->logger->error('Error converting stored object to PDF: already at the same MIME type', ['backtrace' => $e->getTraceAsString(), 'stored_object_id' => $storedObject->getId(), 'workflow_id' => $entityWorkflow->getId()]);
}
}
}
private function sendEmailToDestinee(EntityWorkflowSend $send, PostSendExternalMessage $message): void private function sendEmailToDestinee(EntityWorkflowSend $send, PostSendExternalMessage $message): void
{ {
$entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow(); $entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow();

View File

@@ -22,7 +22,6 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Transition;
/** /**
* Handles state changes for signature steps within a workflow. * Handles state changes for signature steps within a workflow.
@@ -51,10 +50,8 @@ class SignatureStepStateChanger
* *
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as signed * @param EntityWorkflowStepSignature $signature the signature entity to be marked as signed
* @param int|null $atIndex optional index position for the signature within the zone * @param int|null $atIndex optional index position for the signature within the zone
*
* @return string The expected new workflow's step, after transition is applyied
*/ */
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): string public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): void
{ {
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE); $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
@@ -63,14 +60,7 @@ class SignatureStepStateChanger
->setZoneSignatureIndex($atIndex) ->setZoneSignatureIndex($atIndex)
->setStateDate($this->clock->now()); ->setStateDate($this->clock->now());
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]); $this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]);
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId())); $this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
if (null === $transition) {
return $signature->getStep()->getEntityWorkflow()->getStep();
}
return $transition->getTos()[0];
} }
/** /**
@@ -81,10 +71,8 @@ class SignatureStepStateChanger
* *
* This method updates the signature state to 'canceled' and logs the action. * This method updates the signature state to 'canceled' and logs the action.
* It also dispatches a message to notify about the state change. * It also dispatches a message to notify about the state change.
*
* @return string The expected new workflow's step, after transition is applyied
*/ */
public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): string public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): void
{ {
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE); $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
@@ -92,15 +80,7 @@ class SignatureStepStateChanger
->setState(EntityWorkflowSignatureStateEnum::CANCELED) ->setState(EntityWorkflowSignatureStateEnum::CANCELED)
->setStateDate($this->clock->now()); ->setStateDate($this->clock->now());
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as canceled', ['signatureId' => $signature->getId()]); $this->logger->info(self::LOG_PREFIX.'Mark signature entity as canceled', ['signatureId' => $signature->getId()]);
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId())); $this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
if (null === $transition) {
return $signature->getStep()->getEntityWorkflow()->getStep();
}
return $transition->getTos()[0];
} }
/** /**
@@ -113,10 +93,8 @@ class SignatureStepStateChanger
* a state change has occurred. * a state change has occurred.
* *
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as rejected * @param EntityWorkflowStepSignature $signature the signature entity to be marked as rejected
*
* @return string The expected new workflow's step, after transition is applyied
*/ */
public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): string public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): void
{ {
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE); $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
@@ -124,16 +102,7 @@ class SignatureStepStateChanger
->setState(EntityWorkflowSignatureStateEnum::REJECTED) ->setState(EntityWorkflowSignatureStateEnum::REJECTED)
->setStateDate($this->clock->now()); ->setStateDate($this->clock->now());
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as rejected', ['signatureId' => $signature->getId()]); $this->logger->info(self::LOG_PREFIX.'Mark signature entity as rejected', ['signatureId' => $signature->getId()]);
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId())); $this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
if (null === $transition) {
return $signature->getStep()->getEntityWorkflow()->getStep();
}
return $transition->getTos()[0];
} }
/** /**
@@ -148,35 +117,10 @@ class SignatureStepStateChanger
{ {
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_READ); $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_READ);
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
if (null === $transition) {
return;
}
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
$transitionDto->futureDestUsers[] = $futureUser;
$workflow->apply($entityWorkflow, $transition->getName(), [
'context' => $transitionDto,
'transitionAt' => $this->clock->now(),
'transition' => $transition->getName(),
]);
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
}
/**
* @return array{transition: Transition|null, futureUser: User|null}
*/
private function decideTransition(EntityWorkflowStepSignature $signature): array
{
if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) { if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) {
$this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]); $this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]);
return ['transition' => null, 'futureUser' => null]; return;
} }
$this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]); $this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]);
@@ -200,7 +144,7 @@ class SignatureStepStateChanger
if (null === $transition) { if (null === $transition) {
$this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]); $this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]);
return ['transition' => null, 'futureUser' => null]; return;
} }
if ('person' === $signature->getSignerKind()) { if ('person' === $signature->getSignerKind()) {
@@ -212,16 +156,19 @@ class SignatureStepStateChanger
if (null === $futureUser) { if (null === $futureUser) {
$this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]); $this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]);
return ['transition' => null, 'futureUser' => null]; return;
} }
foreach ($workflow->getDefinition()->getTransitions() as $transitionObj) { $transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
if ($transitionObj->getName() === $transition) { $transitionDto->futureDestUsers[] = $futureUser;
return ['transition' => $transitionObj, 'futureUser' => $futureUser];
}
}
throw new \RuntimeException('Transition not found'); $workflow->apply($entityWorkflow, $transition, [
'context' => $transitionDto,
'transitionAt' => $this->clock->now(),
'transition' => $transition,
]);
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
} }
private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User

View File

@@ -965,31 +965,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/UserJob" $ref: "#/components/schemas/UserJob"
/1.0/main/workflow/{id}.json:
get:
tags:
- workflow
summary: Return a workflow
parameters:
- name: id
in: path
required: true
description: The workflow id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
content:
application/json:
schema:
$ref: "#/components/schemas/Workflow"
404:
description: "not found"
401:
description: "Unauthorized"
/1.0/main/workflow/my: /1.0/main/workflow/my:
get: get:
tags: tags:

View File

@@ -120,8 +120,5 @@ module.exports = function (encore, entries) {
"vue_onthefly", "vue_onthefly",
__dirname + "/Resources/public/vuejs/OnTheFly/index.js", __dirname + "/Resources/public/vuejs/OnTheFly/index.js",
); );
encore.addEntry(
"page_workflow_waiting_post_process",
__dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts"
);
}; };

View File

@@ -54,7 +54,7 @@ user:
title: Mon profil title: Mon profil
Profile successfully updated!: Votre profil a été mis à jour! Profile successfully updated!: Votre profil a été mis à jour!
no job: Pas de métier assigné no job: Pas de métier assigné
no scope: Pas de service assigné no scope: Pas de cercle assigné
notification_preferences: Préférences pour mes notifications notification_preferences: Préférences pour mes notifications
user_group: user_group:
@@ -102,9 +102,9 @@ createdAt: Créé le
createdBy: Créé par createdBy: Créé par
#elements used in software #elements used in software
centers: territoires centers: centres
Centers: Territoires Centers: Centres
center: territoire center: centre
comment: commentaire comment: commentaire
Comment: Commentaire Comment: Commentaire
Comments: Commentaires Comments: Commentaires
@@ -227,12 +227,12 @@ Location Menu: Localisations et types de localisation
Management of location: Gestion des localisations et types de localisation Management of location: Gestion des localisations et types de localisation
#admin section for center's administration #admin section for center's administration
Create a new center: Créer une nouveau territoire Create a new center: Créer un nouveau centre
Center list: Liste des territoires Center list: Liste des centres
Center edit: Édition d'un territoire Center edit: Édition d'un centre
Center creation: Création d'un territoire Center creation: Création d'un centre
New center: Nouveau territoire New center: Nouveau centre
Center: Territoire Center: Centre
#admin section for permissions group #admin section for permissions group
Permissions group list: Groupes de permissions Permissions group list: Groupes de permissions
@@ -246,15 +246,15 @@ New permission group: Nouveau groupe de permissions
PermissionsGroup "%name%" edit: Modification du groupe de permission '%name%' PermissionsGroup "%name%" edit: Modification du groupe de permission '%name%'
Role: Rôle Role: Rôle
Choose amongst roles: Choisir un rôle Choose amongst roles: Choisir un rôle
Choose amongst scopes: Choisir un service Choose amongst scopes: Choisir un cercle
Add permission: Ajouter les permissions Add permission: Ajouter les permissions
This group does not provide any permission: Ce groupe n'attribue aucune permission This group does not provide any permission: Ce groupe n'attribue aucune permission
The role '%role%' has been removed: Le rôle "%role%" a été enlevé de ce groupe de permission The role '%role%' has been removed: Le rôle "%role%" a été enlevé de ce groupe de permission
The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le service "%scope%" a été enlevé de ce groupe de permission The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le cercle "%scope%" a été enlevé de ce groupe de permission
Unclassified: Non classifié Unclassified: Non classifié
Help to pick role and scope: Certains rôles ne nécessitent pas de service. Help to pick role and scope: Certains rôles ne nécessitent pas de cercle.
The role need scope: Ce rôle nécessite un service. The role need scope: Ce rôle nécessite un cercle.
The role does not need scope: Ce rôle ne nécessite pas de service ! The role does not need scope: Ce rôle ne nécessite pas de cercle !
#admin section for users #admin section for users
User configuration: Gestion des utilisateurs User configuration: Gestion des utilisateurs
@@ -270,7 +270,7 @@ Grant new permissions: Ajout de permissions
Add a new groupCenter: Ajout de permissions Add a new groupCenter: Ajout de permissions
The permissions have been successfully added to the user: Les permissions ont été accordées à l'utilisateur The permissions have been successfully added to the user: Les permissions ont été accordées à l'utilisateur
The permissions where removed.: Les permissions ont été enlevées. The permissions where removed.: Les permissions ont été enlevées.
Center & groups: Territoire et groupes Center & groups: Centre et groupes
User %username%: Utilisateur %username% User %username%: Utilisateur %username%
Add a new user: Ajouter un nouvel utilisateur Add a new user: Ajouter un nouvel utilisateur
The permissions have been added: Les permissions ont été ajoutées The permissions have been added: Les permissions ont été ajoutées
@@ -280,13 +280,13 @@ Back to the user edition: Retour au formulaire d'édition
Password successfully updated!: Mot de passe mis à jour Password successfully updated!: Mot de passe mis à jour
Flags: Drapeaux Flags: Drapeaux
Main location: Localisation principale Main location: Localisation principale
Main scope: Service Main scope: Cercle
Main center: Territoire Main center: Centre
user job: Métier de l'utilisateur user job: Métier de l'utilisateur
Job: Métier Job: Métier
Jobs: Métiers Jobs: Métiers
Choose a main center: Choisir un territoire Choose a main center: Choisir un centre
Choose a main scope: Choisir un service Choose a main scope: Choisir un cercle
choose a job: Choisir un métier choose a job: Choisir un métier
choose a location: Choisir une localisation choose a location: Choisir une localisation
@@ -302,12 +302,12 @@ Current location successfully updated: Localisation actuelle mise à jour
Pick a location: Choisir un lieu Pick a location: Choisir un lieu
#admin section for circles (old: scopes) #admin section for circles (old: scopes)
List circles: Services List circles: Cercles
New circle: Nouveau service New circle: Nouveau cercle
Circle: Service Circle: Cercle
Circle edit: Modification du service Circle edit: Modification du cercle
Circle creation: Création d'un service Circle creation: Création d'un cercle
Create a new circle: Créer un nouveau service Create a new circle: Créer un nouveau cercle
#admin section for location #admin section for location
Location: Localisation Location: Localisation
@@ -347,9 +347,9 @@ Country list: Liste des pays
Country code: Code du pays Country code: Code du pays
# circles / scopes # circles / scopes
Choose the circle: Choisir le service Choose the circle: Choisir le cercle
Scope: Service Scope: Cercle
Scopes: Services Scopes: Cercles
#export #export
@@ -357,14 +357,14 @@ Scopes: Services
Exports list: Liste des exports Exports list: Liste des exports
Create an export: Créer un export Create an export: Créer un export
#export creation step 'center' : pick a center #export creation step 'center' : pick a center
Pick centers: Choisir les territoires Pick centers: Choisir les centres
Pick a center: Choisir un territoire Pick a center: Choisir un centre
The export will contains only data from the picked centers.: L'export ne contiendra que les données des territoires choisis. The export will contains only data from the picked centers.: L'export ne contiendra que les données des centres choisis.
This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les territoires choisis. This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les centres choisis.
Go to export options: Vers la préparation de l'export Go to export options: Vers la préparation de l'export
Pick aggregated centers: Regroupement de territoires Pick aggregated centers: Regroupement de centres
uncheck all centers: Désélectionner tous les territoires uncheck all centers: Désélectionner tous les centres
check all centers: Sélectionner tous les territoires check all centers: Sélectionner tous les centres
# export creation step 'export' : choose aggregators, filtering and formatter # export creation step 'export' : choose aggregators, filtering and formatter
Formatter: Mise en forme Formatter: Mise en forme
Choose the formatter: Choisissez le format d'export voulu. Choose the formatter: Choisissez le format d'export voulu.
@@ -510,10 +510,10 @@ crud:
title_edit: Modifier un regroupement title_edit: Modifier un regroupement
center: center:
index: index:
title: Liste des territoires title: Liste des centres
add_new: Ajouter un territoire add_new: Ajouter un centre
title_new: Nouveau territoire title_new: Nouveau centre
title_edit: Modifier un territoire title_edit: Modifier un centre
news_item: news_item:
index: index:
title: Liste des actualités title: Liste des actualités
@@ -666,17 +666,10 @@ workflow:
cancel_are_you_sure: Êtes-vous sûr de vouloir annuler la signature de %signer% cancel_are_you_sure: Êtes-vous sûr de vouloir annuler la signature de %signer%
reject_signature_of: Rejet de la signature de %signer% reject_signature_of: Rejet de la signature de %signer%
reject_are_you_sure: Êtes-vous sûr de vouloir rejeter la signature de %signer% reject_are_you_sure: Êtes-vous sûr de vouloir rejeter la signature de %signer%
waiting_for: En attente de modification de l'état de la signature
attachments: attachments:
title: Pièces jointes title: Pièces jointes
wait:
title: En attente de traitement
error_while_waiting: Le traitement a échoué
success: Traitement terminé. Redirection en cours...
Subscribe final: Recevoir une notification à l'étape finale Subscribe final: Recevoir une notification à l'étape finale
Subscribe all steps: Recevoir une notification à chaque étape Subscribe all steps: Recevoir une notification à chaque étape
CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows
@@ -860,7 +853,7 @@ absence:
admin: admin:
users: users:
export_list_csv: Liste des utilisateurs (format CSV) export_list_csv: Liste des utilisateurs (format CSV)
export_permissions_csv: Association utilisateurs - groupes de permissions - territoire (format CSV) export_permissions_csv: Association utilisateurs - groupes de permissions - centre (format CSV)
export: export:
id: Identifiant id: Identifiant
username: Nom d'utilisateur username: Nom d'utilisateur
@@ -870,8 +863,8 @@ admin:
civility_abbreviation: Abbréviation civilité civility_abbreviation: Abbréviation civilité
civility_name: Civilité civility_name: Civilité
label: Label label: Label
mainCenter_id: Identifiant territoire principal mainCenter_id: Identifiant centre principal
mainCenter_name: Territoire principal mainCenter_name: Centre principal
mainScope_id: Identifiant service principal mainScope_id: Identifiant service principal
mainScope_name: Service principal mainScope_name: Service principal
userJob_id: Identifiant métier userJob_id: Identifiant métier
@@ -881,8 +874,8 @@ admin:
mainLocation_id: Identifiant localisation principale mainLocation_id: Identifiant localisation principale
mainLocation_name: Localisation principale mainLocation_name: Localisation principale
absenceStart: Absent à partir du absenceStart: Absent à partir du
center_id: Identifiant du territoire center_id: Identifiant du centre
center_name: Territoire center_name: Centre
permissionsGroup_id: Identifiant du groupe de permissions permissionsGroup_id: Identifiant du groupe de permissions
permissionsGroup_name: Groupe de permissions permissionsGroup_name: Groupe de permissions
job_scope_histories: job_scope_histories:

View File

@@ -1,15 +1,15 @@
# role_scope constraint # role_scope constraint
# scope presence # scope presence
The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un service. The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un cercle.
The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un service. The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un cercle.
"The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Le mot de passe doit contenir une majuscule, une minuscule, et au moins un caractère spécial parmi *[@#$%!,;:+\"'-/{}~=µ()£]). Les autres caractères sont autorisés." "The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Le mot de passe doit contenir une majuscule, une minuscule, et au moins un caractère spécial parmi *[@#$%!,;:+\"'-/{}~=µ()£]). Les autres caractères sont autorisés."
The password fields must match: Les mots de passe doivent correspondre The password fields must match: Les mots de passe doivent correspondre
The password must be greater than {{ limit }} characters: "[1,Inf] Le mot de passe doit contenir au moins {{ limit }} caractères" The password must be greater than {{ limit }} characters: "[1,Inf] Le mot de passe doit contenir au moins {{ limit }} caractères"
A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et service. A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et cercle.
#UserCircleConsistency #UserCircleConsistency
"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce service." "{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce cercle."
The user in cc cannot be a dest user in the same workflow step: Un utilisateur en Cc ne peut pas être un utilisateur qui valide. The user in cc cannot be a dest user in the same workflow step: Un utilisateur en Cc ne peut pas être un utilisateur qui valide.

View File

@@ -80,7 +80,7 @@ const appMessages = {
firstName: "Prénom", firstName: "Prénom",
lastName: "Nom", lastName: "Nom",
birthdate: "Date de naissance", birthdate: "Date de naissance",
center: "Territoire", center: "Centre",
phonenumber: "Téléphone", phonenumber: "Téléphone",
mobilenumber: "Mobile", mobilenumber: "Mobile",
altNames: "Autres noms", altNames: "Autres noms",

View File

@@ -50,8 +50,8 @@ const visMessages = {
return "Né·e le"; return "Né·e le";
} }
}, },
center_id: "Identifiant du territoire", center_id: "Identifiant du centre",
center_type: "Type de territoire", center_type: "Type de centre",
center_name: "Territoire", // vendée center_name: "Territoire", // vendée
phonenumber: "Téléphone", phonenumber: "Téléphone",
mobilenumber: "Mobile", mobilenumber: "Mobile",

View File

@@ -464,7 +464,7 @@ export default {
this.errors.push("Le genre doit être renseigné"); this.errors.push("Le genre doit être renseigné");
} }
if (this.showCenters && this.person.center === null) { if (this.showCenters && this.person.center === null) {
this.errors.push("Le territoire doit être renseigné"); this.errors.push("Le centre doit être renseigné");
} }
}, },
loadData() { loadData() {

View File

@@ -25,8 +25,8 @@ const personMessages = {
return "Né·e le"; return "Né·e le";
} }
}, },
center_id: "Identifiant du territoire", center_id: "Identifiant du centre",
center_type: "Type de territoire", center_type: "Type de centre",
center_name: "Territoire", // vendée center_name: "Territoire", // vendée
phonenumber: "Téléphone", phonenumber: "Téléphone",
mobilenumber: "Mobile", mobilenumber: "Mobile",
@@ -53,8 +53,8 @@ const personMessages = {
"Un nouveau ménage va être créé. L'usager sera membre de ce ménage.", "Un nouveau ménage va être créé. L'usager sera membre de ce ménage.",
}, },
center: { center: {
placeholder: "Choisissez un territoire", placeholder: "Choisissez un centre",
title: "territoire", title: "Centre",
}, },
}, },
error_only_one_person: "Une seule personne peut être sélectionnée !", error_only_one_person: "Une seule personne peut être sélectionnée !",

View File

@@ -376,7 +376,7 @@ Create a list of people according to various filters.: Crée une liste d'usagers
Fields to include in export: Champs à inclure dans l'export Fields to include in export: Champs à inclure dans l'export
Address valid at this date: Addresse valide à cette date Address valid at this date: Addresse valide à cette date
Data valid at this date: Données valides à cette date Data valid at this date: Données valides à cette date
Data regarding center, addresses, and so on will be computed at this date: Les données concernant le territoire, l'adresse, le ménage, sera calculé à cette date. Data regarding center, addresses, and so on will be computed at this date: Les données concernant le centre, l'adresse, le ménage, sera calculé à cette date.
List duplicates: Liste des doublons List duplicates: Liste des doublons
Create a list of duplicate people: Créer la liste des usagers détectés comme doublons. Create a list of duplicate people: Créer la liste des usagers détectés comme doublons.
Count people participating in an accompanying course: Nombre d'usagers concernés par un parcours Count people participating in an accompanying course: Nombre d'usagers concernés par un parcours
@@ -1110,9 +1110,9 @@ export:
Group course by household composition: Grouper les usagers par composition familiale Group course by household composition: Grouper les usagers par composition familiale
Calc date: Date de calcul de la composition du ménage Calc date: Date de calcul de la composition du ménage
by_center: by_center:
title: Grouper les usagers par territoire title: Grouper les usagers par centre
at_date: Date de calcul du territoire at_date: Date de calcul du centre
center: Territoire de l'usager center: Centre de l'usager
by_postal_code: by_postal_code:
title: Grouper les usagers par code postal de l'adresse title: Grouper les usagers par code postal de l'adresse
at_date: Date de calcul de l'adresse at_date: Date de calcul de l'adresse
@@ -1437,7 +1437,7 @@ export:
acpParticipantPersons: Usagers concernés acpParticipantPersons: Usagers concernés
acpParticipantPersonsIds: Usagers concernés (identifiants) acpParticipantPersonsIds: Usagers concernés (identifiants)
duration: Durée du parcours (en jours) duration: Durée du parcours (en jours)
centers: Territoires des usagers centers: Centres des usagers
eval: eval:
List of evaluations: Liste des évaluations List of evaluations: Liste des évaluations

View File

@@ -23,7 +23,7 @@ The gender must be set: Le genre doit être renseigné
You are not allowed to perform this action: Vous n'avez pas le droit de changer cette valeur. You are not allowed to perform this action: Vous n'avez pas le droit de changer cette valeur.
Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again: Désolé, mais quelqu'un d'autre a déjà modifié cette entité. Veuillez actualiser la page et appliquer à nouveau les modifications Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again: Désolé, mais quelqu'un d'autre a déjà modifié cette entité. Veuillez actualiser la page et appliquer à nouveau les modifications
A center is required: Un territoire est requis A center is required: Un centre est requis
#export list #export list
You must select at least one element: Vous devez sélectionner au moins un élément You must select at least one element: Vous devez sélectionner au moins un élément

View File

@@ -9,7 +9,7 @@
'Report list': 'Liste des rapports' 'Report list': 'Liste des rapports'
Details: Détails Details: Détails
Person: Usager Person: Usager
Scope: Service Scope: Cercle
Date: Date Date: Date
User: Utilisateur User: Utilisateur
'Report type': 'Type de rapport' 'Report type': 'Type de rapport'

View File

@@ -4,7 +4,7 @@ Tasks: "Tâches"
Title: Titre Title: Titre
Description: Description Description: Description
Assignee: "Personne assignée" Assignee: "Personne assignée"
Scope: Service Scope: Cercle
"Start date": "Date de début" "Start date": "Date de début"
"End date": "Date d'échéance" "End date": "Date d'échéance"
"Warning date": "Date d'avertissement" "Warning date": "Date d'avertissement"
@@ -106,7 +106,7 @@ My tasks over deadline: Mes tâches à échéance dépassée
#transition page #transition page
Apply transition on task <em>%title%</em>: Appliquer la transition sur la tâche <em>%title%</em> Apply transition on task <em>%title%</em>: Appliquer la transition sur la tâche <em>%title%</em>
All centers: Tous les territoires All centers: Tous les centres
# ROLES # ROLES
CHILL_TASK_TASK_CREATE: Ajouter une tâche CHILL_TASK_TASK_CREATE: Ajouter une tâche

View File

@@ -73,8 +73,8 @@ No acronym given: Aucun sigle renseigné
No phone given: Aucun téléphone renseigné No phone given: Aucun téléphone renseigné
No email given: Aucune adresse courriel renseignée No email given: Aucune adresse courriel renseignée
The party is visible in those centers: Le tiers est visible dans ces territoires The party is visible in those centers: Le tiers est visible dans ces centres
The party is not visible in any center: Le tiers n'est associé à aucun territoire The party is not visible in any center: Le tiers n'est associé à aucun centre
No third parties: Aucun tiers No third parties: Aucun tiers
Any third party selected: Aucun tiers sélectionné Any third party selected: Aucun tiers sélectionné