mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 01:08:26 +00:00 
			
		
		
		
	Compare commits
	
		
			7 Commits
		
	
	
		
			v4.5.0
			...
			385-invita
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 74c9eb5585 | |||
| f93c7e014f | |||
| e6a799abc4 | |||
| 68a0ef7115 | |||
| 1675c56f3d | |||
| 675e8450fc | |||
| 4ffd7034d0 | 
							
								
								
									
										6
									
								
								.changes/unreleased/Feature-20250808-120802.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.changes/unreleased/Feature-20250808-120802.yaml
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										6
									
								
								.changes/unreleased/Fixed-20250918-114044.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.changes/unreleased/Fixed-20250918-114044.yaml
									
									
									
									
									
										Normal 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 | ||||
| @@ -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    | ||||
							
								
								
									
										14
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -6,20 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), | ||||
| and is generated by [Changie](https://github.com/miniscruff/changie). | ||||
|  | ||||
|  | ||||
| ## 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 | ||||
| ### Fixed | ||||
| * Fix document generation and workflow generation do not work on accompanying period work documents    | ||||
|   | ||||
| @@ -266,7 +266,7 @@ class CalendarController extends AbstractController | ||||
|         } | ||||
|  | ||||
|         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'; | ||||
|   | ||||
| @@ -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), | ||||
|         ]); | ||||
|     } | ||||
| } | ||||
| @@ -30,6 +30,13 @@ class UserMenuBuilder implements LocalMenuBuilderInterface | ||||
|                     'order' => 9, | ||||
|                     'icon' => 'tasks', | ||||
|                 ]); | ||||
|             $menu->addChild('My invitations list', [ | ||||
|                 'route' => 'chill_calendar_invitations_list_my', | ||||
|             ]) | ||||
|                 ->setExtras([ | ||||
|                     'order' => 9, | ||||
|                     'icon' => 'tasks', | ||||
|                 ]); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class InviteRepository implements ObjectRepository | ||||
|     /** | ||||
|      * @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); | ||||
|     } | ||||
|   | ||||
| @@ -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="flex-table list-records context-accompanyingCourse"> | ||||
| <div class="item-bloc"> | ||||
|     <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="item-bloc"> | ||||
|                 <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> | ||||
|  | ||||
|                                     <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 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 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> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <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 %} | ||||
|                             </ul> | ||||
|                         </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> | ||||
|         {% endfor %} | ||||
|         </div> | ||||
|     {% endif %} | ||||
|  | ||||
|         {% if calendarItems|length < paginator.getTotalItems %} | ||||
|             {{ chill_pagination(paginator) }} | ||||
|         {% 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> | ||||
| {% endif %} | ||||
|  | ||||
| </div> | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -34,7 +34,18 @@ | ||||
|             {% endif %} | ||||
|         </p> | ||||
|     {% 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 %} | ||||
|  | ||||
|     <ul class="record_actions sticky-form-buttons"> | ||||
|   | ||||
| @@ -33,7 +33,17 @@ | ||||
|             {% endif %} | ||||
|         </p> | ||||
|     {% 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 %} | ||||
|  | ||||
|     <ul class="record_actions sticky-form-buttons"> | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -86,6 +86,9 @@ invite: | ||||
|     declined: Refusé | ||||
|     pending: En attente | ||||
|     tentative: Accepté provisoirement | ||||
|     list: | ||||
|         none: Il n'y aucun invitation | ||||
|         title: Mes invitations | ||||
|  | ||||
| # exports | ||||
| Exports of calendar: Exports des rendez-vous | ||||
|   | ||||
| @@ -20,4 +20,9 @@ use Doctrine\Persistence\ObjectRepository; | ||||
| interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository | ||||
| { | ||||
|     public function countByEntity(string $entity): int; | ||||
|  | ||||
|     /** | ||||
|      * @return array|DocGeneratorTemplate[] | ||||
|      */ | ||||
|     public function findByEntity(string $entity, ?int $start = 0, ?int $limit = 50): array; | ||||
| } | ||||
|   | ||||
| @@ -25,7 +25,7 @@ export interface GenericDoc { | ||||
|     type: "doc_store_generic_doc"; | ||||
|     uniqueKey: string; | ||||
|     key: string; | ||||
|     identifiers: { id: number }; | ||||
|     identifiers: object; | ||||
|     context: "person" | "accompanying-period"; | ||||
|     doc_date: DateTime; | ||||
|     metadata: GenericDocMetadata; | ||||
|   | ||||
| @@ -46,16 +46,6 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
|  | ||||
|     public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         // we first try to get the permission from the workflow, as attachement (this is the less intensive query) | ||||
|         $workflowPermissionAsAttachment = match ($attribute) { | ||||
|             StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject), | ||||
|             StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject), | ||||
|         }; | ||||
|  | ||||
|         if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Retrieve the related entity | ||||
|         $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); | ||||
|  | ||||
| @@ -76,7 +66,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
|         return match ($workflowPermission) { | ||||
|             WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, | ||||
|             WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, | ||||
|             WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission, | ||||
|             WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission, | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -14,12 +14,6 @@ namespace Chill\DocStoreBundle\Security\Authorization; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| 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 | ||||
| { | ||||
|     public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; | ||||
|   | ||||
| @@ -86,165 +86,9 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission | ||||
|      * @dataProvider dataProviderVoteOnAttribute | ||||
|      */ | ||||
|     public function testVoteOnAttributeWithStoredObjectPermission( | ||||
|         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( | ||||
|     public function testVoteOnAttribute( | ||||
|         StoredObjectRoleEnum $attribute, | ||||
|         bool $expected, | ||||
|         bool $canBeAssociatedWithWorkflow, | ||||
| @@ -261,10 +105,6 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|         $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN); | ||||
|         $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN); | ||||
|  | ||||
|         if (null !== $isGrantedWorkflowPermissionRead) { | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) | ||||
|                 ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); | ||||
| @@ -283,7 +123,7 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|         self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); | ||||
|     } | ||||
|  | ||||
|     public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable | ||||
|     public static function dataProviderVoteOnAttribute(): iterable | ||||
|     { | ||||
|         // not associated on a workflow | ||||
|         yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper']; | ||||
|   | ||||
| @@ -11,7 +11,6 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\CRUD\Controller\ApiController; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| 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\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) {} | ||||
|  | ||||
|   | ||||
| @@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|             $signature, | ||||
|             $request, | ||||
|             EntityWorkflowStepSignatureVoter::CANCEL, | ||||
|             fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature), | ||||
|             function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); }, | ||||
|             '@ChillMain/WorkflowSignature/cancel.html.twig', | ||||
|         ); | ||||
|     } | ||||
| @@ -56,18 +56,11 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|             $signature, | ||||
|             $request, | ||||
|             EntityWorkflowStepSignatureVoter::REJECT, | ||||
|             fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature), | ||||
|             function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); }, | ||||
|             '@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( | ||||
|         EntityWorkflowStepSignature $signature, | ||||
|         Request $request, | ||||
| @@ -86,13 +79,12 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|         $form->handleRequest($request); | ||||
|  | ||||
|         if ($form->isSubmitted() && $form->isValid()) { | ||||
|             $expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature)); | ||||
|             $this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) { | ||||
|                 $markSignature($signature); | ||||
|             }); | ||||
|  | ||||
|             return new RedirectResponse( | ||||
|                 $this->chillUrlGenerator->forwardReturnPath( | ||||
|                     'chill_main_workflow_wait', | ||||
|                     ['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep] | ||||
|                 ) | ||||
|                 $this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -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]) | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -30,7 +30,6 @@ use Chill\MainBundle\Controller\UserGroupAdminController; | ||||
| use Chill\MainBundle\Controller\UserGroupApiController; | ||||
| use Chill\MainBundle\Controller\UserJobApiController; | ||||
| use Chill\MainBundle\Controller\UserJobController; | ||||
| use Chill\MainBundle\Controller\WorkflowApiController; | ||||
| use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; | ||||
| use Chill\MainBundle\Doctrine\DQL\Age; | ||||
| use Chill\MainBundle\Doctrine\DQL\Extract; | ||||
| @@ -67,7 +66,6 @@ use Chill\MainBundle\Entity\Regroupment; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\UserGroup; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Form\CenterType; | ||||
| use Chill\MainBundle\Form\CivilityType; | ||||
| use Chill\MainBundle\Form\CountryType; | ||||
| @@ -81,7 +79,6 @@ use Chill\MainBundle\Form\UserGroupType; | ||||
| use Chill\MainBundle\Form\UserJobType; | ||||
| use Chill\MainBundle\Form\UserType; | ||||
| use Chill\MainBundle\Security\Authorization\ChillExportVoter; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter; | ||||
| use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; | ||||
| use Ramsey\Uuid\Doctrine\UuidType; | ||||
| 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, | ||||
|                             ], | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|             ], | ||||
|         ]); | ||||
|     } | ||||
|   | ||||
| @@ -17,7 +17,7 @@ use Symfony\Component\Routing\RouterInterface; | ||||
| /** | ||||
|  * Create paginator instances. | ||||
|  */ | ||||
| final readonly class PaginatorFactory implements PaginatorFactoryInterface | ||||
| class PaginatorFactory implements PaginatorFactoryInterface | ||||
| { | ||||
|     final public const DEFAULT_CURRENT_PAGE_KEY = 'page'; | ||||
|  | ||||
| @@ -29,16 +29,16 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface | ||||
|         /** | ||||
|          * the request stack. | ||||
|          */ | ||||
|         private RequestStack $requestStack, | ||||
|         private readonly RequestStack $requestStack, | ||||
|         /** | ||||
|          * the router and generator for url. | ||||
|          */ | ||||
|         private RouterInterface $router, | ||||
|         private readonly RouterInterface $router, | ||||
|         /** | ||||
|          * the default item per page. This may be overriden by | ||||
|          * the request or inside the paginator. | ||||
|          */ | ||||
|         private int $itemPerPage = 20, | ||||
|         private readonly int $itemPerPage = 20, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| }; | ||||
| @@ -1,6 +1,5 @@ | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { Person } from "../../../ChillPersonBundle/Resources/public/types"; | ||||
|  | ||||
| export interface DateTime { | ||||
|     datetime: string; | ||||
| @@ -203,58 +202,6 @@ export interface WorkflowAttachment { | ||||
|     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 { | ||||
|     id: string; | ||||
|     type: "export_generation"; | ||||
| @@ -268,8 +215,3 @@ export interface ExportGeneration { | ||||
| export interface PrivateCommentEmbeddable { | ||||
|     comments: Record<number, string>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Possible states for the WaitingScreen Component. | ||||
|  */ | ||||
| export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready"; | ||||
|   | ||||
| @@ -10,8 +10,7 @@ import { computed, onMounted, ref } from "vue"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export"; | ||||
| import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; | ||||
| import WaitingScreen from "../_components/WaitingScreen.vue"; | ||||
| import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types"; | ||||
| import { ExportGeneration } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface AppProps { | ||||
|     exportGenerationId: string; | ||||
| @@ -35,16 +34,13 @@ const storedObject = computed<null | StoredObject>(() => { | ||||
| }); | ||||
|  | ||||
| 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 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 | ||||
|  */ | ||||
| @@ -89,36 +85,57 @@ onMounted(() => { | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <WaitingScreen :state="state"> | ||||
|         <template v-slot:pending> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} | ||||
|             </p> | ||||
|         </template> | ||||
|     <div id="waiting-screen"> | ||||
|         <div | ||||
|             v-if="isPending && isFetching" | ||||
|             class="alert alert-danger text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} | ||||
|                 </p> | ||||
|             </div> | ||||
|  | ||||
|         <template v-slot:stopped> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} | ||||
|             </p> | ||||
|         </template> | ||||
|             <div> | ||||
|                 <i class="fa fa-cog fa-spin fa-3x fa-fw"></i> | ||||
|                 <span class="sr-only">Loading...</span> | ||||
|             </div> | ||||
|         </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> | ||||
|                 {{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} | ||||
|             </p> | ||||
|         </template> | ||||
|  | ||||
|         <template v-slot:ready> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_EXPORT_READY) }} | ||||
|             </p> | ||||
|  | ||||
|             <p v-if="storedObject !== null"> | ||||
|                 <document-action-buttons-group | ||||
|                     :stored-object="storedObject" | ||||
|                     :filename="filename" | ||||
|                 ></document-action-buttons-group> | ||||
|             </p> | ||||
|         </template> | ||||
|     </WaitingScreen> | ||||
|                 <p v-if="storedObject !== null"> | ||||
|                     <document-action-buttons-group | ||||
|                         :stored-object="storedObject" | ||||
|                         :filename="filename" | ||||
|                     ></document-action-buttons-group> | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| #waiting-screen { | ||||
|     > .alert { | ||||
|         min-height: 350px; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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(); | ||||
| } | ||||
| @@ -1,11 +1,10 @@ | ||||
| <script setup lang="ts"> | ||||
| import { computed, onMounted, ref, useTemplateRef } from "vue"; | ||||
| import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import { computed, useTemplateRef } from "vue"; | ||||
| import type { WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue"; | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types"; | ||||
| import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api"; | ||||
|  | ||||
| interface AppConfig { | ||||
|     workflowId: number; | ||||
| @@ -35,13 +34,6 @@ const attachedGenericDoc = computed<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 () { | ||||
|     pickDocModal.value?.openModal(); | ||||
| }; | ||||
| @@ -57,30 +49,20 @@ const onPickGenericDoc = ({ | ||||
| const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => { | ||||
|     emit("removeAttachment", payload); | ||||
| }; | ||||
|  | ||||
| const canEditAttachement = computed<boolean>(() => { | ||||
|     if (null === workflow.value) { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <pick-generic-doc-modal | ||||
|         :workflow="workflow" | ||||
|         :accompanying-period-id="props.accompanyingPeriodId" | ||||
|         :to-remove="attachedGenericDoc" | ||||
|         ref="pickDocModal" | ||||
|         @pickGenericDoc="onPickGenericDoc" | ||||
|     ></pick-generic-doc-modal> | ||||
|     <attachment-list | ||||
|         :workflow="workflow" | ||||
|         :attachments="props.attachments" | ||||
|         @removeAttachment="onRemoveAttachment" | ||||
|     ></attachment-list> | ||||
|     <ul v-if="canEditAttachement" class="record_actions"> | ||||
|     <ul class="record_actions"> | ||||
|         <li> | ||||
|             <button type="button" class="btn btn-create" @click="openModal"> | ||||
|                 Ajouter une pièce jointe | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| <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 DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; | ||||
|  | ||||
| interface AttachmentListProps { | ||||
|     attachments: WorkflowAttachment[]; | ||||
|     workflow: EntityWorkflow | null; | ||||
| } | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| @@ -37,12 +36,7 @@ const props = defineProps<AttachmentListProps>(); | ||||
|                             :stored-object="a.genericDoc.storedObject" | ||||
|                         ></document-action-buttons-group> | ||||
|                     </li> | ||||
|                     <li | ||||
|                         v-if=" | ||||
|                             !workflow?._permissions | ||||
|                                 .CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT | ||||
|                         " | ||||
|                     > | ||||
|                     <li> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="btn btn-delete" | ||||
|   | ||||
| @@ -6,10 +6,8 @@ import { | ||||
| import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue"; | ||||
| import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api"; | ||||
| import { computed, onMounted, ref } from "vue"; | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface PickGenericDocProps { | ||||
|     workflow: EntityWorkflow | null; | ||||
|     accompanyingPeriodId: number; | ||||
|     pickedList: GenericDocForAccompanyingPeriod[]; | ||||
|     toRemove: GenericDocForAccompanyingPeriod[]; | ||||
| @@ -38,21 +36,9 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean => | ||||
|     ) !== -1; | ||||
|  | ||||
| onMounted(async () => { | ||||
|     const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period( | ||||
|     genericDocs.value = await fetch_generic_docs_by_accompanying_period( | ||||
|         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; | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -3,10 +3,8 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import { computed, ref, useTemplateRef } from "vue"; | ||||
| import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue"; | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface PickGenericDocModalProps { | ||||
|     workflow: EntityWorkflow | null; | ||||
|     accompanyingPeriodId: number; | ||||
|     toRemove: GenericDocForAccompanyingPeriod[]; | ||||
| } | ||||
| @@ -82,7 +80,6 @@ defineExpose({ openModal, closeModal }); | ||||
|         </template> | ||||
|         <template v-slot:body> | ||||
|             <pick-generic-doc | ||||
|                 :workflow="props.workflow" | ||||
|                 :accompanying-period-id="props.accompanyingPeriodId" | ||||
|                 :to-remove="props.toRemove" | ||||
|                 :picked-list="pickeds" | ||||
|   | ||||
| @@ -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> | ||||
| @@ -58,14 +58,12 @@ | ||||
|         {% endif %} | ||||
|     </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/_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"> | ||||
|             <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} %} | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -12,25 +12,18 @@ declare(strict_types=1); | ||||
| namespace Chill\MainBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Chill\MainBundle\Workflow\Helper\MetadataExtractor; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
|  | ||||
| final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface | ||||
| class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface | ||||
| { | ||||
|     use NormalizerAwareTrait; | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|         private readonly MetadataExtractor $metadataExtractor, | ||||
|         private readonly Registry $registry, | ||||
|         private readonly Security $security, | ||||
|     ) {} | ||||
|     public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {} | ||||
|  | ||||
|     /** | ||||
|      * @param EntityWorkflow $object | ||||
| @@ -53,9 +46,6 @@ final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerA | ||||
|             'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context), | ||||
|             'title' => $handler->getEntityTitle($object), | ||||
|             'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(), | ||||
|             '_permissions' => [ | ||||
|                 EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object), | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -11,9 +11,6 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Workflow\Helper; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository; | ||||
| use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| @@ -151,11 +148,8 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         $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::FORCE_DENIED, new \DateTimeImmutable(), | ||||
|             'force deny because the user is not present as a dest 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); | ||||
| @@ -177,9 +171,6 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             '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(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
| @@ -241,13 +232,6 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|  | ||||
|         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']; | ||||
|     } | ||||
|  | ||||
|     public function testNoWorkflow(): void | ||||
| @@ -269,217 +253,7 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); | ||||
|         $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows); | ||||
|  | ||||
|         $repository = $this->prophesize(EntityWorkflowAttachmentRepository::class); | ||||
|         $repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]); | ||||
|  | ||||
|         return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment | ||||
|      * | ||||
|      * @param list<EntityWorkflow> $entityWorkflows | ||||
|      */ | ||||
|     public function testAllowedByWorkflowReadByAttachment( | ||||
|         array $entityWorkflows, | ||||
|         User $user, | ||||
|         string $expected, | ||||
|         ?\DateTimeImmutable $atDate, | ||||
|         string $message, | ||||
|     ): void { | ||||
|         // all entities must have this workflow name, so we are ok to set it here | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             $entityWorkflow->setWorkflowName('dummy'); | ||||
|         } | ||||
|         $helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate); | ||||
|  | ||||
|         self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message); | ||||
|     } | ||||
|  | ||||
|     public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable | ||||
|     { | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because the user is not present as a dest user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user is a current user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user was a previous user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'Abstain: there is a signature for person, but the attachment is not concerned']; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment | ||||
|      * | ||||
|      * @param list<EntityWorkflow> $entityWorkflows | ||||
|      */ | ||||
|     public function testAllowedByWorkflowWriteByAttachment( | ||||
|         array $entityWorkflows, | ||||
|         User $user, | ||||
|         string $expected, | ||||
|         ?\DateTimeImmutable $atDate, | ||||
|         string $message, | ||||
|     ): void { | ||||
|         // all entities must have this workflow name, so we are ok to set it here | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             $entityWorkflow->setWorkflowName('dummy'); | ||||
|         } | ||||
|         $helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate); | ||||
|  | ||||
|         self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message); | ||||
|     } | ||||
|  | ||||
|     public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable | ||||
|     { | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because there is no workflow']; | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because the user is not present as a dest user (and attachment)']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user is a current user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user was a previous user']; | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because the user was not a previous user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User()); | ||||
|         $entityWorkflow->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), | ||||
|             'force denied: user was a previous user, but it is finalized positive']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable()); | ||||
|         $entityWorkflow->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: user was a previous user, it is finalized, but finalized negative']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); | ||||
|         $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: there is a signature, but not on the attachment']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: there is a signature, but the signature is not on the attachment']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); | ||||
|         $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User()); | ||||
|         $entityWorkflow->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: there is a signature on a canceled workflow']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), | ||||
|             'force denied: the workflow is sent to an external user']; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param list<EntityWorkflow> $entityWorkflows | ||||
|      */ | ||||
|     private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->getUser()->willReturn($user); | ||||
|  | ||||
|         $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); | ||||
|         $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled(); | ||||
|  | ||||
|         $repository = $this->prophesize(EntityWorkflowAttachmentRepository::class); | ||||
|         $attachments = []; | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             $attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject()); | ||||
|         } | ||||
|         $repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments); | ||||
|  | ||||
|         return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); | ||||
|         return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); | ||||
|     } | ||||
|  | ||||
|     private static function buildRegistry(): Registry | ||||
| @@ -487,13 +261,10 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         $builder = new DefinitionBuilder(); | ||||
|         $builder | ||||
|             ->setInitialPlaces(['initial']) | ||||
|             ->addPlaces(['initial', 'test', 'sent_external', 'final_positive', 'final_negative']) | ||||
|             ->addPlaces(['initial', 'test', 'final_positive', 'final_negative']) | ||||
|             ->setMetadataStore( | ||||
|                 new InMemoryMetadataStore( | ||||
|                     placesMetadata: [ | ||||
|                         'sent_external' => [ | ||||
|                             'isSentExternal' => true, | ||||
|                         ], | ||||
|                         'final_positive' => [ | ||||
|                             'isFinal' => true, | ||||
|                             'isFinalPositive' => true, | ||||
|   | ||||
| @@ -11,11 +11,8 @@ declare(strict_types=1); | ||||
|  | ||||
| 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\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| @@ -26,7 +23,6 @@ use Chill\ThirdPartyBundle\Entity\ThirdParty; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Bridge\Twig\Mime\TemplatedEmail; | ||||
| use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Mime\Address; | ||||
| @@ -43,54 +39,24 @@ class PostSendExternalMessageHandlerTest extends TestCase | ||||
|     public function testSendMessageHappyScenario(): void | ||||
|     { | ||||
|         $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->futureDestineeEmails = ['external@example.com']; | ||||
|         $dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')]; | ||||
|         $entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         // Repository returns our workflow | ||||
|         $repository = $this->prophesize(EntityWorkflowRepository::class); | ||||
|         $repository->find(1)->willReturn($entityWorkflow); | ||||
|  | ||||
|         // Mailer must send to both recipients | ||||
|         $mailer = $this->prophesize(MailerInterface::class); | ||||
|         $mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce(); | ||||
|         $mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce(); | ||||
|  | ||||
|         // Workflow manager and handler | ||||
|         $workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class); | ||||
|         $workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title'); | ||||
|         $workflowManager = $this->prophesize(EntityWorkflowManager::class); | ||||
|         $workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal()); | ||||
|  | ||||
|         // Associated stored object for the workflow | ||||
|         $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 PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $workflowManager->reveal()); | ||||
|  | ||||
|         $handler(new PostSendExternalMessage(1, 'fr')); | ||||
|  | ||||
|   | ||||
| @@ -11,12 +11,9 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Workflow\Helper; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| @@ -61,39 +58,21 @@ class WorkflowRelatedEntityPermissionHelper | ||||
|     public function __construct( | ||||
|         private readonly Security $security, | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|         private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository, | ||||
|         private readonly Registry $registry, | ||||
|         private readonly ClockInterface $clock, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * @param object $entity The entity may be an | ||||
|      * | ||||
|      * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' | ||||
|      */ | ||||
|     public function isAllowedByWorkflowForReadOperation(object $entity): string | ||||
|     { | ||||
|         if ($entity instanceof StoredObject) { | ||||
|             $attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity); | ||||
|             $entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments); | ||||
|             $isAttached = true; | ||||
|         } else { | ||||
|             $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|             $isAttached = false; | ||||
|         } | ||||
|  | ||||
|         if ([] === $entityWorkflows) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|         $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|  | ||||
|         if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) { | ||||
|             return self::FORCE_GRANT; | ||||
|         } | ||||
|  | ||||
|         if ($isAttached) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|  | ||||
|         // give a view permission if there is a Person signature pending, or in the 12 hours following | ||||
|         // the signature last state | ||||
|         foreach ($entityWorkflows as $workflow) { | ||||
| @@ -121,51 +100,33 @@ class WorkflowRelatedEntityPermissionHelper | ||||
|      */ | ||||
|     public function isAllowedByWorkflowForWriteOperation(object $entity): string | ||||
|     { | ||||
|         if ($entity instanceof StoredObject) { | ||||
|             $attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity); | ||||
|             $entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments); | ||||
|             $isAttached = true; | ||||
|         } else { | ||||
|             $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|             $isAttached = false; | ||||
|         } | ||||
|         $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|         $runningWorkflows = []; | ||||
|  | ||||
|         if ([] === $entityWorkflows) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|         // if a workflow is finalized positive, we are not allowed to edit to document any more | ||||
|  | ||||
|         // if a workflow is finalized positive, anyone is allowed to edit the document anymore | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); | ||||
|             $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); | ||||
|             foreach ($marking->getPlaces() as $place => $int) { | ||||
|                 $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); | ||||
|                 if ( | ||||
|                     ($entityWorkflow->isFinal() && ($placeMetadata['isFinalPositive'] ?? false)) | ||||
|                     || ($placeMetadata['isSentExternal'] ?? false) | ||||
|                 ) { | ||||
|                     // 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; | ||||
|             if ($entityWorkflow->isFinal()) { | ||||
|                 $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); | ||||
|                 $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); | ||||
|                 foreach ($marking->getPlaces() as $place => $int) { | ||||
|                     $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); | ||||
|                     if (true === ($placeMetadata['isFinalPositive'] ?? false)) { | ||||
|                         // the workflow is final, and final positive, so we stop here. | ||||
|                         return self::FORCE_DENIED; | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|                 $runningWorkflows[] = $entityWorkflow; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal()); | ||||
|  | ||||
|         // if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore | ||||
|         if (!$isAttached) { | ||||
|             foreach ($runningWorkflows as $entityWorkflow) { | ||||
|                 foreach ($entityWorkflow->getSteps() as $step) { | ||||
|                     foreach ($step->getSignatures() as $signature) { | ||||
|                         if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { | ||||
|                             return self::FORCE_DENIED; | ||||
|                         } | ||||
|         // if there is a signature on a **running workflow**, no one can edit the workflow any more | ||||
|         foreach ($runningWorkflows as $entityWorkflow) { | ||||
|             foreach ($entityWorkflow->getSteps() as $step) { | ||||
|                 foreach ($step->getSignatures() as $signature) { | ||||
|                     if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { | ||||
|                         return self::FORCE_DENIED; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| @@ -176,11 +137,7 @@ class WorkflowRelatedEntityPermissionHelper | ||||
|             return self::FORCE_GRANT; | ||||
|         } | ||||
|  | ||||
|         if ($isAttached) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|  | ||||
|         return self::FORCE_DENIED; | ||||
|         return self::ABSTAIN; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -11,13 +11,9 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Workflow\Messenger; | ||||
|  | ||||
| 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\Repository\Workflow\EntityWorkflowRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Bridge\Twig\Mime\TemplatedEmail; | ||||
| use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; | ||||
| @@ -29,8 +25,6 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt | ||||
|         private EntityWorkflowRepository $entityWorkflowRepository, | ||||
|         private MailerInterface $mailer, | ||||
|         private EntityWorkflowManager $workflowManager, | ||||
|         private StoredObjectToPdfConverter $storedObjectToPdfConverter, | ||||
|         private LoggerInterface $logger, | ||||
|     ) {} | ||||
|  | ||||
|     public function __invoke(PostSendExternalMessage $message): void | ||||
| @@ -41,34 +35,11 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt | ||||
|             throw new UnrecoverableMessageHandlingException(sprintf('Entity workflow with id %d not found', $message->entityWorkflowId)); | ||||
|         } | ||||
|  | ||||
|         $this->convertToPdf($entityWorkflow, $message->lang); | ||||
|  | ||||
|         foreach ($entityWorkflow->getCurrentStep()->getSends() as $send) { | ||||
|             $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()]); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $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()]); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private function sendEmailToDestinee(EntityWorkflowSend $send, PostSendExternalMessage $message): void | ||||
|     { | ||||
|         $entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow(); | ||||
|   | ||||
| @@ -22,7 +22,6 @@ use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
| use Symfony\Component\Workflow\Transition; | ||||
|  | ||||
| /** | ||||
|  * 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 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); | ||||
|  | ||||
| @@ -63,14 +60,7 @@ class SignatureStepStateChanger | ||||
|             ->setZoneSignatureIndex($atIndex) | ||||
|             ->setStateDate($this->clock->now()); | ||||
|         $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())); | ||||
|         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. | ||||
|      * 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); | ||||
|  | ||||
| @@ -92,15 +80,7 @@ class SignatureStepStateChanger | ||||
|             ->setState(EntityWorkflowSignatureStateEnum::CANCELED) | ||||
|             ->setStateDate($this->clock->now()); | ||||
|         $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())); | ||||
|         if (null === $transition) { | ||||
|             return $signature->getStep()->getEntityWorkflow()->getStep(); | ||||
|         } | ||||
|  | ||||
|         return $transition->getTos()[0]; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -113,10 +93,8 @@ class SignatureStepStateChanger | ||||
|      * a state change has occurred. | ||||
|      * | ||||
|      * @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); | ||||
|  | ||||
| @@ -124,16 +102,7 @@ class SignatureStepStateChanger | ||||
|             ->setState(EntityWorkflowSignatureStateEnum::REJECTED) | ||||
|             ->setStateDate($this->clock->now()); | ||||
|         $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())); | ||||
|  | ||||
|         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); | ||||
|  | ||||
|         ['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())) { | ||||
|             $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()]); | ||||
| @@ -200,7 +144,7 @@ class SignatureStepStateChanger | ||||
|         if (null === $transition) { | ||||
|             $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()) { | ||||
| @@ -212,16 +156,19 @@ class SignatureStepStateChanger | ||||
|         if (null === $futureUser) { | ||||
|             $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) { | ||||
|             if ($transitionObj->getName() === $transition) { | ||||
|                 return ['transition' => $transitionObj, 'futureUser' => $futureUser]; | ||||
|             } | ||||
|         } | ||||
|         $transitionDto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $transitionDto->futureDestUsers[] = $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 | ||||
|   | ||||
| @@ -965,31 +965,6 @@ paths: | ||||
|                         application/json: | ||||
|                             schema: | ||||
|                                 $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: | ||||
|         get: | ||||
|             tags: | ||||
|   | ||||
| @@ -120,8 +120,5 @@ module.exports = function (encore, entries) { | ||||
|     "vue_onthefly", | ||||
|     __dirname + "/Resources/public/vuejs/OnTheFly/index.js", | ||||
|   ); | ||||
|   encore.addEntry( | ||||
|     "page_workflow_waiting_post_process", | ||||
|     __dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts" | ||||
|   ); | ||||
|  | ||||
| }; | ||||
|   | ||||
| @@ -666,17 +666,10 @@ workflow: | ||||
|         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_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: | ||||
|         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 all steps: Recevoir une notification à chaque étape | ||||
| CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows | ||||
|   | ||||
		Reference in New Issue
	
	Block a user