mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 17:28:23 +00:00 
			
		
		
		
	Compare commits
	
		
			122 Commits
		
	
	
		
			693-filter
			...
			issue706_c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | b59c69bce4 | ||
|  | 6f9da67942 | ||
|  | 609e2e1610 | ||
|  | d940d13d7d | ||
|  | 1cf99b4383 | ||
|  | c969b35fd1 | ||
|  | cbf8a9ff71 | ||
|  | 7c18899249 | ||
|  | f4f465b33a | ||
|  | aed7fcebae | ||
| 44ecad2bca | |||
| d1bdf41c4c | |||
| 4a30f310b8 | |||
| 896b4cdfe3 | |||
| a272dabcaf | |||
| 3901fe2d32 | |||
| 78858e84f2 | |||
| cc98f64be5 | |||
| 6e812b54e1 | |||
| 83e0a50b57 | |||
| 3aac4d5d35 | |||
| 23cee274a5 | |||
| aacb54037b | |||
| 57cb96320c | |||
| 5a2d80cb4d | |||
| 2e822a9486 | |||
| f376b1af49 | |||
| dd621186e8 | |||
| 1b15abe635 | |||
| 1965fc55f4 | |||
| a9290eb3fe | |||
| e78eb8789d | |||
| 9911112e08 | |||
| c0675aee9b | |||
| 03ee04978c | |||
| 15d68df8c6 | |||
| 1b2c0ecc87 | |||
| f15017ebd7 | |||
| 9a56a1b115 | |||
| 11e7f2179c | |||
| e982e81900 | |||
| e50b02a8c7 | |||
| bea839663f | |||
| ac4c821290 | |||
| c953da3fd0 | |||
| f5d17eb38c | |||
| 73f332927d | |||
| ef75deda26 | |||
| 6264a95d62 | |||
| 26a6169b95 | |||
| 678defdee7 | |||
| 88ccbd450a | |||
| 62532e0a90 | |||
| 73fa585707 | |||
| 21a16dcbe2 | |||
| b30e966316 | |||
| 9696a8194c | |||
| 6749758b46 | |||
| f1ebc089c3 | |||
| 813adc70f4 | |||
| 95984eff6d | |||
| 3db5b62d57 | |||
| cf1cc937ca | |||
| 14df8fe9ad | |||
| fe4388c884 | |||
| dbcc425f5f | |||
| 77c545344c | |||
| c13d672db2 | |||
| a16244a3f5 | |||
| 27f13e0dd1 | |||
| f07ea3259e | |||
| 1f4438690e | |||
| 744b62184a | |||
| 5ee0ab5ab8 | |||
| 4370349f10 | |||
| 6254303392 | |||
| 9676975cd8 | |||
| 7c4bc8f46a | |||
| 55a845fcd6 | |||
| 91d21ba939 | |||
| 55918bcafb | |||
| bb05ba0f17 | |||
| eac3471cbb | |||
| f653f8fd7a | |||
| 6d2c6fb6e1 | |||
| aea6272c4d | |||
| d6df16973a | |||
| 80835dd7c3 | |||
| 1285100801 | |||
| a4e21b7834 | |||
| 1a44a516c2 | |||
| fb9b9b9226 | |||
| 0ace1c1f6a | |||
| 7d80507517 | |||
| b5ec0919e7 | |||
| 068311d071 | |||
| 5756a37178 | |||
| c83e8ad9a4 | |||
| 5bfd2aefe6 | |||
| 2165e04ec3 | |||
| 1a8e21a77f | |||
| 50bb8f10cf | |||
| 1c673db628 | |||
| fa8a2c5cc5 | |||
| d46304e229 | |||
| 4dd81da1ef | |||
| 882e72b609 | |||
| bb7d072cc8 | |||
| f76c031ff3 | |||
| 86b5f4dfac | |||
| ded71c5997 | |||
| 5ae4eb1bf7 | |||
| 7f9e045d5d | |||
| de9d53936f | |||
| 6c1108b8aa | |||
| 5bbe5af124 | |||
| 2c5c815f68 | |||
| 44ef21f940 | |||
| 68998c9156 | |||
| b93b78615b | |||
| b2924ede70 | |||
| fb51e44e45 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -5,6 +5,7 @@ composer.lock | ||||
| docs/build/ | ||||
| node_modules/* | ||||
| .php_cs.cache | ||||
| .cache/* | ||||
|  | ||||
| ###> symfony/framework-bundle ### | ||||
| /.env.local | ||||
|   | ||||
| @@ -340,11 +340,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" | ||||
| 			count: 3 | ||||
| 			path: src/Bundle/ChillPersonBundle/Search/PersonSearch.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method Chill\\\\PersonBundle\\\\Search\\\\PersonSearch\\:\\:renderResult\\(\\) should return string but return statement is missing\\.$#" | ||||
| 			count: 1 | ||||
|   | ||||
| @@ -50,7 +50,7 @@ class LocationFilter implements FilterInterface | ||||
|     { | ||||
|         $builder->add('accepted_location', PickUserLocationType::class, [ | ||||
|             'multiple' => true, | ||||
|             'label' => 'pick location' | ||||
|             'label' => 'pick location', | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -88,3 +88,11 @@ div.flex-bloc.concerned-groups { | ||||
|       font-size: 120%; | ||||
|    } | ||||
| } | ||||
|  | ||||
| /// DOCUMENT LIST IN ACTIVITY ITEM | ||||
| li.document-list-item { | ||||
|     display: flex; | ||||
|     width: 100%; | ||||
|     justify-content: space-between; | ||||
|     margin-bottom: 0.3rem; | ||||
| } | ||||
|   | ||||
| @@ -68,7 +68,7 @@ | ||||
|                     <div class="wl-col title"><h3>{{ 'Referrer'|trans }}</h3></div> | ||||
|                     <div class="wl-col list"> | ||||
|                         <p class="wl-item"> | ||||
|                             {{ activity.user|chill_entity_render_box }} | ||||
|                             <span class="badge-user">{{ activity.user|chill_entity_render_box }}</span> | ||||
|                         </p> | ||||
|                     </div> | ||||
|                 </div> | ||||
| @@ -137,19 +137,42 @@ | ||||
|                         {{ activity.comment|chill_entity_render_box({ | ||||
|                             'disable_markdown': false, | ||||
|                             'limit_lines': 3, | ||||
|                             'metadata': false | ||||
|                             'metadata': false, | ||||
|                         }) }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             {% endif %} | ||||
|  | ||||
|             {#  Only if ACL SEE_DETAILS AND/OR only on template SHOW ?? | ||||
|                 durationTime | ||||
|                 travelTime | ||||
|                 comment | ||||
|                 documents | ||||
|                 attendee | ||||
|             #} | ||||
|             {% if is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) and activity.privateComment.hasCommentForUser(app.user) %} | ||||
|                 <div class="wl-row"> | ||||
|                     <div class="wl-col title"> | ||||
|                         <h3>{{ 'Private comment'|trans }}</h3> | ||||
|                     </div> | ||||
|                     <div class="wl-col list"> | ||||
|                         <section class="chill-entity entity-comment-embeddable"> | ||||
|                             <blockquote class="chill-user-quote private-quote"> | ||||
|                                 {{ activity.privateComment.comments[app.user.id]|chill_markdown_to_html }} | ||||
|                             </blockquote> | ||||
|                         </section> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) and activity.documents|length > 0 %} | ||||
|                 <div class="wl-row"> | ||||
|                     <div class="wl-col title"> | ||||
|                         <h3>{{ 'Documents'|trans }}</h3> | ||||
|                     </div> | ||||
|                     <div class="wl-col list"> | ||||
|                         <ul> | ||||
|                             {% for d in activity.documents %} | ||||
|                                 <li class="document-list-item">{{ d.title|chill_print_or_message('document.Any title') }} {{ d|chill_document_button_group(d.title, is_granted('CHILL_ACTIVITY_UPDATE', activity), {small: true}) }}</li> | ||||
|                             {% endfor %} | ||||
|                         </ul> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             {% endif %} | ||||
|  | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|   | ||||
| @@ -8,11 +8,13 @@ | ||||
| {% block js %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} | ||||
|     {{ encore_entry_script_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} | ||||
|     {{ encore_entry_link_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|   | ||||
| @@ -23,11 +23,13 @@ | ||||
| {% block js %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} | ||||
|     {{ encore_entry_script_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} | ||||
|     {{ encore_entry_link_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|   | ||||
| @@ -41,7 +41,7 @@ | ||||
|                         {% if activity.user and t.userVisible %} | ||||
|                             <li> | ||||
|                                 <span class="item-key">{{ 'Referrer'|trans ~ ': ' }}</span> | ||||
|                                 <b>{{ activity.user|chill_entity_render_box}}</b> | ||||
|                                 <span class="badge-user">{{ activity.user|chill_entity_render_box }}</span> | ||||
|                             </li> | ||||
|                         {% endif %} | ||||
|  | ||||
|   | ||||
| @@ -35,7 +35,9 @@ | ||||
|         <div class="item-row separator"> | ||||
|             <dl class="chill_view_data"> | ||||
|                 <dt class="inline">{{ 'Referrer'|trans|capitalize }}</dt> | ||||
|                 <dd>{{ entity.user|chill_entity_render_box }}</dd> | ||||
|                 <dd> | ||||
|                     <span class="badge-user">{{ entity.user|chill_entity_render_box }}</span> | ||||
|                 </dd> | ||||
|  | ||||
|                 {%- if entity.scope -%} | ||||
|                     <dt class="inline">{{ 'Scope'|trans }}</dt> | ||||
| @@ -168,7 +170,7 @@ | ||||
|                     {% if entity.documents|length > 0 %} | ||||
|                         <ul> | ||||
|                             {% for d in entity.documents %} | ||||
|                                 <li>{{ d.title }} {{ d|chill_document_button_group() }}</li> | ||||
|                                 <li class="document-list-item">{{ d.title|chill_print_or_message('document.Any title') }} {{ d|chill_document_button_group(d.title, is_granted('CHILL_ACTIVITY_UPDATE', entity), {small: true}) }}</li> | ||||
|                             {% endfor %} | ||||
|                         </ul> | ||||
|                     {% else %} | ||||
|   | ||||
| @@ -22,6 +22,7 @@ use Chill\DocStoreBundle\Repository\DocumentCategoryRepository; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Repository\PersonRepository; | ||||
| use Chill\PersonBundle\Templating\Entity\PersonRenderInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bridge\Doctrine\Form\Type\EntityType; | ||||
| @@ -45,6 +46,8 @@ class ActivityContext implements | ||||
|  | ||||
|     private PersonRenderInterface $personRender; | ||||
|  | ||||
|     private PersonRepository $personRepository; | ||||
|  | ||||
|     private TranslatableStringHelperInterface $translatableStringHelper; | ||||
|  | ||||
|     private TranslatorInterface $translator; | ||||
| @@ -55,6 +58,7 @@ class ActivityContext implements | ||||
|         TranslatableStringHelperInterface $translatableStringHelper, | ||||
|         EntityManagerInterface $em, | ||||
|         PersonRenderInterface $personRender, | ||||
|         PersonRepository $personRepository, | ||||
|         TranslatorInterface $translator, | ||||
|         BaseContextData $baseContextData | ||||
|     ) { | ||||
| @@ -63,6 +67,7 @@ class ActivityContext implements | ||||
|         $this->translatableStringHelper = $translatableStringHelper; | ||||
|         $this->em = $em; | ||||
|         $this->personRender = $personRender; | ||||
|         $this->personRepository = $personRepository; | ||||
|         $this->translator = $translator; | ||||
|         $this->baseContextData = $baseContextData; | ||||
|     } | ||||
| @@ -147,7 +152,7 @@ class ActivityContext implements | ||||
|         $options = $template->getOptions(); | ||||
|  | ||||
|         $data = []; | ||||
|         $data = array_merge($data, $this->baseContextData->getData()); | ||||
|         $data = array_merge($data, $this->baseContextData->getData($contextGenerationData['creator'] ?? null)); | ||||
|         $data['activity'] = $this->normalizer->normalize($entity, 'docgen', ['docgen:expects' => Activity::class, 'groups' => 'docgen:read']); | ||||
|  | ||||
|         $data['course'] = $this->normalizer->normalize($entity->getAccompanyingPeriod(), 'docgen', ['docgen:expects' => AccompanyingPeriod::class, 'groups' => 'docgen:read']); | ||||
| @@ -206,6 +211,32 @@ class ActivityContext implements | ||||
|         return $options['mainPerson'] || $options['person1'] || $options['person2']; | ||||
|     } | ||||
|  | ||||
|     public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array | ||||
|     { | ||||
|         $normalized = []; | ||||
|  | ||||
|         foreach (['mainPerson', 'person1', 'person2'] as $k) { | ||||
|             $normalized[$k] = null === $data[$k] ? null : $data[$k]->getId(); | ||||
|         } | ||||
|  | ||||
|         return $normalized; | ||||
|     } | ||||
|  | ||||
|     public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array | ||||
|     { | ||||
|         $denormalized = []; | ||||
|  | ||||
|         foreach (['mainPerson', 'person1', 'person2'] as $k) { | ||||
|             if (null !== ($id = ($data[$k] ?? null))) { | ||||
|                 $denormalized[$k] = $this->personRepository->find($id); | ||||
|             } else { | ||||
|                 $denormalized[$k] = null; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $denormalized; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param Activity $entity | ||||
|      */ | ||||
|   | ||||
| @@ -146,6 +146,16 @@ class ListActivitiesByAccompanyingPeriodContext implements | ||||
|         return $this->accompanyingPeriodContext->hasPublicForm($template, $entity); | ||||
|     } | ||||
|  | ||||
|     public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array | ||||
|     { | ||||
|         return $this->accompanyingPeriodContext->contextGenerationDataNormalize($template, $entity, $data); | ||||
|     } | ||||
|  | ||||
|     public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array | ||||
|     { | ||||
|         return $this->accompanyingPeriodContext->contextGenerationDataDenormalize($template, $entity, $data); | ||||
|     } | ||||
|  | ||||
|     public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void | ||||
|     { | ||||
|         $this->accompanyingPeriodContext->storeGenerated($template, $storedObject, $entity, $contextGenerationData); | ||||
|   | ||||
| @@ -77,7 +77,7 @@ Choose a type: Choisir un type | ||||
| 4 hours: 4 heures | ||||
| 4 hours 30: 4 heures 30 | ||||
| 5 hours: 5 heures | ||||
| Concerned groups: Parties concernées | ||||
| Concerned groups: Parties concernées par l'échange | ||||
| Persons in accompanying course: Usagers du parcours | ||||
| Third persons: Tiers non-pro. | ||||
| Others persons: Usagers | ||||
|   | ||||
| @@ -1,10 +1,18 @@ | ||||
| <?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\AsideActivityBundle\Export\Export; | ||||
|  | ||||
| use Chill\AsideActivityBundle\Entity\AsideActivity; | ||||
| use Chill\AsideActivityBundle\Export\Declarations; | ||||
| use Chill\AsideActivityBundle\Form\AsideActivityCategoryType; | ||||
| use Chill\AsideActivityBundle\Repository\AsideActivityCategoryRepository; | ||||
| use Chill\AsideActivityBundle\Security\AsideActivityVoter; | ||||
| use Chill\AsideActivityBundle\Templating\Entity\CategoryRender; | ||||
| @@ -16,32 +24,32 @@ use Chill\MainBundle\Export\Helper\UserHelper; | ||||
| use Chill\MainBundle\Export\ListInterface; | ||||
| use Chill\MainBundle\Repository\CenterRepositoryInterface; | ||||
| use Chill\MainBundle\Repository\ScopeRepositoryInterface; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelper; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Closure; | ||||
| use DateTimeInterface; | ||||
| use Doctrine\ORM\AbstractQuery; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use LogicException; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
| { | ||||
|     private EntityManagerInterface $em; | ||||
|  | ||||
|     private UserHelper $userHelper; | ||||
|  | ||||
|     private DateTimeHelper $dateTimeHelper; | ||||
|  | ||||
|     private ScopeRepositoryInterface $scopeRepository; | ||||
|  | ||||
|     private CenterRepositoryInterface $centerRepository; | ||||
|  | ||||
|     private AsideActivityCategoryRepository $asideActivityCategoryRepository; | ||||
|  | ||||
|     private CategoryRender $categoryRender; | ||||
|  | ||||
|     private CenterRepositoryInterface $centerRepository; | ||||
|  | ||||
|     private DateTimeHelper $dateTimeHelper; | ||||
|  | ||||
|     private EntityManagerInterface $em; | ||||
|  | ||||
|     private ScopeRepositoryInterface $scopeRepository; | ||||
|  | ||||
|     private TranslatableStringHelperInterface $translatableStringHelper; | ||||
|  | ||||
|     private UserHelper $userHelper; | ||||
|  | ||||
|     public function __construct( | ||||
|         EntityManagerInterface $em, | ||||
|         DateTimeHelper $dateTimeHelper, | ||||
| @@ -76,11 +84,6 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|         return 'export.aside_activity.List of aside activities'; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.aside_activity.List of aside activities'; | ||||
|     } | ||||
|  | ||||
|     public function getGroup(): string | ||||
|     { | ||||
|         return 'export.Exports of aside activities'; | ||||
| @@ -91,15 +94,16 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|         switch ($key) { | ||||
|             case 'id': | ||||
|             case 'note': | ||||
|                 return function ($value) use ($key) { | ||||
|                 return static function ($value) use ($key) { | ||||
|                     if ('_header' === $value) { | ||||
|                         return 'export.aside_activity.' . $key; | ||||
|                     } | ||||
|  | ||||
|                     return $value ?? ''; | ||||
|                 }; | ||||
|  | ||||
|             case 'duration': | ||||
|                 return function ($value) use ($key) { | ||||
|                 return static function ($value) use ($key) { | ||||
|                     if ('_header' === $value) { | ||||
|                         return 'export.aside_activity.' . $key; | ||||
|                     } | ||||
| @@ -108,7 +112,7 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|                         return ''; | ||||
|                     } | ||||
|  | ||||
|                     if ($value instanceof \DateTimeInterface) { | ||||
|                     if ($value instanceof DateTimeInterface) { | ||||
|                         return $value->format('H:i:s'); | ||||
|                     } | ||||
|  | ||||
| @@ -118,7 +122,7 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|             case 'createdAt': | ||||
|             case 'updatedAt': | ||||
|             case 'date': | ||||
|                 return $this->dateTimeHelper->getLabel('export.aside_activity.'.$key); | ||||
|                 return $this->dateTimeHelper->getLabel('export.aside_activity.' . $key); | ||||
|  | ||||
|             case 'agent_id': | ||||
|             case 'creator_id': | ||||
| @@ -165,7 +169,7 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|                 }; | ||||
|  | ||||
|             default: | ||||
|                 throw new \LogicException('this key is not supported : ' . $key); | ||||
|                 throw new LogicException('this key is not supported : ' . $key); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -182,7 +186,7 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|             'aside_activity_type', | ||||
|             'date', | ||||
|             'duration', | ||||
|             'note' | ||||
|             'note', | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
| @@ -195,6 +199,11 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|         return $query->getQuery()->getResult(AbstractQuery::HYDRATE_ARRAY); | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.aside_activity.List of aside activities'; | ||||
|     } | ||||
|  | ||||
|     public function getType(): string | ||||
|     { | ||||
|         return Declarations::ASIDE_ACTIVITY_TYPE; | ||||
| @@ -204,8 +213,7 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|     { | ||||
|         $qb = $this->em->createQueryBuilder() | ||||
|             ->from(AsideActivity::class, 'aside') | ||||
|             ->leftJoin('aside.agent', 'agent') | ||||
|         ; | ||||
|             ->leftJoin('aside.agent', 'agent'); | ||||
|  | ||||
|         $qb | ||||
|             ->addSelect('aside.id AS id') | ||||
| @@ -218,8 +226,7 @@ final class ListAsideActivity implements ListInterface, GroupedExportInterface | ||||
|             ->addSelect('IDENTITY(aside.type) AS aside_activity_type') | ||||
|             ->addSelect('aside.date') | ||||
|             ->addSelect('aside.duration') | ||||
|             ->addSelect('aside.note') | ||||
|         ; | ||||
|             ->addSelect('aside.note'); | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
|   | ||||
| @@ -17,7 +17,6 @@ use Chill\MainBundle\Form\Type\Export\FilterType; | ||||
| use Chill\MainBundle\Form\Type\PickRollingDateType; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDate; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; | ||||
| use Doctrine\ORM\Query\Expr\Andx; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\Form\FormError; | ||||
|   | ||||
| @@ -15,7 +15,6 @@ use Chill\AsideActivityBundle\Export\Declarations; | ||||
| use Chill\MainBundle\Export\FilterInterface; | ||||
| use Chill\MainBundle\Form\Type\PickUserDynamicType; | ||||
| use Chill\MainBundle\Templating\Entity\UserRender; | ||||
| use Doctrine\ORM\Query\Expr\Andx; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
|   | ||||
| @@ -29,10 +29,10 @@ class CalculatorManager | ||||
|  | ||||
|     public function addCalculator(CalculatorInterface $calculator, bool $default) | ||||
|     { | ||||
|         $this->calculators[$calculator::getAlias()] = $calculator; | ||||
|         $this->calculators[$calculator->getAlias()] = $calculator; | ||||
|  | ||||
|         if ($default) { | ||||
|             $this->defaultCalculator[] = $calculator::getAlias(); | ||||
|             $this->defaultCalculator[] = $calculator->getAlias(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -50,7 +50,7 @@ class CalculatorManager | ||||
|             $result = $calculator->calculate($elements); | ||||
|  | ||||
|             if (null !== $result) { | ||||
|                 $results[$calculator::getAlias()] = $result; | ||||
|                 $results[$calculator->getAlias()] = $result; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -30,7 +30,7 @@ class ChargeKindType extends AbstractType | ||||
|             ]) | ||||
|             ->add('kind', TextType::class, [ | ||||
|                 'label' => 'budget.admin.form.Charge_kind_key', | ||||
|                 'help' => 'budget.admin.form.This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document' | ||||
|                 'help' => 'budget.admin.form.This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document', | ||||
|             ]) | ||||
|             ->add('ordering', NumberType::class) | ||||
|             ->add('isActive', CheckboxType::class, [ | ||||
|   | ||||
| @@ -30,7 +30,7 @@ class ResourceKindType extends AbstractType | ||||
|             ]) | ||||
|             ->add('kind', TextType::class, [ | ||||
|                 'label' => 'budget.admin.form.Resource_kind_key', | ||||
|                 'help' => 'budget.admin.form.This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document' | ||||
|                 'help' => 'budget.admin.form.This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document', | ||||
|             ]) | ||||
|             ->add('ordering', NumberType::class) | ||||
|             ->add('isActive', CheckboxType::class, [ | ||||
|   | ||||
| @@ -49,8 +49,7 @@ final class ChargeKindRepository implements ChargeKindRepositoryInterface | ||||
|             ->where($qb->expr()->eq('c.isActive', 'true')) | ||||
|             ->orderBy('c.ordering', 'ASC') | ||||
|             ->getQuery() | ||||
|             ->getResult() | ||||
|         ; | ||||
|             ->getResult(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -28,8 +28,6 @@ interface ChargeKindRepositoryInterface extends ObjectRepository | ||||
|      */ | ||||
|     public function findAllActive(): array; | ||||
|  | ||||
|     public function findOneByKind(string $kind): ?ChargeKind; | ||||
|  | ||||
|     /** | ||||
|      * @return ChargeType[] | ||||
|      */ | ||||
| @@ -45,5 +43,7 @@ interface ChargeKindRepositoryInterface extends ObjectRepository | ||||
|  | ||||
|     public function findOneBy(array $criteria): ?ChargeKind; | ||||
|  | ||||
|     public function findOneByKind(string $kind): ?ChargeKind; | ||||
|  | ||||
|     public function getClassName(): string; | ||||
| } | ||||
|   | ||||
| @@ -49,8 +49,7 @@ final class ResourceKindRepository implements ResourceKindRepositoryInterface | ||||
|             ->where($qb->expr()->eq('r.isActive', 'true')) | ||||
|             ->orderBy('r.ordering', 'ASC') | ||||
|             ->getQuery() | ||||
|             ->getResult() | ||||
|         ; | ||||
|             ->getResult(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -34,7 +34,7 @@ class ResourceRepository extends EntityRepository | ||||
|             //->andWhere('c.startDate < :date') | ||||
|             // TODO: there is a misconception here, the end date must be lower or null. startDate are never null | ||||
|             //->andWhere('c.startDate < :date OR c.startDate IS NULL'); | ||||
|         ; | ||||
| ; | ||||
|  | ||||
|         if (null !== $sort) { | ||||
|             $qb->orderBy($sort); | ||||
|   | ||||
| @@ -13,9 +13,7 @@ namespace Chill\BudgetBundle\Service\Summary; | ||||
|  | ||||
| use Chill\BudgetBundle\Entity\ChargeKind; | ||||
| use Chill\BudgetBundle\Entity\ResourceKind; | ||||
| use Chill\BudgetBundle\Repository\ChargeKindRepository; | ||||
| use Chill\BudgetBundle\Repository\ChargeKindRepositoryInterface; | ||||
| use Chill\BudgetBundle\Repository\ResourceKindRepository; | ||||
| use Chill\BudgetBundle\Repository\ResourceKindRepositoryInterface; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\Household\Household; | ||||
|   | ||||
| @@ -20,12 +20,15 @@ use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\Household\Household; | ||||
| use Chill\PersonBundle\Entity\Household\HouseholdMember; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use DateTimeImmutable; | ||||
| use Doctrine\ORM\AbstractQuery; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\Query; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use ReflectionClass; | ||||
| use RuntimeException; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
| @@ -47,10 +50,9 @@ final class SummaryBudgetTest extends TestCase | ||||
|             ], | ||||
|         ]); | ||||
|         $queryCharges->setParameters(Argument::type('array')) | ||||
|             ->will(function ($args, $query) { | ||||
|             ->will(static function ($args, $query) { | ||||
|                 return $query; | ||||
|             }) | ||||
|         ; | ||||
|             }); | ||||
|  | ||||
|         $queryResources = $this->prophesize(AbstractQuery::class); | ||||
|         $queryResources->getResult()->willReturn([ | ||||
| @@ -61,23 +63,23 @@ final class SummaryBudgetTest extends TestCase | ||||
|             ], | ||||
|         ]); | ||||
|         $queryResources->setParameters(Argument::type('array')) | ||||
|             ->will(function ($args, $query) { | ||||
|             ->will(static function ($args, $query) { | ||||
|                 return $query; | ||||
|             }) | ||||
|         ; | ||||
|             }); | ||||
|  | ||||
|         $em = $this->prophesize(EntityManagerInterface::class); | ||||
|         $em->createNativeQuery(Argument::type('string'), Argument::type(Query\ResultSetMapping::class)) | ||||
|             ->will(function ($args) use ($queryResources, $queryCharges) { | ||||
|             ->will(static function ($args) use ($queryResources, $queryCharges) { | ||||
|                 if (false !== strpos($args[0], 'chill_budget.resource')) { | ||||
|                     return $queryResources->reveal(); | ||||
|                 } | ||||
|  | ||||
|                 if (false !== strpos($args[0], 'chill_budget.charge')) { | ||||
|                     return $queryCharges->reveal(); | ||||
|                 } | ||||
|                 throw new \RuntimeException('this query does not have a stub counterpart: '.$args[0]); | ||||
|             }) | ||||
|         ; | ||||
|  | ||||
|                 throw new RuntimeException('this query does not have a stub counterpart: ' . $args[0]); | ||||
|             }); | ||||
|  | ||||
|         $chargeRepository = $this->prophesize(ChargeKindRepositoryInterface::class); | ||||
|         $chargeRepository->findAll()->willReturn([ | ||||
| @@ -98,24 +100,23 @@ final class SummaryBudgetTest extends TestCase | ||||
|         $resourceRepository->findOneByKind('misc')->willReturn($misc); | ||||
|  | ||||
|         $translatableStringHelper = $this->prophesize(TranslatableStringHelperInterface::class); | ||||
|         $translatableStringHelper->localize(Argument::type('array'))->will(function ($arg) { | ||||
|         $translatableStringHelper->localize(Argument::type('array'))->will(static function ($arg) { | ||||
|             return $arg[0]['fr']; | ||||
|         }); | ||||
|  | ||||
|         $person = new Person(); | ||||
|         $personReflection = new \ReflectionClass($person); | ||||
|         $personReflection = new ReflectionClass($person); | ||||
|         $personIdReflection = $personReflection->getProperty('id'); | ||||
|         $personIdReflection->setAccessible(true); | ||||
|         $personIdReflection->setValue($person, 1); | ||||
|  | ||||
|         $household = new Household(); | ||||
|         $householdReflection = new \ReflectionClass($household); | ||||
|         $householdReflection = new ReflectionClass($household); | ||||
|         $householdId = $householdReflection->getProperty('id'); | ||||
|         $householdId->setAccessible(true); | ||||
|         $householdId->setValue($household, 1); | ||||
|         $householdMember = (new HouseholdMember())->setPerson($person) | ||||
|             ->setStartDate(new \DateTimeImmutable('1 month ago')) | ||||
|         ; | ||||
|             ->setStartDate(new DateTimeImmutable('1 month ago')); | ||||
|         $household->addMember($householdMember); | ||||
|  | ||||
|         $summaryBudget = new SummaryBudget( | ||||
|   | ||||
| @@ -2,6 +2,13 @@ | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\Migrations\Budget; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| @@ -9,6 +16,12 @@ use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20230209161546 extends AbstractMigration | ||||
| { | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('DROP INDEX resource_kind_unique_type_idx'); | ||||
|         $this->addSql('DROP INDEX charge_kind_unique_type_idx'); | ||||
|     } | ||||
|  | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Budget: add unique constraint on kind for charge_kind and resource_kind'; | ||||
| @@ -21,10 +34,4 @@ final class Version20230209161546 extends AbstractMigration | ||||
|         $this->addSql('CREATE UNIQUE INDEX resource_kind_unique_type_idx ON chill_budget.resource_type (kind);'); | ||||
|         $this->addSql('CREATE UNIQUE INDEX charge_kind_unique_type_idx ON chill_budget.charge_type (kind);'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('DROP INDEX resource_kind_unique_type_idx'); | ||||
|         $this->addSql('DROP INDEX charge_kind_unique_type_idx'); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -2,8 +2,8 @@ Budget: Budget | ||||
| Resource: Inkomsten | ||||
| Charge: Onkosten | ||||
| Budget for %name%: Budget van %name% | ||||
| Budget for household %household%: Budget van gezin | ||||
| Current budget household members: Actuele budget van gezinsleden | ||||
| Budget for household %household%: Budget van huishouden | ||||
| Current budget household members: Actuele budget van leden huishouden | ||||
| Show budget of %name%: Toon budget van %name% | ||||
| See complete budget: Toon volledige budget | ||||
| Hide budget: Verbergen | ||||
|   | ||||
| @@ -61,7 +61,7 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere | ||||
|             ->setEmail('centreA@test.chill.social') | ||||
|             ->setLocationType($type = new LocationType()) | ||||
|             ->setPhonenumber1(PhoneNumberUtil::getInstance()->parse('+3287653812')); | ||||
|         $type->setTitle('Service'); | ||||
|         $type->setTitle(['fr' => 'Service']); | ||||
|         $address->setStreet('Rue des Épaules')->setStreetNumber('14') | ||||
|             ->setPostcode($postCode = new PostalCode()); | ||||
|         $postCode->setCode('4145')->setName('Houte-Si-Plout')->setCountry( | ||||
|   | ||||
| @@ -12,6 +12,8 @@ declare(strict_types=1); | ||||
| namespace Chill\CalendarBundle\DataFixtures\ORM; | ||||
|  | ||||
| use Chill\CalendarBundle\Entity\Invite; | ||||
| use Chill\MainBundle\DataFixtures\ORM\LoadUsers; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Doctrine\Bundle\FixturesBundle\Fixture; | ||||
| use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; | ||||
| use Doctrine\Persistence\ObjectManager; | ||||
| @@ -33,14 +35,21 @@ class LoadInvite extends Fixture implements FixtureGroupInterface | ||||
|     public function load(ObjectManager $manager): void | ||||
|     { | ||||
|         $arr = [ | ||||
|             ['name' => ['fr' => 'Rendez-vous décliné']], | ||||
|             ['name' => ['fr' => 'Rendez-vous accepté']], | ||||
|             [ | ||||
|                 'name' => ['fr' => 'Rendez-vous décliné'], | ||||
|                 'status' => Invite::DECLINED, | ||||
|             ], | ||||
|             [ | ||||
|                 'name' => ['fr' => 'Rendez-vous accepté'], | ||||
|                 'status' => Invite::ACCEPTED, | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         foreach ($arr as $a) { | ||||
|             echo 'Creating calendar invite : ' . $a['name']['fr'] . "\n"; | ||||
|             $invite = (new Invite()) | ||||
|                 ->setStatus($a['name']); | ||||
|                 ->setStatus($a['status']) | ||||
|                 ->setUser($this->getRandomUser()); | ||||
|             $manager->persist($invite); | ||||
|             $reference = 'Invite_' . $a['name']['fr']; | ||||
|             $this->addReference($reference, $invite); | ||||
| @@ -49,4 +58,11 @@ class LoadInvite extends Fixture implements FixtureGroupInterface | ||||
|  | ||||
|         $manager->flush(); | ||||
|     } | ||||
|  | ||||
|     private function getRandomUser(): User | ||||
|     { | ||||
|         $userRef = array_rand(LoadUsers::$refs); | ||||
|  | ||||
|         return $this->getReference($userRef); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -17,30 +17,20 @@ | ||||
|                     <td class="eval"> | ||||
|                         <ul class="eval_title"> | ||||
|                             <li> | ||||
|                                 {{ mm.mimeIcon(d.storedObject.type) }} | ||||
|                                 {{ d.storedObject.title }} | ||||
|                                 {% if d.dateTimeVersion < d.calendar.dateTimeVersion %} | ||||
|                                 <span class="badge bg-danger">{{ 'chill_calendar.Document outdated'|trans }}</span> | ||||
|                                 {% endif %} | ||||
|  | ||||
|                                 <ul class="record_actions small inline"> | ||||
|                                     {% if chill_document_is_editable(d.storedObject) and is_granted('CHILL_CALENDAR_DOC_EDIT', d) %} | ||||
|                                         <li> | ||||
|                                             <a href="{{ chill_path_add_return_path('chill_calendar_calendardoc_delete', {'id': d.id})}}" class="btn btn-delete"></a> | ||||
|                                         </li> | ||||
|                                         <li> | ||||
|                                             {{ d.storedObject|chill_document_edit_button }} | ||||
|                                         </li> | ||||
|                                     {% endif %} | ||||
|                                     {% if is_granted('CHILL_CALENDAR_DOC_EDIT', d) %} | ||||
|                                     <li> | ||||
|                                         <a href="{{ chill_path_add_return_path('chill_calendar_calendardoc_edit', {'id': d.id})}}" class="btn btn-edit"></a> | ||||
|                                     </li> | ||||
|                                     {% endif %} | ||||
|                                     <li> | ||||
|                                         {{ m.download_button(d.storedObject, d.storedObject.title) }} | ||||
|                                     </li> | ||||
|                                 </ul> | ||||
|                                 <div class="row"> | ||||
|                                     <div class="col text-start"> | ||||
|                                         {{ d.storedObject.title }} | ||||
|                                         {% if d.dateTimeVersion < d.calendar.dateTimeVersion %} | ||||
|                                         <span class="badge bg-danger">{{ 'chill_calendar.Document outdated'|trans }}</span> | ||||
|                                         {% endif %} | ||||
|                                     </div> | ||||
|                                     <div class="col-md-auto text-center"> | ||||
|                                         {{ mm.mimeIcon(d.storedObject.type) }} | ||||
|                                     </div> | ||||
|                                     <div class="col col-lg-4 text-end"> | ||||
|                                         {{ d.storedObject|chill_document_button_group(d.storedObject.title, is_granted('CHILL_CALENDAR_DOC_EDIT', d), {'small': true}) }} | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </li> | ||||
|                         </ul> | ||||
|                     </td> | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|  | ||||
| <div id="mainUser"></div> {# <=== vue component: mainUser #} | ||||
|  | ||||
| <h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2> | ||||
| <h2 class="chill-red">{{ 'Concerned groups calendar'|trans }}</h2> | ||||
|  | ||||
| {%- if form.persons is defined -%} | ||||
|     {{ form_widget(form.persons) }} | ||||
|   | ||||
| @@ -10,13 +10,13 @@ | ||||
| {% block js %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_script_tags('mod_answer') }} | ||||
|     {{ encore_entry_script_tags('mod_async_upload') }} | ||||
|     {{ encore_entry_script_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_link_tags('mod_answer') }} | ||||
|     {{ encore_entry_link_tags('mod_async_upload') }} | ||||
|     {{ encore_entry_link_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|  | ||||
| <div id="mainUser"></div> {# <=== vue component: mainUser #} | ||||
|  | ||||
| <h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2> | ||||
| <h2 class="chill-red">{{ 'Concerned groups calendar'|trans }}</h2> | ||||
|  | ||||
| {%- if form.mainUser is defined -%} | ||||
|     {{ form_row(form.mainUser) }} | ||||
|   | ||||
| @@ -14,7 +14,7 @@ | ||||
|     <dd>{{ entity.mainUser }}</dd> | ||||
| </dl> | ||||
|  | ||||
| <h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2> | ||||
| <h2 class="chill-red">{{ 'Concerned groups calendar'|trans }}</h2> | ||||
| {% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': 'calendar_' ~ context, 'render': 'bloc' } %} | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -18,8 +18,10 @@ use Chill\DocGeneratorBundle\Service\Context\BaseContextData; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Repository\PersonRepository; | ||||
| use Chill\PersonBundle\Templating\Entity\PersonRender; | ||||
| use Chill\ThirdPartyBundle\Entity\ThirdParty; | ||||
| use Chill\ThirdPartyBundle\Repository\ThirdPartyRepository; | ||||
| use Chill\ThirdPartyBundle\Templating\Entity\ThirdPartyRender; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bridge\Doctrine\Form\Type\EntityType; | ||||
| @@ -39,6 +41,10 @@ final class CalendarContext implements CalendarContextInterface | ||||
|  | ||||
|     private PersonRender $personRender; | ||||
|  | ||||
|     private PersonRepository $personRepository; | ||||
|  | ||||
|     private ThirdPartyRepository $thirdPartyRepository; | ||||
|  | ||||
|     private ThirdPartyRender $thirdPartyRender; | ||||
|  | ||||
|     private TranslatableStringHelperInterface $translatableStringHelper; | ||||
| @@ -48,14 +54,18 @@ final class CalendarContext implements CalendarContextInterface | ||||
|         EntityManagerInterface $entityManager, | ||||
|         NormalizerInterface $normalizer, | ||||
|         PersonRender $personRender, | ||||
|         PersonRepository $personRepository, | ||||
|         ThirdPartyRender $thirdPartyRender, | ||||
|         ThirdPartyRepository $thirdPartyRepository, | ||||
|         TranslatableStringHelperInterface $translatableStringHelper | ||||
|     ) { | ||||
|         $this->baseContextData = $baseContextData; | ||||
|         $this->entityManager = $entityManager; | ||||
|         $this->normalizer = $normalizer; | ||||
|         $this->personRender = $personRender; | ||||
|         $this->personRepository = $personRepository; | ||||
|         $this->thirdPartyRender = $thirdPartyRender; | ||||
|         $this->thirdPartyRepository = $thirdPartyRepository; | ||||
|         $this->translatableStringHelper = $translatableStringHelper; | ||||
|     } | ||||
|  | ||||
| @@ -146,7 +156,7 @@ final class CalendarContext implements CalendarContextInterface | ||||
|         $options = $this->getOptions($template); | ||||
|  | ||||
|         $data = array_merge( | ||||
|             $this->baseContextData->getData(), | ||||
|             $this->baseContextData->getData($contextGenerationData['creator'] ?? null), | ||||
|             [ | ||||
|                 'calendar' => $this->normalizer->normalize($entity, 'docgen', ['docgen:expects' => Calendar::class, 'groups' => ['docgen:read']]), | ||||
|             ] | ||||
| @@ -226,8 +236,44 @@ final class CalendarContext implements CalendarContextInterface | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array | ||||
|     { | ||||
|         $normalized = []; | ||||
|         $normalized['title'] = $data['title'] ?? ''; | ||||
|  | ||||
|         foreach (['mainPerson', 'thirdParty'] as $k) { | ||||
|             if (isset($data[$k])) { | ||||
|                 $normalized[$k] = $data[$k]->getId(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $normalized; | ||||
|     } | ||||
|  | ||||
|     public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array | ||||
|     { | ||||
|         $denormalized = []; | ||||
|         $denormalized['title'] = $data['title']; | ||||
|  | ||||
|         if (null !== ($data['mainPerson'] ?? null)) { | ||||
|             if (null === $person = $this->personRepository->find($data['mainPerson'])) { | ||||
|                 throw new \RuntimeException('person not found'); | ||||
|             } | ||||
|             $denormalized['mainPerson'] = $person; | ||||
|         } | ||||
|  | ||||
|         if (null !== ($data['thirdParty'] ?? null)) { | ||||
|             if (null === $thirdParty = $this->thirdPartyRepository->find($data['thirdParty'])) { | ||||
|                 throw new \RuntimeException('third party not found'); | ||||
|             } | ||||
|             $denormalized['thirdParty'] = $thirdParty; | ||||
|         } | ||||
|  | ||||
|         return $denormalized; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param array{mainPerson?: Person, thirdParty?: ThirdParty, title: string} $contextGenerationData | ||||
|      * param array{mainPerson?: Person, thirdParty?: ThirdParty, title: string} $contextGenerationData | ||||
|      */ | ||||
|     public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void | ||||
|     { | ||||
|   | ||||
| @@ -56,6 +56,10 @@ interface CalendarContextInterface extends DocGeneratorContextWithPublicFormInte | ||||
|      */ | ||||
|     public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool; | ||||
|  | ||||
|     public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array; | ||||
|  | ||||
|     public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array; | ||||
|  | ||||
|     /** | ||||
|      * @param Calendar $entity | ||||
|      */ | ||||
|   | ||||
| @@ -205,7 +205,7 @@ final class CalendarContextTest extends TestCase | ||||
|         ?NormalizerInterface $normalizer = null | ||||
|     ): CalendarContext { | ||||
|         $baseContext = $this->prophesize(BaseContextData::class); | ||||
|         $baseContext->getData()->willReturn(['base_context' => 'data']); | ||||
|         $baseContext->getData(null)->willReturn(['base_context' => 'data']); | ||||
|  | ||||
|         $personRender = $this->prophesize(PersonRender::class); | ||||
|         $personRender->renderString(Argument::type(Person::class), [])->willReturn('person name'); | ||||
|   | ||||
| @@ -4,7 +4,7 @@ My calendar list: Mes rendez-vous | ||||
| There is no calendar items.: Il n'y a pas de rendez-vous | ||||
| Remove calendar item: Supprimer le rendez-vous | ||||
| Are you sure you want to remove the calendar item?: Êtes-vous sûr de vouloir supprimer le rendez-vous? | ||||
| Concerned groups: Parties concernées | ||||
| Concerned groups calendar: Parties concernées | ||||
| Calendar data: Données du rendez-vous | ||||
| Update calendar: Modifier le rendez-vous | ||||
| main user concerned: Utilisateur concerné | ||||
|   | ||||
| @@ -23,6 +23,9 @@ interface DocGeneratorContextWithPublicFormInterface extends DocGeneratorContext | ||||
|      */ | ||||
|     public function buildPublicForm(FormBuilderInterface $builder, DocGeneratorTemplate $template, $entity): void; | ||||
|  | ||||
|     /** | ||||
|      * Fill the form with initial data | ||||
|      */ | ||||
|     public function getFormData(DocGeneratorTemplate $template, $entity): array; | ||||
|  | ||||
|     /** | ||||
| @@ -31,4 +34,14 @@ interface DocGeneratorContextWithPublicFormInterface extends DocGeneratorContext | ||||
|      * @param mixed $entity | ||||
|      */ | ||||
|     public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool; | ||||
|  | ||||
|     /** | ||||
|      * Transform the data from the form into serializable data, storable into messenger's message | ||||
|      */ | ||||
|     public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array; | ||||
|  | ||||
|     /** | ||||
|      * Reverse the data from the messenger's message into data usable for doc's generation | ||||
|      */ | ||||
|     public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array; | ||||
| } | ||||
|   | ||||
| @@ -16,67 +16,58 @@ use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface; | ||||
| use Chill\DocGeneratorBundle\Context\Exception\ContextNotFoundException; | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface; | ||||
| use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException; | ||||
| use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\GeneratorInterface; | ||||
| use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Serializer\Model\Collection; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Exception; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\Form\Extension\Core\Type\CheckboxType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\FileType; | ||||
| use Symfony\Component\HttpFoundation\File\File; | ||||
| use Symfony\Component\HttpFoundation\RedirectResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| // TODO à mettre dans services | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; | ||||
| use Symfony\Contracts\HttpClient\HttpClientInterface; | ||||
| use Throwable; | ||||
| use function strlen; | ||||
| use const JSON_PRETTY_PRINT; | ||||
|  | ||||
| final class DocGeneratorTemplateController extends AbstractController | ||||
| { | ||||
|     private HttpClientInterface $client; | ||||
|  | ||||
|     private ContextManager $contextManager; | ||||
|  | ||||
|     private DocGeneratorTemplateRepository $docGeneratorTemplateRepository; | ||||
|  | ||||
|     private DriverInterface $driver; | ||||
|  | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
|     private LoggerInterface $logger; | ||||
|     private GeneratorInterface $generator; | ||||
|  | ||||
|     private MessageBusInterface $messageBus; | ||||
|  | ||||
|     private PaginatorFactory $paginatorFactory; | ||||
|  | ||||
|     private StoredObjectManagerInterface $storedObjectManager; | ||||
|  | ||||
|     public function __construct( | ||||
|         ContextManager $contextManager, | ||||
|         DocGeneratorTemplateRepository $docGeneratorTemplateRepository, | ||||
|         DriverInterface $driver, | ||||
|         LoggerInterface $logger, | ||||
|         GeneratorInterface $generator, | ||||
|         MessageBusInterface $messageBus, | ||||
|         PaginatorFactory $paginatorFactory, | ||||
|         HttpClientInterface $client, | ||||
|         StoredObjectManagerInterface $storedObjectManager, | ||||
|         EntityManagerInterface $entityManager | ||||
|     ) { | ||||
|         $this->contextManager = $contextManager; | ||||
|         $this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository; | ||||
|         $this->driver = $driver; | ||||
|         $this->logger = $logger; | ||||
|         $this->generator = $generator; | ||||
|         $this->messageBus = $messageBus; | ||||
|         $this->paginatorFactory = $paginatorFactory; | ||||
|         $this->client = $client; | ||||
|         $this->storedObjectManager = $storedObjectManager; | ||||
|         $this->entityManager = $entityManager; | ||||
|     } | ||||
|  | ||||
| @@ -94,7 +85,6 @@ final class DocGeneratorTemplateController extends AbstractController | ||||
|     ): Response { | ||||
|         return $this->generateDocFromTemplate( | ||||
|             $template, | ||||
|             $entityClassName, | ||||
|             $entityId, | ||||
|             $request, | ||||
|             true | ||||
| @@ -115,7 +105,6 @@ final class DocGeneratorTemplateController extends AbstractController | ||||
|     ): Response { | ||||
|         return $this->generateDocFromTemplate( | ||||
|             $template, | ||||
|             $entityClassName, | ||||
|             $entityId, | ||||
|             $request, | ||||
|             false | ||||
| @@ -185,7 +174,6 @@ final class DocGeneratorTemplateController extends AbstractController | ||||
|  | ||||
|     private function generateDocFromTemplate( | ||||
|         DocGeneratorTemplate $template, | ||||
|         string $entityClassName, | ||||
|         int $entityId, | ||||
|         Request $request, | ||||
|         bool $isTest | ||||
| @@ -206,7 +194,7 @@ final class DocGeneratorTemplateController extends AbstractController | ||||
|  | ||||
|         if (null === $entity) { | ||||
|             throw new NotFoundHttpException( | ||||
|                 sprintf('Entity with classname %s and id %s is not found', $entityClassName, $entityId) | ||||
|                 sprintf('Entity with classname %s and id %s is not found', $context->getEntityClass(), $entityId) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
| @@ -259,99 +247,68 @@ final class DocGeneratorTemplateController extends AbstractController | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $document = $template->getFile(); | ||||
|  | ||||
|         if ($isTest && ($contextGenerationData['test_file'] instanceof File)) { | ||||
|             $dataDecrypted = file_get_contents($contextGenerationData['test_file']->getPathname()); | ||||
|         } else { | ||||
|             try { | ||||
|                 $dataDecrypted = $this->storedObjectManager->read($document); | ||||
|             } catch (Throwable $exception) { | ||||
|                 throw $exception; | ||||
|             } | ||||
|         } | ||||
|         // transform context generation data | ||||
|         $contextGenerationDataSanitized = | ||||
|             $context instanceof DocGeneratorContextWithPublicFormInterface ? | ||||
|                 $context->contextGenerationDataNormalize($template, $entity, $contextGenerationData) | ||||
|                 : []; | ||||
|  | ||||
|         // if is test, render the data or generate the doc | ||||
|         if ($isTest && isset($form) && $form['show_data']->getData()) { | ||||
|             return $this->render('@ChillDocGenerator/Generator/debug_value.html.twig', [ | ||||
|                 'datas' => json_encode($context->getData($template, $entity, $contextGenerationData), JSON_PRETTY_PRINT) | ||||
|                 'datas' => json_encode($context->getData($template, $entity, $contextGenerationData), JSON_PRETTY_PRINT), | ||||
|             ]); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             $generatedResource = $this | ||||
|                 ->driver | ||||
|                 ->generateFromString( | ||||
|                     $dataDecrypted, | ||||
|                     $template->getFile()->getType(), | ||||
|                     $context->getData($template, $entity, $contextGenerationData), | ||||
|                     $template->getFile()->getFilename() | ||||
|                 ); | ||||
|         } catch (TemplateException $e) { | ||||
|             return new Response( | ||||
|                 implode("\n", $e->getErrors()), | ||||
|                 400, | ||||
|                 [ | ||||
|                     'Content-Type' => 'text/plain', | ||||
|                 ] | ||||
|         } elseif ($isTest) { | ||||
|             $generated = $this->generator->generateDocFromTemplate( | ||||
|                 $template, | ||||
|                 $entityId, | ||||
|                 $contextGenerationDataSanitized, | ||||
|                 null, | ||||
|                 true, | ||||
|                 isset($form) ? $form['test_file']->getData() : null | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if ($isTest) { | ||||
|             return new Response( | ||||
|                 $generatedResource, | ||||
|                 $generated, | ||||
|                 Response::HTTP_OK, | ||||
|                 [ | ||||
|                     'Content-Transfer-Encoding', 'binary', | ||||
|                     'Content-Type' => 'application/vnd.oasis.opendocument.text', | ||||
|                     'Content-Disposition' => 'attachment; filename="generated.odt"', | ||||
|                     'Content-Length' => strlen($generatedResource), | ||||
|                     'Content-Length' => strlen($generated), | ||||
|                 ], | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         /** @var StoredObject $storedObject */ | ||||
|         $storedObject = (new ObjectNormalizer()) | ||||
|             ->denormalize( | ||||
|                 [ | ||||
|                     'type' => $template->getFile()->getType(), | ||||
|                     'filename' => sprintf('%s_odt', uniqid('doc_', true)), | ||||
|                 ], | ||||
|                 StoredObject::class | ||||
|             ); | ||||
|  | ||||
|         try { | ||||
|             $this->storedObjectManager->write($storedObject, $generatedResource); | ||||
|         } catch (Throwable $exception) { | ||||
|             throw $exception; | ||||
|         } | ||||
|         // this is not a test | ||||
|         // we prepare the object to store the document | ||||
|         $storedObject = (new StoredObject()) | ||||
|             ->setStatus(StoredObject::STATUS_PENDING) | ||||
|             ; | ||||
|  | ||||
|         $this->entityManager->persist($storedObject); | ||||
|  | ||||
|         try { | ||||
|             $context | ||||
|                 ->storeGenerated( | ||||
|                     $template, | ||||
|                     $storedObject, | ||||
|                     $entity, | ||||
|                     $contextGenerationData | ||||
|                 ); | ||||
|         } catch (Exception $e) { | ||||
|             $this | ||||
|                 ->logger | ||||
|                 ->error( | ||||
|                     'Unable to store the associated document to entity', | ||||
|                     [ | ||||
|                         'entityClassName' => $entityClassName, | ||||
|                         'entityId' => $entityId, | ||||
|                         'contextKey' => $context->getName(), | ||||
|                     ] | ||||
|                 ); | ||||
|  | ||||
|             throw $e; | ||||
|         } | ||||
|         // we store the generated document | ||||
|         $context | ||||
|             ->storeGenerated( | ||||
|                 $template, | ||||
|                 $storedObject, | ||||
|                 $entity, | ||||
|                 $contextGenerationData | ||||
|             ); | ||||
|  | ||||
|         $this->entityManager->flush(); | ||||
|  | ||||
|         $this->messageBus->dispatch( | ||||
|             new RequestGenerationMessage( | ||||
|                 $this->getUser(), | ||||
|                 $template, | ||||
|                 $entityId, | ||||
|                 $storedObject, | ||||
|                 $contextGenerationDataSanitized, | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|         return $this | ||||
|             ->redirectToRoute( | ||||
|                 'chill_wopi_file_edit', | ||||
|   | ||||
| @@ -53,6 +53,7 @@ final class RelatorioDriver implements DriverInterface | ||||
|             $response = $this->client->request('POST', $this->url, [ | ||||
|                 'headers' => $form->getPreparedHeaders()->toArray(), | ||||
|                 'body' => $form->bodyToIterable(), | ||||
|                 'timeout' => '300', | ||||
|             ]); | ||||
|  | ||||
|             return $response->getContent(); | ||||
|   | ||||
| @@ -0,0 +1,16 @@ | ||||
| {{ creator.label }}, | ||||
|  | ||||
| {{ 'docgen.failure_email.The generation of the document {template_name} failed'|trans({'{template_name}': template.name|localize_translatable_string}) }} | ||||
|  | ||||
| {{ 'docgen.failure_email.Forward this email to your administrator for solving'|trans }} | ||||
|  | ||||
| {{ 'docgen.failure_email.References'|trans }}: | ||||
| {% if errors|length > 0 %} | ||||
| {{ 'docgen.failure_email.The following errors were encoutered'|trans }}: | ||||
|  | ||||
| {% for error in errors %} | ||||
| - {{ error }} | ||||
| {% endfor %} | ||||
| {% endif %} | ||||
| - template_id: {{ template.id }} | ||||
| - stored_object_destination_id: {{ stored_object_id }} | ||||
| @@ -21,18 +21,14 @@ class BaseContextData | ||||
| { | ||||
|     private NormalizerInterface $normalizer; | ||||
|  | ||||
|     private Security $security; | ||||
|  | ||||
|     public function __construct(Security $security, NormalizerInterface $normalizer) | ||||
|     public function __construct(NormalizerInterface $normalizer) | ||||
|     { | ||||
|         $this->security = $security; | ||||
|         $this->normalizer = $normalizer; | ||||
|     } | ||||
|  | ||||
|     public function getData(): array | ||||
|     public function getData(?User $user = null): array | ||||
|     { | ||||
|         $data = []; | ||||
|         $user = $this->security->getUser(); | ||||
|  | ||||
|         $data['creator'] = $this->normalizer->normalize( | ||||
|             $user instanceof User ? $user : null, | ||||
|   | ||||
| @@ -0,0 +1,146 @@ | ||||
| <?php | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Generator; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Context\ContextManagerInterface; | ||||
| use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface; | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface; | ||||
| use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\HttpFoundation\File\File; | ||||
|  | ||||
| class Generator implements GeneratorInterface | ||||
| { | ||||
|     private ContextManagerInterface $contextManager; | ||||
|  | ||||
|     private DriverInterface $driver; | ||||
|  | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
|     private LoggerInterface $logger; | ||||
|  | ||||
|     private StoredObjectManagerInterface $storedObjectManager; | ||||
|  | ||||
|     private const LOG_PREFIX = '[docgen generator] '; | ||||
|  | ||||
|     public function __construct( | ||||
|         ContextManagerInterface $contextManager, | ||||
|         DriverInterface $driver, | ||||
|         EntityManagerInterface $entityManager, | ||||
|         LoggerInterface $logger, | ||||
|         StoredObjectManagerInterface $storedObjectManager | ||||
|     ) { | ||||
|         $this->contextManager = $contextManager; | ||||
|         $this->driver = $driver; | ||||
|         $this->entityManager = $entityManager; | ||||
|         $this->logger = $logger; | ||||
|         $this->storedObjectManager = $storedObjectManager; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @template T of File|null | ||||
|      * @template B of bool | ||||
|      * @param B $isTest | ||||
|      * @param (B is true ? T : null) $testFile | ||||
|      * @psalm-return (B is true ? string : null) | ||||
|      * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable | ||||
|      */ | ||||
|     public function generateDocFromTemplate( | ||||
|         DocGeneratorTemplate $template, | ||||
|         int                  $entityId, | ||||
|         array                $contextGenerationDataNormalized, | ||||
|         ?StoredObject        $destinationStoredObject = null, | ||||
|         bool                 $isTest = false, | ||||
|         ?File                $testFile = null, | ||||
|         ?User                $creator = null | ||||
|     ): ?string { | ||||
|         if ($destinationStoredObject instanceof StoredObject && StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'Aborting generation of an already generated document'); | ||||
|             throw new ObjectReadyException(); | ||||
|         } | ||||
|  | ||||
|         $this->logger->info(self::LOG_PREFIX.'Starting generation of a document', [ | ||||
|             'entity_id' => $entityId, | ||||
|             'destination_stored_object' => $destinationStoredObject === null ? null : $destinationStoredObject->getId() | ||||
|         ]); | ||||
|  | ||||
|         $context = $this->contextManager->getContextByDocGeneratorTemplate($template); | ||||
|  | ||||
|         $entity = $this | ||||
|             ->entityManager | ||||
|             ->find($context->getEntityClass(), $entityId) | ||||
|             ; | ||||
|  | ||||
|         if (null === $entity) { | ||||
|             throw new RelatedEntityNotFoundException($template->getEntity(), $entityId); | ||||
|         } | ||||
|  | ||||
|         $contextGenerationDataNormalized = array_merge( | ||||
|             $contextGenerationDataNormalized, | ||||
|                 ['creator' => $creator], | ||||
|                 $context instanceof DocGeneratorContextWithPublicFormInterface ? | ||||
|                     $context->contextGenerationDataDenormalize($template, $entity, $contextGenerationDataNormalized) | ||||
|                     : [] | ||||
|         ); | ||||
|  | ||||
|         $data = $context->getData($template, $entity, $contextGenerationDataNormalized); | ||||
|  | ||||
|         $destinationStoredObjectId = $destinationStoredObject instanceof StoredObject ? $destinationStoredObject->getId() : null; | ||||
|         $this->entityManager->clear(); | ||||
|         gc_collect_cycles(); | ||||
|         if (null !== $destinationStoredObjectId) { | ||||
|             $destinationStoredObject = $this->entityManager->find(StoredObject::class, $destinationStoredObjectId); | ||||
|         } | ||||
|  | ||||
|         if ($isTest && ($testFile instanceof File)) { | ||||
|             $templateDecrypted = file_get_contents($testFile->getPathname()); | ||||
|         } else { | ||||
|             $templateDecrypted = $this->storedObjectManager->read($template->getFile()); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             $generatedResource = $this | ||||
|                 ->driver | ||||
|                 ->generateFromString( | ||||
|                     $templateDecrypted, | ||||
|                     $template->getFile()->getType(), | ||||
|                     $data, | ||||
|                     $template->getFile()->getFilename() | ||||
|                 ); | ||||
|         } catch (TemplateException $e) { | ||||
|             throw new GeneratorException($e->getErrors(), $e); | ||||
|         } | ||||
|  | ||||
|         if ($isTest) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [ | ||||
|                 'is_test' => true, | ||||
|                 'entity_id' => $entityId, | ||||
|                 'destination_stored_object' => $destinationStoredObject === null ? null : $destinationStoredObject->getId() | ||||
|             ]); | ||||
|             return $generatedResource; | ||||
|         } | ||||
|  | ||||
|         /** @var StoredObject $storedObject */ | ||||
|         $destinationStoredObject | ||||
|             ->setType($template->getFile()->getType()) | ||||
|             ->setFilename(sprintf('%s_odt', uniqid('doc_', true))) | ||||
|             ->setStatus(StoredObject::STATUS_READY) | ||||
|             ; | ||||
|  | ||||
|         $this->storedObjectManager->write($destinationStoredObject, $generatedResource); | ||||
|  | ||||
|         $this->entityManager->flush(); | ||||
|  | ||||
|         $this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [ | ||||
|             'entity_id' => $entityId, | ||||
|             'destination_stored_object' => $destinationStoredObject->getId(), | ||||
|         ]); | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Generator; | ||||
|  | ||||
| use RuntimeException; | ||||
| use Throwable; | ||||
|  | ||||
| class GeneratorException extends RuntimeException | ||||
| { | ||||
|     /** | ||||
|      * @var list<string> | ||||
|      */ | ||||
|     private array $errors; | ||||
|  | ||||
|     public function __construct(array $errors = [], ?Throwable $previous = null) | ||||
|     { | ||||
|         $this->errors = $errors; | ||||
|         parent::__construct( | ||||
|             'Could not generate the document', | ||||
|             15252, | ||||
|             $previous | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return array | ||||
|      */ | ||||
|     public function getErrors(): array | ||||
|     { | ||||
|         return $this->errors; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| <?php | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Generator; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Symfony\Component\HttpFoundation\File\File; | ||||
|  | ||||
| interface GeneratorInterface | ||||
| { | ||||
|     /** | ||||
|      * @template T of File|null | ||||
|      * @template B of bool | ||||
|      * @param B $isTest | ||||
|      * @param (B is true ? T : null) $testFile | ||||
|      * @psalm-return (B is true ? string : null) | ||||
|      * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable | ||||
|      */ | ||||
|     public function generateDocFromTemplate( | ||||
|         DocGeneratorTemplate $template, | ||||
|         int                  $entityId, | ||||
|         array                $contextGenerationDataNormalized, | ||||
|         ?StoredObject        $destinationStoredObject = null, | ||||
|         bool                 $isTest = false, | ||||
|         ?File                $testFile = null, | ||||
|         ?User                $creator = null | ||||
|     ): ?string; | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Generator; | ||||
|  | ||||
| use RuntimeException; | ||||
|  | ||||
| class ObjectReadyException extends RuntimeException | ||||
| { | ||||
|     public function __construct() | ||||
|     { | ||||
|         parent::__construct('object is already ready', 6698856); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Generator; | ||||
|  | ||||
| use RuntimeException; | ||||
|  | ||||
| class RelatedEntityNotFoundException extends RuntimeException | ||||
| { | ||||
|     public function __construct(string $relatedEntityClass, int $relatedEntityId, ?Throwable $previous = null) | ||||
|     { | ||||
|         parent::__construct( | ||||
|             sprintf('Related entity not found: %s, %s', $relatedEntityClass, $relatedEntityId), | ||||
|             99876652, | ||||
|             $previous | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,156 @@ | ||||
| <?php | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Messenger; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\GeneratorException; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepository; | ||||
| use Chill\MainBundle\Repository\UserRepositoryInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Bridge\Twig\Mime\TemplatedEmail; | ||||
| use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||||
| use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| final class OnGenerationFails implements EventSubscriberInterface | ||||
| { | ||||
|     private DocGeneratorTemplateRepository $docGeneratorTemplateRepository; | ||||
|  | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
|     private LoggerInterface $logger; | ||||
|  | ||||
|     private MailerInterface $mailer; | ||||
|  | ||||
|     private StoredObjectRepository $storedObjectRepository; | ||||
|  | ||||
|     private TranslatorInterface $translator; | ||||
|  | ||||
|     private UserRepositoryInterface $userRepository; | ||||
|  | ||||
|     const LOG_PREFIX = '[docgen failed] '; | ||||
|  | ||||
|     /** | ||||
|      * @param DocGeneratorTemplateRepository $docGeneratorTemplateRepository | ||||
|      * @param EntityManagerInterface $entityManager | ||||
|      * @param LoggerInterface $logger | ||||
|      * @param MailerInterface $mailer | ||||
|      * @param StoredObjectRepository $storedObjectRepository | ||||
|      * @param TranslatorInterface $translator | ||||
|      * @param UserRepositoryInterface $userRepository | ||||
|      */ | ||||
|     public function __construct( | ||||
|         DocGeneratorTemplateRepository $docGeneratorTemplateRepository, | ||||
|         EntityManagerInterface $entityManager, | ||||
|         LoggerInterface $logger, | ||||
|         MailerInterface $mailer, | ||||
|         StoredObjectRepository $storedObjectRepository, | ||||
|         TranslatorInterface $translator, | ||||
|         UserRepositoryInterface $userRepository | ||||
|     ) { | ||||
|         $this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository; | ||||
|         $this->entityManager = $entityManager; | ||||
|         $this->logger = $logger; | ||||
|         $this->mailer = $mailer; | ||||
|         $this->storedObjectRepository = $storedObjectRepository; | ||||
|         $this->translator = $translator; | ||||
|         $this->userRepository = $userRepository; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public static function getSubscribedEvents() | ||||
|     { | ||||
|         return [ | ||||
|             WorkerMessageFailedEvent::class => 'onMessageFailed' | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function onMessageFailed(WorkerMessageFailedEvent $event): void | ||||
|     { | ||||
|         if ($event->willRetry()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!$event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         /** @var \Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage $message */ | ||||
|         $message = $event->getEnvelope()->getMessage(); | ||||
|  | ||||
|         $this->logger->error(self::LOG_PREFIX.'Docgen failed', [ | ||||
|             'stored_object_id' => $message->getDestinationStoredObjectId(), | ||||
|             'entity_id' => $message->getEntityId(), | ||||
|             'template_id' => $message->getTemplateId(), | ||||
|             'creator_id' => $message->getCreatorId(), | ||||
|             'throwable_class' => get_class($event->getThrowable()), | ||||
|         ]); | ||||
|  | ||||
|         $this->markObjectAsFailed($message); | ||||
|         $this->warnCreator($message, $event); | ||||
|     } | ||||
|  | ||||
|     private function markObjectAsFailed(RequestGenerationMessage $message): void | ||||
|     { | ||||
|         $object = $this->storedObjectRepository->find($message->getDestinationStoredObjectId()); | ||||
|  | ||||
|         if (null === $object) { | ||||
|             $this->logger->error(self::LOG_PREFIX.'Stored object not found', ['stored_object_id', $message->getDestinationStoredObjectId()]); | ||||
|         } | ||||
|  | ||||
|         $object->setStatus(StoredObject::STATUS_FAILURE); | ||||
|  | ||||
|         $this->entityManager->flush(); | ||||
|     } | ||||
|  | ||||
|     private function warnCreator(RequestGenerationMessage $message, WorkerMessageFailedEvent $event): void | ||||
|     { | ||||
|         if (null === $creatorId = $message->getCreatorId()) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'creator id is null'); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (null === $creator = $this->userRepository->find($creatorId)) { | ||||
|             $this->logger->error(self::LOG_PREFIX.'Creator not found with given id', ['creator_id', $creatorId]); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (null === $creator->getEmail() || '' === $creator->getEmail()) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'Creator does not have any email', ['user' => $creator->getUsernameCanonical()]); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // if the exception is not a GeneratorException, we try the previous one... | ||||
|         $throwable = $event->getThrowable(); | ||||
|         if (!$throwable instanceof GeneratorException) { | ||||
|             $throwable = $throwable->getPrevious(); | ||||
|         } | ||||
|  | ||||
|         if ($throwable instanceof GeneratorException) { | ||||
|             $errors = $throwable->getErrors(); | ||||
|         } else { | ||||
|             $errors = [$throwable->getTraceAsString()]; | ||||
|         } | ||||
|  | ||||
|         if (null === $template = $this->docGeneratorTemplateRepository->find($message->getTemplateId())) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'Template not found', ['template_id' => $message->getTemplateId()]); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $email = (new TemplatedEmail()) | ||||
|             ->to($creator->getEmail()) | ||||
|             ->subject($this->translator->trans('docgen.failure_email.The generation of a document failed')) | ||||
|             ->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig') | ||||
|             ->context([ | ||||
|                 'errors' => $errors, | ||||
|                 'template' => $template, | ||||
|                 'creator' => $creator, | ||||
|                 'stored_object_id' => $message->getDestinationStoredObjectId(), | ||||
|             ]); | ||||
|  | ||||
|         $this->mailer->send($email); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,89 @@ | ||||
| <?php | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Messenger; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\Generator; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepository; | ||||
| use Chill\MainBundle\Repository\UserRepositoryInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; | ||||
| use Symfony\Component\Messenger\Handler\MessageHandlerInterface; | ||||
|  | ||||
| /** | ||||
|  * Handle the request of document generation | ||||
|  */ | ||||
| class RequestGenerationHandler implements MessageHandlerInterface | ||||
| { | ||||
|     private StoredObjectRepository $storedObjectRepository; | ||||
|  | ||||
|     private DocGeneratorTemplateRepository $docGeneratorTemplateRepository; | ||||
|  | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
|     private Generator $generator; | ||||
|  | ||||
|     private LoggerInterface $logger; | ||||
|  | ||||
|     private UserRepositoryInterface $userRepository; | ||||
|  | ||||
|     public const AUTHORIZED_TRIALS = 5; | ||||
|  | ||||
|     private const LOG_PREFIX = '[docgen message handler] '; | ||||
|  | ||||
|     public function __construct( | ||||
|         DocGeneratorTemplateRepository $docGeneratorTemplateRepository, | ||||
|         EntityManagerInterface $entityManager, | ||||
|         Generator $generator, | ||||
|         LoggerInterface $logger, | ||||
|         StoredObjectRepository $storedObjectRepository, | ||||
|         UserRepositoryInterface $userRepository | ||||
|     ) { | ||||
|         $this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository; | ||||
|         $this->entityManager = $entityManager; | ||||
|         $this->generator = $generator; | ||||
|         $this->logger = $logger; | ||||
|         $this->storedObjectRepository = $storedObjectRepository; | ||||
|         $this->userRepository = $userRepository; | ||||
|     } | ||||
|  | ||||
|     public function __invoke(RequestGenerationMessage $message) | ||||
|     { | ||||
|         if (null === $template = $this->docGeneratorTemplateRepository->find($message->getTemplateId())) { | ||||
|             throw new \RuntimeException('template not found: ' . $message->getTemplateId()); | ||||
|         } | ||||
|  | ||||
|         if (null === $destinationStoredObject = $this->storedObjectRepository->find($message->getDestinationStoredObjectId())) { | ||||
|             throw new \RuntimeException('destination stored object not found : ' . $message->getDestinationStoredObjectId()); | ||||
|         } | ||||
|  | ||||
|         if ($destinationStoredObject->getGenerationTrialsCounter() >= self::AUTHORIZED_TRIALS) { | ||||
|             throw new UnrecoverableMessageHandlingException('maximum number of retry reached'); | ||||
|         } | ||||
|  | ||||
|         $creator = $this->userRepository->find($message->getCreatorId()); | ||||
|  | ||||
|         $destinationStoredObject->addGenerationTrial(); | ||||
|         $this->entityManager->createQuery('UPDATE '.StoredObject::class.' s SET s.generationTrialsCounter = s.generationTrialsCounter + 1 WHERE s.id = :id') | ||||
|             ->setParameter('id', $destinationStoredObject->getId()) | ||||
|             ->execute(); | ||||
|  | ||||
|         $this->generator->generateDocFromTemplate( | ||||
|             $template, | ||||
|             $message->getEntityId(), | ||||
|             $message->getContextGenerationData(), | ||||
|             $destinationStoredObject, | ||||
|             false, | ||||
|             null, | ||||
|             $creator | ||||
|         ); | ||||
|  | ||||
|         $this->logger->info(self::LOG_PREFIX.'Request generation finished', [ | ||||
|             'template_id' => $message->getTemplateId(), | ||||
|             'destination_stored_object' => $message->getDestinationStoredObjectId(), | ||||
|             'duration_int' => (new \DateTimeImmutable('now'))->getTimestamp() - $message->getCreatedAt()->getTimestamp(), | ||||
|         ]); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,67 @@ | ||||
| <?php | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Messenger; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Entity\User; | ||||
|  | ||||
| class RequestGenerationMessage | ||||
| { | ||||
|    private int $creatorId; | ||||
|  | ||||
|    private int $templateId; | ||||
|  | ||||
|    private int $entityId; | ||||
|  | ||||
|    private int $destinationStoredObjectId; | ||||
|  | ||||
|    private array $contextGenerationData; | ||||
|  | ||||
|    private \DateTimeImmutable $createdAt; | ||||
|  | ||||
|     public function __construct( | ||||
|         User $creator, | ||||
|         DocGeneratorTemplate $template, | ||||
|         int $entityId, | ||||
|         StoredObject $destinationStoredObject, | ||||
|         array $contextGenerationData | ||||
|     ) { | ||||
|         $this->creatorId = $creator->getId(); | ||||
|         $this->templateId = $template->getId(); | ||||
|         $this->entityId = $entityId; | ||||
|         $this->destinationStoredObjectId = $destinationStoredObject->getId(); | ||||
|         $this->contextGenerationData = $contextGenerationData; | ||||
|         $this->createdAt = new \DateTimeImmutable('now'); | ||||
|     } | ||||
|  | ||||
|     public function getCreatorId(): int | ||||
|     { | ||||
|         return $this->creatorId; | ||||
|     } | ||||
|  | ||||
|     public function getDestinationStoredObjectId(): int | ||||
|     { | ||||
|         return $this->destinationStoredObjectId; | ||||
|     } | ||||
|  | ||||
|     public function getTemplateId(): int | ||||
|     { | ||||
|         return $this->templateId; | ||||
|     } | ||||
|  | ||||
|     public function getEntityId(): int | ||||
|     { | ||||
|         return $this->entityId; | ||||
|     } | ||||
|  | ||||
|     public function getContextGenerationData(): array | ||||
|     { | ||||
|         return $this->contextGenerationData; | ||||
|     } | ||||
|  | ||||
|     public function getCreatedAt(): \DateTimeImmutable | ||||
|     { | ||||
|         return $this->createdAt; | ||||
|     } | ||||
| } | ||||
| @@ -20,10 +20,14 @@ services: | ||||
|         resource: '../Serializer/Normalizer/' | ||||
|         tags: | ||||
|             - { name: 'serializer.normalizer', priority: -152 } | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Serializer\Normalizer\CollectionDocGenNormalizer: | ||||
|         tags: | ||||
|             - { name: 'serializer.normalizer', priority: -126 } | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Service\Context\: | ||||
|         resource: "../Service/Context" | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Controller\: | ||||
|         resource: "../Controller" | ||||
|         autowire: true | ||||
| @@ -34,18 +38,20 @@ services: | ||||
|         autowire: true | ||||
|         autoconfigure: true | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Service\Context\: | ||||
|         resource: "../Service/Context/" | ||||
|         autowire: true | ||||
|         autoconfigure: true | ||||
|  | ||||
|     Chill\DocGeneratorBundle\GeneratorDriver\: | ||||
|         resource: "../GeneratorDriver/" | ||||
|         autowire: true | ||||
|         autoconfigure: true | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Service\Messenger\: | ||||
|         resource: "../Service/Messenger/" | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Service\Generator\Generator: ~ | ||||
|     Chill\DocGeneratorBundle\Service\Generator\GeneratorInterface: '@Chill\DocGeneratorBundle\Service\Generator\Generator' | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Driver\RelatorioDriver: '@Chill\DocGeneratorBundle\Driver\DriverInterface' | ||||
|  | ||||
|     Chill\DocGeneratorBundle\Context\ContextManager: | ||||
|         arguments: | ||||
|             $contexts: !tagged_iterator { tag: chill_docgen.context, default_index_method: getKey } | ||||
|     Chill\DocGeneratorBundle\Context\ContextManagerInterface: '@Chill\DocGeneratorBundle\Context\ContextManager' | ||||
|   | ||||
| @@ -0,0 +1,47 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\Migrations\DocGenerator; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20230214192558 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Add status, template_id and fix defaults on chill_doc.stored_object'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD template_id INT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD status TEXT DEFAULT \'ready\' NOT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); | ||||
|         $this->addSql('UPDATE chill_doc.stored_object SET createdAt = creation_date'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD createdBy_id INT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP creation_date;'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ALTER type SET DEFAULT \'\''); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ALTER title DROP DEFAULT'); | ||||
|         $this->addSql('COMMENT ON COLUMN chill_doc.stored_object.createdAt IS \'(DC2Type:datetime_immutable)\''); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD CONSTRAINT FK_49604E365DA0FB8 FOREIGN KEY (template_id) REFERENCES chill_docgen_template (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD CONSTRAINT FK_49604E363174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('CREATE INDEX IDX_49604E365DA0FB8 ON chill_doc.stored_object (template_id)'); | ||||
|         $this->addSql('CREATE INDEX IDX_49604E363174800F ON chill_doc.stored_object (createdBy_id)'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP CONSTRAINT FK_49604E365DA0FB8'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP CONSTRAINT FK_49604E363174800F'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP template_id'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP status'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD creation_date TIMESTAMP(0) DEFAULT NOW()'); | ||||
|         $this->addSql('UPDATE chill_doc.stored_object SET creation_date = createdAt'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP createdAt'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP createdBy_id'); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ALTER title SET DEFAULT \'\''); | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ALTER type DROP DEFAULT'); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,141 @@ | ||||
| <?php | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\tests\Service\Context\Generator; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Context\ContextManagerInterface; | ||||
| use Chill\DocGeneratorBundle\Context\DocGeneratorContextInterface; | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\Generator; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\NullLogger; | ||||
|  | ||||
| class GeneratorTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     public function testSuccessfulGeneration(): void | ||||
|     { | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) | ||||
|             ->setType('application/test')); | ||||
|         $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING); | ||||
|         $reflection = new \ReflectionClass($destinationStoredObject); | ||||
|         $reflection->getProperty('id')->setAccessible(true); | ||||
|         $reflection->getProperty('id')->setValue($destinationStoredObject, 1); | ||||
|         $entity = new class {}; | ||||
|         $data = []; | ||||
|  | ||||
|         $context = $this->prophesize(DocGeneratorContextInterface::class); | ||||
|         $context->getData($template, $entity, Argument::type('array'))->willReturn($data); | ||||
|         $context->getName()->willReturn('dummy_context'); | ||||
|         $context->getEntityClass()->willReturn('DummyClass'); | ||||
|         $context = $context->reveal(); | ||||
|  | ||||
|         $contextManagerInterface = $this->prophesize(ContextManagerInterface::class); | ||||
|         $contextManagerInterface->getContextByDocGeneratorTemplate($template) | ||||
|             ->willReturn($context); | ||||
|  | ||||
|         $driver = $this->prophesize(DriverInterface::class); | ||||
|         $driver->generateFromString('template', 'application/test', $data, Argument::any()) | ||||
|             ->willReturn('generated'); | ||||
|  | ||||
|         $entityManager = $this->prophesize(EntityManagerInterface::class); | ||||
|         $entityManager->find(StoredObject::class, 1) | ||||
|             ->willReturn($destinationStoredObject); | ||||
|         $entityManager->find('DummyClass', Argument::type('int')) | ||||
|             ->willReturn($entity); | ||||
|         $entityManager->clear()->shouldBeCalled(); | ||||
|         $entityManager->flush()->shouldBeCalled(); | ||||
|  | ||||
|         $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); | ||||
|         $storedObjectManager->read($templateStoredObject)->willReturn('template'); | ||||
|         $storedObjectManager->write($destinationStoredObject, 'generated')->shouldBeCalled(); | ||||
|  | ||||
|  | ||||
|         $generator = new Generator( | ||||
|             $contextManagerInterface->reveal(), | ||||
|             $driver->reveal(), | ||||
|             $entityManager->reveal(), | ||||
|             new NullLogger(), | ||||
|             $storedObjectManager->reveal() | ||||
|         ); | ||||
|  | ||||
|         $generator->generateDocFromTemplate( | ||||
|             $template, | ||||
|             1, | ||||
|             [], | ||||
|             $destinationStoredObject | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function testPreventRegenerateDocument(): void | ||||
|     { | ||||
|         $this->expectException(ObjectReadyException::class); | ||||
|  | ||||
|         $generator = new Generator( | ||||
|             $this->prophesize(ContextManagerInterface::class)->reveal(), | ||||
|             $this->prophesize(DriverInterface::class)->reveal(), | ||||
|             $this->prophesize(EntityManagerInterface::class)->reveal(), | ||||
|             new NullLogger(), | ||||
|             $this->prophesize(StoredObjectManagerInterface::class)->reveal() | ||||
|         ); | ||||
|  | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) | ||||
|             ->setType('application/test')); | ||||
|         $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY); | ||||
|  | ||||
|         $generator->generateDocFromTemplate( | ||||
|             $template, | ||||
|             1, | ||||
|             [], | ||||
|             $destinationStoredObject | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function testRelatedEntityNotFound(): void | ||||
|     { | ||||
|         $this->expectException(RelatedEntityNotFoundException::class); | ||||
|  | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) | ||||
|             ->setType('application/test')); | ||||
|         $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING); | ||||
|         $reflection = new \ReflectionClass($destinationStoredObject); | ||||
|         $reflection->getProperty('id')->setAccessible(true); | ||||
|         $reflection->getProperty('id')->setValue($destinationStoredObject, 1); | ||||
|  | ||||
|         $context = $this->prophesize(DocGeneratorContextInterface::class); | ||||
|         $context->getName()->willReturn('dummy_context'); | ||||
|         $context->getEntityClass()->willReturn('DummyClass'); | ||||
|         $context = $context->reveal(); | ||||
|  | ||||
|         $contextManagerInterface = $this->prophesize(ContextManagerInterface::class); | ||||
|         $contextManagerInterface->getContextByDocGeneratorTemplate($template) | ||||
|             ->willReturn($context); | ||||
|  | ||||
|         $entityManager = $this->prophesize(EntityManagerInterface::class); | ||||
|         $entityManager->find(Argument::type('string'), Argument::type('int')) | ||||
|             ->willReturn(null); | ||||
|  | ||||
|         $generator = new Generator( | ||||
|             $contextManagerInterface->reveal(), | ||||
|             $this->prophesize(DriverInterface::class)->reveal(), | ||||
|             $entityManager->reveal(), | ||||
|             new NullLogger(), | ||||
|             $this->prophesize(StoredObjectManagerInterface::class)->reveal() | ||||
|         ); | ||||
|  | ||||
|         $generator->generateDocFromTemplate( | ||||
|             $template, | ||||
|             1, | ||||
|             [], | ||||
|             $destinationStoredObject | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -10,6 +10,16 @@ docgen: | ||||
|     test generate: Tester la génération | ||||
|     With context %name%: 'Avec le contexte "%name%"' | ||||
|  | ||||
|     Doc generation failed: La génération de ce document a échoué | ||||
|     Doc generation is pending: La génération de ce document est en cours | ||||
|     Come back later: Revenir plus tard | ||||
|  | ||||
|     failure_email: | ||||
|         The generation of a document failed: La génération d'un document a échoué | ||||
|         The generation of the document {template_name} failed: La génération d'un document à partir du modèle {{ template_name }} a échoué. | ||||
|         The following errors were encoutered: Les erreurs suivantes ont été rencontrées | ||||
|         Forward this email to your administrator for solving: Faites suivre ce message vers votre administrateur pour la résolution du problème. | ||||
|         References: Références | ||||
|  | ||||
| crud: | ||||
|     docgen_template: | ||||
| @@ -19,4 +29,4 @@ crud: | ||||
|  | ||||
|  | ||||
| Show data instead of generating: Montrer les données au lieu de générer le document | ||||
| Template file: Fichier modèle | ||||
| Template file: Fichier modèle | ||||
|   | ||||
| @@ -0,0 +1,39 @@ | ||||
| <?php | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Controller; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class StoredObjectApiController | ||||
| { | ||||
|     private Security $security; | ||||
|  | ||||
|     public function __construct(Security $security) | ||||
|     { | ||||
|         $this->security = $security; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/api/1.0/doc-store/stored-object/{uuid}/is-ready") | ||||
|      */ | ||||
|     public function isDocumentReady(StoredObject $storedObject): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted('ROLE_USER')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         return new JsonResponse( | ||||
|             [ | ||||
|                 'id' => $storedObject->getId(), | ||||
|                 'filename' => $storedObject->getFilename(), | ||||
|                 'status' => $storedObject->getStatus(), | ||||
|                 'type' => $storedObject->getType(), | ||||
|             ] | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -14,6 +14,9 @@ namespace Chill\DocStoreBundle\Entity; | ||||
| use ChampsLibres\AsyncUploaderBundle\Model\AsyncFileInterface; | ||||
| use ChampsLibres\AsyncUploaderBundle\Validator\Constraints\AsyncFileExists; | ||||
| use ChampsLibres\WopiLib\Contract\Entity\Document; | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use DateTime; | ||||
| use DateTimeInterface; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| @@ -30,13 +33,13 @@ use Symfony\Component\Serializer\Annotation as Serializer; | ||||
|  *     message="The file is not stored properly" | ||||
|  * ) | ||||
|  */ | ||||
| class StoredObject implements AsyncFileInterface, Document | ||||
| class StoredObject implements AsyncFileInterface, Document, TrackCreationInterface | ||||
| { | ||||
|     /** | ||||
|      * @ORM\Column(type="datetime", name="creation_date") | ||||
|      * @Serializer\Groups({"read", "write"}) | ||||
|      */ | ||||
|     private DateTimeInterface $creationDate; | ||||
|     public const STATUS_READY = "ready"; | ||||
|     public const STATUS_PENDING = "pending"; | ||||
|     public const STATUS_FAILURE = "failure"; | ||||
|  | ||||
|     use TrackCreationTrait; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="json", name="datas") | ||||
| @@ -48,7 +51,7 @@ class StoredObject implements AsyncFileInterface, Document | ||||
|      * @ORM\Column(type="text") | ||||
|      * @Serializer\Groups({"read", "write"}) | ||||
|      */ | ||||
|     private $filename; | ||||
|     private string $filename = ''; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Id | ||||
| @@ -56,7 +59,7 @@ class StoredObject implements AsyncFileInterface, Document | ||||
|      * @ORM\Column(type="integer") | ||||
|      * @Serializer\Groups({"read", "write"}) | ||||
|      */ | ||||
|     private $id; | ||||
|     private ?int $id; | ||||
|  | ||||
|     /** | ||||
|      * @var int[] | ||||
| @@ -78,7 +81,7 @@ class StoredObject implements AsyncFileInterface, Document | ||||
|     private string $title = ''; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="text", name="type") | ||||
|      * @ORM\Column(type="text", name="type", options={"default": ""}) | ||||
|      * @Serializer\Groups({"read", "write"}) | ||||
|      */ | ||||
|     private string $type = ''; | ||||
| @@ -89,28 +92,68 @@ class StoredObject implements AsyncFileInterface, Document | ||||
|      */ | ||||
|     private UuidInterface $uuid; | ||||
|  | ||||
|     public function __construct() | ||||
|     /** | ||||
|      * @ORM\ManyToOne(targetEntity=DocGeneratorTemplate::class) | ||||
|      */ | ||||
|     private ?DocGeneratorTemplate $template; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="text", options={"default": "ready"}) | ||||
|      * @Serializer\Groups({"read"}) | ||||
|      */ | ||||
|     private string $status; | ||||
|  | ||||
|     /** | ||||
|      * Store the number of times a generation has been tryied for this StoredObject. | ||||
|      * | ||||
|      * This is a workaround, as generation consume lot of memory, and out-of-memory errors | ||||
|      * are not handled by messenger. | ||||
|      * | ||||
|      * @ORM\Column(type="integer", options={"default": 0}) | ||||
|      */ | ||||
|     private int $generationTrialsCounter = 0; | ||||
|  | ||||
|     /** | ||||
|      * @param StoredObject::STATUS_* $status | ||||
|      */ | ||||
|     public function __construct(string $status = "ready") | ||||
|     { | ||||
|         $this->creationDate = new DateTime(); | ||||
|         $this->uuid = Uuid::uuid4(); | ||||
|         $this->status = $status; | ||||
|     } | ||||
|  | ||||
|     public function addGenerationTrial(): self | ||||
|     { | ||||
|         $this->generationTrialsCounter++; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Serializer\Groups({"read", "write"}) | ||||
|      * @deprecated | ||||
|      */ | ||||
|     public function getCreationDate(): DateTime | ||||
|     { | ||||
|         return $this->creationDate; | ||||
|         return DateTime::createFromImmutable($this->createdAt); | ||||
|     } | ||||
|  | ||||
|     public function getDatas() | ||||
|     public function getDatas(): array | ||||
|     { | ||||
|         return $this->datas; | ||||
|     } | ||||
|  | ||||
|     public function getFilename() | ||||
|     public function getFilename(): string | ||||
|     { | ||||
|         return $this->filename; | ||||
|     } | ||||
|  | ||||
|     public function getId() | ||||
|     public function getGenerationTrialsCounter(): int | ||||
|     { | ||||
|         return $this->generationTrialsCounter; | ||||
|     } | ||||
|  | ||||
|     public function getId(): ?int | ||||
|     { | ||||
|         return $this->id; | ||||
|     } | ||||
| @@ -133,6 +176,14 @@ class StoredObject implements AsyncFileInterface, Document | ||||
|         return $this->getFilename(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return StoredObject::STATUS_* | ||||
|      */ | ||||
|     public function getStatus(): string | ||||
|     { | ||||
|         return $this->status; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return $this->title; | ||||
| @@ -153,52 +204,92 @@ class StoredObject implements AsyncFileInterface, Document | ||||
|         return (string) $this->uuid; | ||||
|     } | ||||
|  | ||||
|     public function setCreationDate(DateTime $creationDate) | ||||
|     /** | ||||
|      * @Serializer\Groups({"write"}) | ||||
|      * @deprecated | ||||
|      */ | ||||
|     public function setCreationDate(DateTime $creationDate): self | ||||
|     { | ||||
|         $this->creationDate = $creationDate; | ||||
|         $this->createdAt = \DateTimeImmutable::createFromMutable($creationDate); | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setDatas(?array $datas) | ||||
|     public function setDatas(?array $datas): self | ||||
|     { | ||||
|         $this->datas = (array) $datas; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setFilename(?string $filename) | ||||
|     public function setFilename(?string $filename): self | ||||
|     { | ||||
|         $this->filename = (string) $filename; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setIv(?array $iv) | ||||
|     public function setIv(?array $iv): self | ||||
|     { | ||||
|         $this->iv = (array) $iv; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setKeyInfos(?array $keyInfos) | ||||
|     public function setKeyInfos(?array $keyInfos): self | ||||
|     { | ||||
|         $this->keyInfos = (array) $keyInfos; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setTitle(?string $title) | ||||
|     /** | ||||
|      * @param StoredObject::STATUS_* $status | ||||
|      */ | ||||
|     public function setStatus(string $status): self | ||||
|     { | ||||
|         $this->status = $status; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setTitle(?string $title): self | ||||
|     { | ||||
|         $this->title = (string) $title; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setType(?string $type) | ||||
|     public function setType(?string $type): self | ||||
|     { | ||||
|         $this->type = (string) $type; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getTemplate(): ?DocGeneratorTemplate | ||||
|     { | ||||
|         return $this->template; | ||||
|     } | ||||
|  | ||||
|     public function hasTemplate(): bool | ||||
|     { | ||||
|         return null !== $this->template; | ||||
|     } | ||||
|  | ||||
|     public function setTemplate(?DocGeneratorTemplate $template): StoredObject | ||||
|     { | ||||
|         $this->template = $template; | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function isPending(): bool | ||||
|     { | ||||
|         return self::STATUS_PENDING === $this->getStatus(); | ||||
|     } | ||||
|  | ||||
|     public function isFailure(): bool | ||||
|     { | ||||
|         return self::STATUS_FAILURE === $this->getStatus(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n"; | ||||
| import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue"; | ||||
| import {createApp} from "vue"; | ||||
| import {StoredObject} from "../../types"; | ||||
| import {StoredObject, StoredObjectStatusChange} from "../../types"; | ||||
| import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers"; | ||||
|  | ||||
| const i18n = _createI18n({}); | ||||
|  | ||||
| @@ -15,19 +16,32 @@ window.addEventListener('DOMContentLoaded', function (e) { | ||||
|            filename: string, | ||||
|            canEdit: string, | ||||
|            storedObject: string, | ||||
|            small: string, | ||||
|            buttonSmall: string, | ||||
|          }; | ||||
|  | ||||
|          const | ||||
|            storedObject = JSON.parse(datasets.storedObject), | ||||
|            storedObject = JSON.parse(datasets.storedObject) as StoredObject, | ||||
|            filename = datasets.filename, | ||||
|            canEdit = datasets.canEdit === '1', | ||||
|            small = datasets.small === '1' | ||||
|            small = datasets.buttonSmall === '1' | ||||
|            ; | ||||
|  | ||||
|          return { storedObject, filename, canEdit, small }; | ||||
|        }, | ||||
|        template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small"></document-action-buttons-group>', | ||||
|        template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>', | ||||
|        methods: { | ||||
|          onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void { | ||||
|            this.$data.storedObject.status = newStatus.status; | ||||
|            this.$data.storedObject.filename = newStatus.filename; | ||||
|            this.$data.storedObject.type = newStatus.type; | ||||
|  | ||||
|            // remove eventual div which inform pending status | ||||
|            document.querySelectorAll(`[data-docgen-is-pending="${this.$data.storedObject.id}"]`) | ||||
|              .forEach(function(el) { | ||||
|                el.remove(); | ||||
|              }); | ||||
|          } | ||||
|        } | ||||
|      }); | ||||
|  | ||||
|      app.use(i18n).mount(el); | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import {DateTime} from "../../../ChillMainBundle/Resources/public/types"; | ||||
|  | ||||
| export type StoredObjectStatus = "ready"|"failure"|"pending"; | ||||
|  | ||||
| export interface StoredObject { | ||||
|   id: number, | ||||
|  | ||||
| @@ -13,7 +15,15 @@ export interface StoredObject { | ||||
|   keyInfos: object, | ||||
|   title: string, | ||||
|   type: string, | ||||
|   uuid: string | ||||
|   uuid: string, | ||||
|   status: StoredObjectStatus, | ||||
| } | ||||
|  | ||||
| export interface StoredObjectStatusChange { | ||||
|   id: number, | ||||
|   filename: string, | ||||
|   status: StoredObjectStatus, | ||||
|   type: string, | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <div class="dropdown"> | ||||
|     <button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, small: props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|   <div v-if="'ready' === props.storedObject.status" class="btn-group"> | ||||
|     <button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|       Actions | ||||
|     </button> | ||||
|     <ul class="dropdown-menu"> | ||||
| @@ -15,16 +15,27 @@ | ||||
|       </li> | ||||
|     </ul> | ||||
|   </div> | ||||
|   <div v-else-if="'pending' === props.storedObject.status"> | ||||
|     <div class="btn btn-outline-info">Génération en cours</div> | ||||
|   </div> | ||||
|   <div v-else-if="'failure' === props.storedObject.status"> | ||||
|     <div class="btn btn-outline-danger">La génération a échoué</div> | ||||
|   </div> | ||||
|  | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
|  | ||||
| import {onMounted} from "vue"; | ||||
| import ConvertButton from "./StoredObjectButton/ConvertButton.vue"; | ||||
| import DownloadButton from "./StoredObjectButton/DownloadButton.vue"; | ||||
| import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue"; | ||||
| import {is_extension_editable, is_extension_viewable} from "./StoredObjectButton/helpers"; | ||||
| import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../types"; | ||||
| import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers"; | ||||
| import { | ||||
|   StoredObject, | ||||
|   StoredObjectStatusChange, | ||||
|   WopiEditButtonExecutableBeforeLeaveFunction | ||||
| } from "../types"; | ||||
|  | ||||
| interface DocumentActionButtonsGroupConfig { | ||||
|   storedObject: StoredObject, | ||||
| @@ -48,6 +59,10 @@ interface DocumentActionButtonsGroupConfig { | ||||
|   executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction, | ||||
| } | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|   (e: 'onStoredObjectStatusChange', newStatus: StoredObjectStatusChange): void | ||||
| }>(); | ||||
|  | ||||
| const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), { | ||||
|   small: false, | ||||
|   canEdit: true, | ||||
| @@ -56,6 +71,51 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), { | ||||
|   returnPath: window.location.pathname + window.location.search + window.location.hash, | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * counter for the number of times that we check for a new status | ||||
|  */ | ||||
| let tryiesForReady = 0; | ||||
|  | ||||
| /** | ||||
|  * how many times we may check for a new status, once loaded | ||||
|  */ | ||||
| const maxTryiesForReady = 120; | ||||
|  | ||||
| const checkForReady = function(): void { | ||||
|   if ( | ||||
|     'ready' === props.storedObject.status | ||||
|     || 'failure' === props.storedObject.status | ||||
|     // stop reloading if the page stays opened for a long time | ||||
|     || tryiesForReady > maxTryiesForReady | ||||
|   ) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   tryiesForReady = tryiesForReady + 1; | ||||
|  | ||||
|   setTimeout(onObjectNewStatusCallback, 5000); | ||||
| }; | ||||
|  | ||||
| const onObjectNewStatusCallback = async function(): Promise<void> { | ||||
|   const new_status = await is_object_ready(props.storedObject); | ||||
|   if (props.storedObject.status !== new_status.status) { | ||||
|     emit('onStoredObjectStatusChange', new_status); | ||||
|     return Promise.resolve(); | ||||
|   } else if ('failure' === new_status.status) { | ||||
|     return Promise.resolve(); | ||||
|   } | ||||
|  | ||||
|   if ('ready' !== new_status.status) { | ||||
|     // we check for new status, unless it is ready | ||||
|     checkForReady(); | ||||
|   } | ||||
|  | ||||
|   return Promise.resolve(); | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   checkForReady(); | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
|   | ||||
| @@ -38,13 +38,11 @@ async function download_and_open(event: Event): Promise<void> { | ||||
|     button.href = window.URL.createObjectURL(raw); | ||||
|     button.type = props.storedObject.type; | ||||
|  | ||||
|     if (props.filename !== undefined) { | ||||
|       button.download = props.filename || 'document'; | ||||
|     button.download = props.filename || 'document'; | ||||
|  | ||||
|       const ext = mime.getExtension(props.storedObject.type); | ||||
|       if (null !== ext) { | ||||
|         button.download = button.download + '.' + ext; | ||||
|       } | ||||
|     const ext = mime.getExtension(props.storedObject.type); | ||||
|     if (null !== ext) { | ||||
|       button.download = button.download + '.' + ext; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types"; | ||||
|  | ||||
| const MIMES_EDIT = new Set([ | ||||
|   'application/vnd.ms-powerpoint', | ||||
| @@ -168,6 +169,18 @@ async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKe | ||||
|    } | ||||
| } | ||||
|  | ||||
| async function is_object_ready(storedObject: StoredObject): Promise<StoredObjectStatusChange> | ||||
| { | ||||
|     const new_status_response = await window | ||||
|       .fetch( `/api/1.0/doc-store/stored-object/${storedObject.uuid}/is-ready`); | ||||
|  | ||||
|     if (!new_status_response.ok) { | ||||
|       throw new Error("could not fetch the new status"); | ||||
|     } | ||||
|  | ||||
|     return await new_status_response.json(); | ||||
| } | ||||
|  | ||||
| export { | ||||
|   build_convert_link, | ||||
|   build_download_info_link, | ||||
| @@ -176,4 +189,5 @@ export { | ||||
|   download_doc, | ||||
|   is_extension_editable, | ||||
|   is_extension_viewable, | ||||
|   is_object_ready, | ||||
| }; | ||||
|   | ||||
| @@ -9,11 +9,6 @@ | ||||
|         <dt>{{ 'Title'|trans }}</dt> | ||||
|         <dd>{{ document.title }}</dd> | ||||
|  | ||||
|         {% if document.scope is not null %} | ||||
|             <dt>{{ 'Scope' | trans }}</dt> | ||||
|             <dd>{{ document.scope.name | localize_translatable_string }}</dd> | ||||
|         {% endif %} | ||||
|  | ||||
|         <dt>{{ 'Category'|trans }}</dt> | ||||
|         <dd>{{ document.category.name|localize_translatable_string }}</dd> | ||||
|  | ||||
|   | ||||
| @@ -5,18 +5,25 @@ | ||||
| <div class="item-bloc"> | ||||
|     <div class="item-row"> | ||||
|         <div class="item-col" style="width: unset"> | ||||
|             {% if document.object.isPending %} | ||||
|                 <div class="badge text-bg-info" data-docgen-is-pending="{{ document.object.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div> | ||||
|             {% elseif document.object.isFailure %} | ||||
|                 <div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div> | ||||
|             {% endif %} | ||||
|             <div class="denomination h2"> | ||||
|                 {{ document.title }} | ||||
|             </div> | ||||
|             <div> | ||||
|                 {{ mm.mimeIcon(document.object.type) }} | ||||
|             </div> | ||||
|             {% if document.object.type is not empty %} | ||||
|                 <div> | ||||
|                     {{ mm.mimeIcon(document.object.type) }} | ||||
|                 </div> | ||||
|             {% endif %} | ||||
|             <div> | ||||
|                 <p>{{ document.category.name|localize_translatable_string }}</p> | ||||
|             </div> | ||||
|             {% if document.template is not null %} | ||||
|             {% if document.object.hasTemplate %} | ||||
|                 <div> | ||||
|                     <p>{{ document.template.name.fr }}</p> | ||||
|                     <p>{{ document.object.template.name|localize_translatable_string }}</p> | ||||
|                 </div> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|   | ||||
| @@ -157,7 +157,7 @@ final class WopiEditTwigExtensionRuntime implements RuntimeExtensionInterface | ||||
|             'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]), | ||||
|             'title' => $title, | ||||
|             'can_edit' => $canEdit, | ||||
|             'options' => array_merge($options, self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP), | ||||
|             'options' => array_merge(self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, $options), | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,26 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\Migrations\DocStore; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20230227161327 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Add a generation counter on doc store'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object ADD generationTrialsCounter INT DEFAULT 0 NOT NULL;'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_doc.stored_object DROP generationTrialsCounter'); | ||||
|     } | ||||
| } | ||||
| @@ -19,6 +19,9 @@ The document is successfully registered: Le document est enregistré | ||||
| The document is successfully updated: Le document est mis à jour | ||||
| Any description: Aucune description | ||||
|  | ||||
| document: | ||||
|     Any title: Aucun titre | ||||
|  | ||||
| # delete | ||||
| Delete document ?: Supprimer le document ? | ||||
| Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ? | ||||
| @@ -73,4 +76,4 @@ CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE: Créer un document | ||||
| CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE: Supprimer un document | ||||
| CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE: Voir les documents | ||||
| CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS: Voir les détails d'un document | ||||
| CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE: Modifier un document | ||||
| CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE: Modifier un document | ||||
|   | ||||
| @@ -72,7 +72,6 @@ class ChillMainBundle extends Bundle | ||||
|         $container->addCompilerPass(new NotificationCounterCompilerPass()); | ||||
|         $container->addCompilerPass(new MenuCompilerPass()); | ||||
|         $container->addCompilerPass(new ACLFlagsCompilerPass()); | ||||
|         $container->addCompilerPass(new GroupingCenterCompilerPass()); | ||||
|         $container->addCompilerPass(new CRUDControllerCompilerPass()); | ||||
|         $container->addCompilerPass(new ShortMessageCompilerPass()); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										65
									
								
								src/Bundle/ChillMainBundle/Controller/AbsenceController.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/Bundle/ChillMainBundle/Controller/AbsenceController.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| <?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\Form\AbsenceType; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
|  | ||||
| class AbsenceController extends AbstractController | ||||
| { | ||||
|     /** | ||||
|      * @Route( | ||||
|      *     "/{_locale}/absence", | ||||
|      *     name="chill_main_user_absence_index", | ||||
|      *     methods={"GET", "POST"} | ||||
|      * ) | ||||
|      */ | ||||
|     public function setAbsence(Request $request) | ||||
|     { | ||||
|         $user = $this->getUser(); | ||||
|         $form = $this->createForm(AbsenceType::class, $user); | ||||
|  | ||||
|         $form->handleRequest($request); | ||||
|  | ||||
|         if ($form->isSubmitted() && $form->isValid()) { | ||||
|             $em = $this->getDoctrine()->getManager(); | ||||
|             $em->flush(); | ||||
|  | ||||
|             return $this->redirect($this->generateUrl('chill_main_user_absence_index')); | ||||
|         } | ||||
|  | ||||
|         return $this->render('@ChillMain/Menu/absence.html.twig', [ | ||||
|             'user' => $user, | ||||
|             'form' => $form->createView(), | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route( | ||||
|      *     "/{_locale}/absence/unset", | ||||
|      *     name="chill_main_user_absence_unset", | ||||
|      *     methods={"GET", "POST"} | ||||
|      * ) | ||||
|      */ | ||||
|     public function unsetAbsence(Request $request) | ||||
|     { | ||||
|         $user = $this->getUser(); | ||||
|  | ||||
|         $user->setAbsenceStart(null); | ||||
|         $em = $this->getDoctrine()->getManager(); | ||||
|         $em->flush(); | ||||
|  | ||||
|         return $this->redirect($this->generateUrl('chill_main_user_absence_index')); | ||||
|     } | ||||
| } | ||||
| @@ -298,6 +298,8 @@ class ExportController extends AbstractController | ||||
|                 'csrf_protection' => $isGenerate ? false : true, | ||||
|             ]); | ||||
|  | ||||
|         // TODO: add a condition to be able to select a regroupment of centers? | ||||
|  | ||||
|         if ('centers' === $step || 'generate_centers' === $step) { | ||||
|             $builder->add('centers', PickCenterType::class, [ | ||||
|                 'export_alias' => $alias, | ||||
|   | ||||
| @@ -28,7 +28,7 @@ class LocationController extends CRUDController | ||||
|  | ||||
|     protected function customizeQuery(string $action, Request $request, $query): void | ||||
|     { | ||||
|         $query->where('e.availableForUsers = "TRUE"'); | ||||
|         $query->where('e.availableForUsers = TRUE'); | ||||
|     } | ||||
|  | ||||
|     protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator) | ||||
|   | ||||
| @@ -14,6 +14,7 @@ namespace Chill\MainBundle\Controller; | ||||
| use Chill\MainBundle\Entity\Notification; | ||||
| use Chill\MainBundle\Entity\NotificationComment; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Form\NotificationCommentType; | ||||
| use Chill\MainBundle\Form\NotificationType; | ||||
| use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound; | ||||
| @@ -21,6 +22,7 @@ use Chill\MainBundle\Notification\NotificationHandlerManager; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Repository\NotificationRepository; | ||||
| use Chill\MainBundle\Repository\UserRepository; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; | ||||
| use Chill\MainBundle\Security\Authorization\NotificationVoter; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| @@ -68,7 +70,8 @@ class NotificationController extends AbstractController | ||||
|         NotificationHandlerManager $notificationHandlerManager, | ||||
|         PaginatorFactory $paginatorFactory, | ||||
|         TranslatorInterface $translator, | ||||
|         UserRepository $userRepository | ||||
|         UserRepository $userRepository, | ||||
|         EntityWorkflowRepository $entityWorkflowRepository | ||||
|     ) { | ||||
|         $this->em = $em; | ||||
|         $this->logger = $logger; | ||||
| @@ -79,6 +82,7 @@ class NotificationController extends AbstractController | ||||
|         $this->paginatorFactory = $paginatorFactory; | ||||
|         $this->translator = $translator; | ||||
|         $this->userRepository = $userRepository; | ||||
|         $this->entityWorkflowRepository = $entityWorkflowRepository; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -345,6 +349,7 @@ class NotificationController extends AbstractController | ||||
|             'appendCommentForm' => isset($appendCommentForm) ? $appendCommentForm->createView() : null, | ||||
|             'editedCommentForm' => isset($editedCommentForm) ? $editedCommentForm->createView() : null, | ||||
|             'editedCommentId' => $commentId ?? null, | ||||
|             'notificationCc' => $this->isNotificationCc($notification), | ||||
|         ]); | ||||
|  | ||||
|         // we mark the notification as read after having computed the response | ||||
| @@ -364,6 +369,21 @@ class NotificationController extends AbstractController | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     private function isNotificationCc(Notification $notification): bool | ||||
|     { | ||||
|         $notificationCc = false; | ||||
|  | ||||
|         if ($notification->getRelatedEntityClass() === EntityWorkflow::class) { | ||||
|             $relatedEntity = $this->entityWorkflowRepository->findOneBy(['id' => $notification->getRelatedEntityId()]); | ||||
|  | ||||
|             if ($relatedEntity->getCurrentStepCreatedBy() !== $this->security->getUser()) { | ||||
|                 $notificationCc = true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $notificationCc; | ||||
|     } | ||||
|  | ||||
|     private function itemsForTemplate(array $notifications): array | ||||
|     { | ||||
|         $templateData = []; | ||||
| @@ -373,6 +393,7 @@ class NotificationController extends AbstractController | ||||
|                 'template' => $this->notificationHandlerManager->getTemplate($notification), | ||||
|                 'template_data' => $this->notificationHandlerManager->getTemplateData($notification), | ||||
|                 'notification' => $notification, | ||||
|                 'isNotificationCc' => $this->isNotificationCc($notification), | ||||
|             ]; | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -93,6 +93,45 @@ class WorkflowApiController | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Return a list of workflow which are waiting an action for the user. | ||||
|      * | ||||
|      * @Route("/api/1.0/main/workflow/my-cc", methods={"GET"}) | ||||
|      */ | ||||
|     public function myWorkflowCc(Request $request): JsonResponse | ||||
|     { | ||||
|         if (!$this->security->isGranted('ROLE_USER') || !$this->security->getUser() instanceof User) { | ||||
|             throw new AccessDeniedException(); | ||||
|         } | ||||
|  | ||||
|         $total = $this->entityWorkflowRepository->countByCc($this->security->getUser()); | ||||
|  | ||||
|         if ($request->query->getBoolean('countOnly', false)) { | ||||
|             return new JsonResponse( | ||||
|                 $this->serializer->serialize(new Counter($total), 'json'), | ||||
|                 JsonResponse::HTTP_OK, | ||||
|                 [], | ||||
|                 true | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         $paginator = $this->paginatorFactory->create($total); | ||||
|  | ||||
|         $workflows = $this->entityWorkflowRepository->findByCc( | ||||
|             $this->security->getUser(), | ||||
|             ['id' => 'DESC'], | ||||
|             $paginator->getItemsPerPage(), | ||||
|             $paginator->getCurrentPageFirstItemNumber() | ||||
|         ); | ||||
|  | ||||
|         return new JsonResponse( | ||||
|             $this->serializer->serialize(new Collection($workflows, $paginator), 'json', ['groups' => ['read']]), | ||||
|             JsonResponse::HTTP_OK, | ||||
|             [], | ||||
|             true | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/api/1.0/main/workflow/{id}/subscribe", methods={"POST"}) | ||||
|      */ | ||||
|   | ||||
| @@ -224,6 +224,33 @@ class WorkflowController extends AbstractController | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/{_locale}/main/workflow/list/cc", name="chill_main_workflow_list_cc") | ||||
|      */ | ||||
|     public function myWorkflowsCc(Request $request): Response | ||||
|     { | ||||
|         $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); | ||||
|  | ||||
|         $total = $this->entityWorkflowRepository->countByDest($this->getUser()); | ||||
|         $paginator = $this->paginatorFactory->create($total); | ||||
|  | ||||
|         $workflows = $this->entityWorkflowRepository->findByCc( | ||||
|             $this->getUser(), | ||||
|             ['createdAt' => 'DESC'], | ||||
|             $paginator->getItemsPerPage(), | ||||
|             $paginator->getCurrentPageFirstItemNumber() | ||||
|         ); | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillMain/Workflow/list.html.twig', | ||||
|             [ | ||||
|                 'workflows' => $this->buildHandler($workflows), | ||||
|                 'paginator' => $paginator, | ||||
|                 'step' => 'cc', | ||||
|             ] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/{_locale}/main/workflow/list/dest", name="chill_main_workflow_list_dest") | ||||
|      */ | ||||
| @@ -318,6 +345,7 @@ class WorkflowController extends AbstractController | ||||
|                 } | ||||
|  | ||||
|                 // TODO symfony 5: add those "future" on context ($workflow->apply($entityWorkflow, $transition, $context) | ||||
|                 $entityWorkflow->futureCcUsers = $transitionForm['future_cc_users']->getData(); | ||||
|                 $entityWorkflow->futureDestUsers = $transitionForm['future_dest_users']->getData(); | ||||
|                 $entityWorkflow->futureDestEmails = $transitionForm['future_dest_emails']->getData(); | ||||
|  | ||||
|   | ||||
| @@ -1,37 +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\DependencyInjection\CompilerPass; | ||||
|  | ||||
| use LogicException; | ||||
| use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; | ||||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||||
| use Symfony\Component\DependencyInjection\Reference; | ||||
|  | ||||
| class GroupingCenterCompilerPass implements CompilerPassInterface | ||||
| { | ||||
|     public function process(ContainerBuilder $container) | ||||
|     { | ||||
|         if (false === $container->hasDefinition('chill.main.form.pick_centers_type')) { | ||||
|             throw new LogicException('The service chill.main.form.pick_centers_type does ' | ||||
|                 . 'not exists in container'); | ||||
|         } | ||||
|  | ||||
|         $pickCenterType = $container->getDefinition('chill.main.form.pick_centers_type'); | ||||
|  | ||||
|         foreach ($container->findTaggedServiceIds('chill.grouping_center') as $serviceId => $tagged) { | ||||
|             $pickCenterType->addMethodCall( | ||||
|                 'addGroupingCenter', | ||||
|                 [new Reference($serviceId)] | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -24,7 +24,7 @@ class Regroupment | ||||
|     /** | ||||
|      * @var Center | ||||
|      * @ORM\ManyToMany( | ||||
|      *     targetEntity="Chill\MainBundle\Entity\Center" | ||||
|      *     targetEntity=Center::class | ||||
|      * ) | ||||
|      * @ORM\Id | ||||
|      */ | ||||
| @@ -43,7 +43,7 @@ class Regroupment | ||||
|     private bool $isActive = true; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="string", length=15, options={"default": ""}, nullable=false) | ||||
|      * @ORM\Column(type="text", options={"default": ""}, nullable=false) | ||||
|      */ | ||||
|     private string $name = ''; | ||||
|  | ||||
| @@ -52,7 +52,7 @@ class Regroupment | ||||
|         $this->centers = new ArrayCollection(); | ||||
|     } | ||||
|  | ||||
|     public function getCenters(): ?Collection | ||||
|     public function getCenters(): Collection | ||||
|     { | ||||
|         return $this->centers; | ||||
|     } | ||||
|   | ||||
| @@ -11,14 +11,15 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Entity; | ||||
|  | ||||
| use DateTimeImmutable; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use RuntimeException; | ||||
| use Symfony\Component\Security\Core\User\UserInterface; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
|  | ||||
| use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
| use function in_array; | ||||
|  | ||||
| /** | ||||
| @@ -40,6 +41,11 @@ class User implements UserInterface | ||||
|      */ | ||||
|     protected ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="datetime_immutable", nullable=true) | ||||
|      */ | ||||
|     private ?DateTimeImmutable $absenceStart = null; | ||||
|  | ||||
|     /** | ||||
|      * Array where SAML attributes's data are stored. | ||||
|      * | ||||
| @@ -173,6 +179,11 @@ class User implements UserInterface | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public function getAbsenceStart(): ?DateTimeImmutable | ||||
|     { | ||||
|         return $this->absenceStart; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get attributes. | ||||
|      * | ||||
| @@ -291,6 +302,11 @@ class User implements UserInterface | ||||
|         return $this->usernameCanonical; | ||||
|     } | ||||
|  | ||||
|     public function isAbsent(): bool | ||||
|     { | ||||
|         return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new DateTimeImmutable('now'); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return bool | ||||
|      */ | ||||
| @@ -355,6 +371,11 @@ class User implements UserInterface | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function setAbsenceStart(?DateTimeImmutable $absenceStart): void | ||||
|     { | ||||
|         $this->absenceStart = $absenceStart; | ||||
|     } | ||||
|  | ||||
|     public function setAttributeByDomain(string $domain, string $key, $value): self | ||||
|     { | ||||
|         $this->attributes[$domain][$key] = $value; | ||||
|   | ||||
| @@ -41,6 +41,13 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface | ||||
|  | ||||
|     use TrackUpdateTrait; | ||||
|  | ||||
|     /** | ||||
|      * a list of future cc users for the next steps. | ||||
|      * | ||||
|      * @var array|User[] | ||||
|      */ | ||||
|     public array $futureCcUsers = []; | ||||
|  | ||||
|     /** | ||||
|      * a list of future dest emails for the next steps. | ||||
|      * | ||||
|   | ||||
| @@ -32,6 +32,12 @@ class EntityWorkflowStep | ||||
|      */ | ||||
|     private string $accessKey; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToMany(targetEntity=User::class) | ||||
|      * @ORM\JoinTable(name="chill_main_workflow_entity_step_cc_user") | ||||
|      */ | ||||
|     private Collection $ccUser; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="text", options={"default": ""}) | ||||
|      */ | ||||
| @@ -114,11 +120,21 @@ class EntityWorkflowStep | ||||
|  | ||||
|     public function __construct() | ||||
|     { | ||||
|         $this->ccUser = new ArrayCollection(); | ||||
|         $this->destUser = new ArrayCollection(); | ||||
|         $this->destUserByAccessKey = new ArrayCollection(); | ||||
|         $this->accessKey = bin2hex(openssl_random_pseudo_bytes(32)); | ||||
|     } | ||||
|  | ||||
|     public function addCcUser(User $user): self | ||||
|     { | ||||
|         if (!$this->ccUser->contains($user)) { | ||||
|             $this->ccUser[] = $user; | ||||
|         } | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function addDestEmail(string $email): self | ||||
|     { | ||||
|         if (!in_array($email, $this->destEmail, true)) { | ||||
| @@ -167,6 +183,11 @@ class EntityWorkflowStep | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function getCcUser(): Collection | ||||
|     { | ||||
|         return $this->ccUser; | ||||
|     } | ||||
|  | ||||
|     public function getComment(): string | ||||
|     { | ||||
|         return $this->comment; | ||||
| @@ -261,6 +282,13 @@ class EntityWorkflowStep | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public function removeCcUser(User $user): self | ||||
|     { | ||||
|         $this->ccUser->removeElement($user); | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function removeDestEmail(string $email): self | ||||
|     { | ||||
|         $this->destEmail = array_filter($this->destEmail, static function (string $existing) use ($email) { | ||||
|   | ||||
| @@ -11,7 +11,6 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Export; | ||||
|  | ||||
| use Closure; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -12,6 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\MainBundle\Export\Helper; | ||||
|  | ||||
| use DateTime; | ||||
| use DateTimeInterface; | ||||
| use Exception; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| @@ -35,7 +36,7 @@ class DateTimeHelper | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             if ($value instanceof \DateTimeInterface) { | ||||
|             if ($value instanceof DateTimeInterface) { | ||||
|                 return $value; | ||||
|             } | ||||
|  | ||||
|   | ||||
							
								
								
									
										38
									
								
								src/Bundle/ChillMainBundle/Form/AbsenceType.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/Bundle/ChillMainBundle/Form/AbsenceType.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Form; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
|  | ||||
| class AbsenceType extends AbstractType | ||||
| { | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
|     { | ||||
|         $builder | ||||
|             ->add('absenceStart', ChillDateType::class, [ | ||||
|                 'required' => true, | ||||
|                 'input' => 'datetime_immutable', | ||||
|                 'label' => 'absence.Absence start', | ||||
|             ]); | ||||
|     } | ||||
|  | ||||
|     public function configureOptions(OptionsResolver $resolver) | ||||
|     { | ||||
|         $resolver->setDefaults([ | ||||
|             'data_class' => User::class, | ||||
|         ]); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,87 @@ | ||||
| <?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\Form\DataMapper; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Regroupment; | ||||
| use Chill\MainBundle\Repository\RegroupmentRepository; | ||||
| use Exception; | ||||
| use Symfony\Component\Form\DataMapperInterface; | ||||
| use Symfony\Component\Form\FormInterface; | ||||
| use function array_key_exists; | ||||
| use function count; | ||||
|  | ||||
| class ExportPickCenterDataMapper implements DataMapperInterface | ||||
| { | ||||
|     protected RegroupmentRepository $regroupmentRepository; | ||||
|  | ||||
|     /** | ||||
|      * @param array|Center[] $data | ||||
|      * @param $forms | ||||
|      * | ||||
|      * @throws Exception | ||||
|      * | ||||
|      * @return void | ||||
|      */ | ||||
|     public function mapDataToForms($data, $forms) | ||||
|     { | ||||
|         if (null === $data) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         /** @var array<string, FormInterface> $form */ | ||||
|         $form = iterator_to_array($forms); | ||||
|  | ||||
|         $pickedRegroupment = []; | ||||
|  | ||||
|         foreach ($this->regroupmentRepository->findAll() as $regroupment) { | ||||
|             [$contained, $notContained] = $regroupment->getCenters()->partition(static function (Center $center) { | ||||
|             }); | ||||
|  | ||||
|             if (0 === count($notContained)) { | ||||
|                 $pickedRegroupment[] = $regroupment; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $form['regroupment']->setData($pickedRegroupment); | ||||
|         $form['centers']->setData($data); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param iterable $forms | ||||
|      * @param array $data | ||||
|      * | ||||
|      * @return void | ||||
|      */ | ||||
|     public function mapFormsToData($forms, &$data) | ||||
|     { | ||||
|         /** @var array<string, FormInterface> $forms */ | ||||
|         $forms = iterator_to_array($forms); | ||||
|  | ||||
|         $centers = []; | ||||
|  | ||||
|         foreach ($forms['center']->getData() as $center) { | ||||
|             $centers[spl_object_hash($center)] = $center; | ||||
|         } | ||||
|  | ||||
|         if (array_key_exists('regroupment', $forms)) { | ||||
|             foreach ($forms['regroupment']->getData() as $regroupment) { | ||||
|                 /** @var Regroupment $regroupment */ | ||||
|                 foreach ($regroupment->getCenters() as $center) { | ||||
|                     $centers[spl_object_hash($center)] = $center; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $data = array_values($centers); | ||||
|     } | ||||
| } | ||||
| @@ -11,57 +11,46 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Form\Type\Export; | ||||
|  | ||||
| use Chill\MainBundle\Center\GroupingCenterInterface; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Regroupment; | ||||
| use Chill\MainBundle\Export\ExportManager; | ||||
| use Chill\MainBundle\Form\DataMapper\ExportPickCenterDataMapper; | ||||
| use Chill\MainBundle\Repository\RegroupmentRepository; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; | ||||
| use Symfony\Bridge\Doctrine\Form\Type\EntityType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\CallbackTransformer; | ||||
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | ||||
|  | ||||
| use Symfony\Component\Security\Core\User\UserInterface; | ||||
| use function array_intersect; | ||||
| use function array_key_exists; | ||||
| use function array_merge; | ||||
| use function array_unique; | ||||
| use function count; | ||||
| use function in_array; | ||||
|  | ||||
| /** | ||||
|  * Pick centers amongst available centers for the user. | ||||
|  */ | ||||
| class PickCenterType extends AbstractType | ||||
| final class PickCenterType extends AbstractType | ||||
| { | ||||
|     public const CENTERS_IDENTIFIERS = 'c'; | ||||
|  | ||||
|     protected AuthorizationHelperInterface $authorizationHelper; | ||||
|     private AuthorizationHelperInterface $authorizationHelper; | ||||
|  | ||||
|     protected ExportManager $exportManager; | ||||
|     private ExportManager $exportManager; | ||||
|  | ||||
|     /** | ||||
|      * @var array|GroupingCenterInterface[] | ||||
|      */ | ||||
|     protected array $groupingCenters = []; | ||||
|     private RegroupmentRepository $regroupmentRepository; | ||||
|  | ||||
|     protected UserInterface $user; | ||||
|     private UserInterface $user; | ||||
|  | ||||
|     public function __construct( | ||||
|         TokenStorageInterface $tokenStorage, | ||||
|         ExportManager $exportManager, | ||||
|         RegroupmentRepository $regroupmentRepository, | ||||
|         AuthorizationHelperInterface $authorizationHelper | ||||
|     ) { | ||||
|         $this->exportManager = $exportManager; | ||||
|         $this->user = $tokenStorage->getToken()->getUser(); | ||||
|         $this->authorizationHelper = $authorizationHelper; | ||||
|     } | ||||
|  | ||||
|     public function addGroupingCenter(GroupingCenterInterface $grouping) | ||||
|     { | ||||
|         $this->groupingCenters[md5($grouping->getName())] = $grouping; | ||||
|         $this->regroupmentRepository = $regroupmentRepository; | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
| @@ -72,97 +61,36 @@ class PickCenterType extends AbstractType | ||||
|             $export->requiredRole() | ||||
|         ); | ||||
|  | ||||
|         $builder->add(self::CENTERS_IDENTIFIERS, EntityType::class, [ | ||||
|         $builder->add('center', EntityType::class, [ | ||||
|             'class' => Center::class, | ||||
|             'label' => 'center', | ||||
|             'choices' => $centers, | ||||
|             'multiple' => true, | ||||
|             'expanded' => true, | ||||
|             'choice_label' => static function (Center $c) { | ||||
|                 return $c->getName(); | ||||
|             }, | ||||
|             'data' => count($this->groupingCenters) > 0 ? null : $centers, | ||||
|             'data' => $centers, | ||||
|         ]); | ||||
|  | ||||
|         if (count($this->groupingCenters) > 0) { | ||||
|             $groupingBuilder = $builder->create('g', null, [ | ||||
|                 'compound' => true, | ||||
|         if (count($this->regroupmentRepository->findAllActive()) > 0) { | ||||
|             $builder->add('regroupment', EntityType::class, [ | ||||
|                 'class' => Regroupment::class, | ||||
|                 'label' => 'regroupment', | ||||
|                 'multiple' => true, | ||||
|                 'expanded' => true, | ||||
|                 'choices' => $this->regroupmentRepository->findAllActive(), | ||||
|                 'choice_label' => static function (Regroupment $r) { | ||||
|                     return $r->getName(); | ||||
|                 }, | ||||
|             ]); | ||||
|  | ||||
|             foreach ($this->groupingCenters as $key => $gc) { | ||||
|                 $choices = $this->buildChoices($centers, $gc); | ||||
|  | ||||
|                 if (count($choices) > 0) { | ||||
|                     $groupingBuilder->add($key, ChoiceType::class, [ | ||||
|                         'choices' => $choices, | ||||
|                         'multiple' => true, | ||||
|                         'expanded' => true, | ||||
|                         'label' => $gc->getName(), | ||||
|                         'required' => false, | ||||
|                     ]); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if ($groupingBuilder->count() > 0) { | ||||
|                 $builder->add($groupingBuilder); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $builder->addModelTransformer(new CallbackTransformer( | ||||
|             function ($data) use ($centers) { | ||||
|                 return $this->transform($data, $centers); | ||||
|             }, | ||||
|             function ($data) use ($centers) { | ||||
|                 return $this->reverseTransform($data, $centers); | ||||
|             } | ||||
|         )); | ||||
|         $builder->setDataMapper(new ExportPickCenterDataMapper()); | ||||
|     } | ||||
|  | ||||
|     public function configureOptions(OptionsResolver $resolver) | ||||
|     { | ||||
|         $resolver->setRequired('export_alias'); | ||||
|     } | ||||
|  | ||||
|     protected function buildChoices($reachablesCenters, GroupingCenterInterface $gc) | ||||
|     { | ||||
|         $result = []; | ||||
|  | ||||
|         foreach ($gc->getGroups() as $group) { | ||||
|             foreach ($gc->getCentersForGroup($group) as $center) { | ||||
|                 if (in_array($center, $reachablesCenters, true)) { | ||||
|                     $result[$group] = $group; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $result; | ||||
|     } | ||||
|  | ||||
|     protected function reverseTransform($data, $centers) | ||||
|     { | ||||
|         $picked = $data[self::CENTERS_IDENTIFIERS] | ||||
|             instanceof \Doctrine\Common\Collections\Collection ? | ||||
|                 $data[self::CENTERS_IDENTIFIERS]->toArray() | ||||
|                 : | ||||
|                 $data[self::CENTERS_IDENTIFIERS]; | ||||
|  | ||||
|         if (array_key_exists('g', $data)) { | ||||
|             foreach ($data['g'] as $gcid => $group) { | ||||
|                 $picked = | ||||
|                     array_merge( | ||||
|                         array_intersect( | ||||
|                             $this->groupingCenters[$gcid]->getCentersForGroup($group), | ||||
|                             $centers | ||||
|                         ), | ||||
|                         $picked | ||||
|                     ); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return array_unique($picked); | ||||
|     } | ||||
|  | ||||
|     protected function transform($data, $centers) | ||||
|     { | ||||
|         return $data; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Location; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Chill\MainBundle\Form\Type\PickCivilityType; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelper; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| @@ -110,6 +111,11 @@ class UserType extends AbstractType | ||||
|  | ||||
|                     return $qb; | ||||
|                 }, | ||||
|             ]) | ||||
|             ->add('absenceStart', ChillDateType::class, [ | ||||
|                 'required' => false, | ||||
|                 'input' => 'datetime_immutable', | ||||
|                 'label' => 'absence.Absence start', | ||||
|             ]); | ||||
|  | ||||
|         // @phpstan-ignore-next-line | ||||
|   | ||||
| @@ -155,6 +155,12 @@ class WorkflowStepType extends AbstractType | ||||
|                     'multiple' => true, | ||||
|                     'mapped' => false, | ||||
|                 ]) | ||||
|                 ->add('future_cc_users', PickUserDynamicType::class, [ | ||||
|                     'label' => 'workflow.cc for next steps', | ||||
|                     'multiple' => true, | ||||
|                     'mapped' => false, | ||||
|                     'required' => false, | ||||
|                 ]) | ||||
|                 ->add('future_dest_emails', ChillCollectionType::class, [ | ||||
|                     'label' => 'workflow.dest by email', | ||||
|                     'help' => 'workflow.dest by email help', | ||||
|   | ||||
| @@ -74,7 +74,9 @@ class NotificationTwigExtensionRuntime implements RuntimeExtensionInterface | ||||
|         } | ||||
|  | ||||
|         return $environment->render('@ChillMain/Notification/extension_list_notifications_for.html.twig', [ | ||||
|             'notifications' => $notifications, 'appendCommentForms' => $appendCommentForms, | ||||
|             'notifications' => $notifications, | ||||
|             'appendCommentForms' => $appendCommentForms, | ||||
|             'notificationCc' => false, | ||||
|         ]); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -118,7 +118,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|      * Return true if the phonenumber is a landline or voip phone.  Return always true | ||||
|      * if the validation is not configured. | ||||
|      * | ||||
|      * @param string $phonenumber | ||||
|      * @param string|PhoneNumber $phonenumber | ||||
|      */ | ||||
|     public function isValidPhonenumberAny($phonenumber): bool | ||||
|     { | ||||
| @@ -138,7 +138,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|      * Return true if the phonenumber is a landline or voip phone.  Return always true | ||||
|      * if the validation is not configured. | ||||
|      * | ||||
|      * @param string $phonenumber | ||||
|      * @param string|PhoneNumber $phonenumber | ||||
|      */ | ||||
|     public function isValidPhonenumberLandOrVoip($phonenumber): bool | ||||
|     { | ||||
| @@ -159,7 +159,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|      * REturn true if the phonenumber is a mobile phone. Return always true | ||||
|      * if the validation is not configured. | ||||
|      * | ||||
|      * @param string $phonenumber | ||||
|      * @param string|PhoneNumber $phonenumber | ||||
|      */ | ||||
|     public function isValidPhonenumberMobile($phonenumber): bool | ||||
|     { | ||||
| @@ -182,6 +182,10 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if ($phonenumber instanceof PhoneNumber) { | ||||
|             $phonenumber = (string) $phonenumber; | ||||
|         } | ||||
|  | ||||
|         // filter only number | ||||
|         $filtered = preg_replace('/[^0-9]/', '', $phonenumber); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,66 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Regroupment; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| final class RegroupmentRepository implements ObjectRepository | ||||
| { | ||||
|     private EntityRepository $repository; | ||||
|  | ||||
|     public function __construct(EntityManagerInterface $entityManager) | ||||
|     { | ||||
|         $this->repository = $entityManager->getRepository(Regroupment::class); | ||||
|     } | ||||
|  | ||||
|     public function find($id, $lockMode = null, $lockVersion = null): ?Regroupment | ||||
|     { | ||||
|         return $this->repository->find($id, $lockMode, $lockVersion); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return Regroupment[] | ||||
|      */ | ||||
|     public function findAll(): array | ||||
|     { | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findAllActive(): array | ||||
|     { | ||||
|         return $this->repository->findBy(['isActive' => true], ['name' => 'ASC']); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param mixed|null $limit | ||||
|      * @param mixed|null $offset | ||||
|      * | ||||
|      * @return Regroupment[] | ||||
|      */ | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     { | ||||
|         return $this->repository->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
|  | ||||
|     public function findOneBy(array $criteria, ?array $orderBy = null): ?Regroupment | ||||
|     { | ||||
|         return $this->repository->findOneBy($criteria, $orderBy); | ||||
|     } | ||||
|  | ||||
|     public function getClassName() | ||||
|     { | ||||
|         return Regroupment::class; | ||||
|     } | ||||
| } | ||||
| @@ -27,6 +27,13 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         $this->repository = $entityManager->getRepository(EntityWorkflow::class); | ||||
|     } | ||||
|  | ||||
|     public function countByCc(User $user): int | ||||
|     { | ||||
|         $qb = $this->buildQueryByCc($user)->select('count(ew)'); | ||||
|  | ||||
|         return (int) $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function countByDest(User $user): int | ||||
|     { | ||||
|         $qb = $this->buildQueryByDest($user)->select('count(ew)'); | ||||
| @@ -103,6 +110,19 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return $this->repository->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
|  | ||||
|     public function findByCc(User $user, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     { | ||||
|         $qb = $this->buildQueryByCc($user)->select('ew'); | ||||
|  | ||||
|         foreach ($orderBy as $key => $sort) { | ||||
|             $qb->addOrderBy('ew.' . $key, $sort); | ||||
|         } | ||||
|  | ||||
|         $qb->setMaxResults($limit)->setFirstResult($offset); | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     public function findByDest(User $user, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     { | ||||
|         $qb = $this->buildQueryByDest($user)->select('ew'); | ||||
| @@ -165,6 +185,25 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return EntityWorkflow::class; | ||||
|     } | ||||
|  | ||||
|     private function buildQueryByCc(User $user): QueryBuilder | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('ew'); | ||||
|  | ||||
|         $qb->join('ew.steps', 'step'); | ||||
|  | ||||
|         $qb->where( | ||||
|             $qb->expr()->andX( | ||||
|                 $qb->expr()->isMemberOf(':user', 'step.ccUser'), | ||||
|                 $qb->expr()->isNull('step.transitionAfter'), | ||||
|                 $qb->expr()->eq('step.isFinal', "'FALSE'") | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|         $qb->setParameter('user', $user); | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     private function buildQueryByDest(User $user): QueryBuilder | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('ew'); | ||||
|   | ||||
| @@ -35,6 +35,7 @@ export interface User { | ||||
|   id: number; | ||||
|   username: string; | ||||
|   text: string; | ||||
|   text_without_absence: string; | ||||
|   email: string; | ||||
|   user_job: Job; | ||||
|   label: string; | ||||
|   | ||||
| @@ -1,88 +1,25 @@ | ||||
| <template> | ||||
|     <div class="alert alert-light">{{ $t('my_workflows.description') }}</div> | ||||
|     <my-workflows-table :workflows="workflows" /> | ||||
|  | ||||
| <div class="alert alert-light">{{ $t('my_workflows.description') }}</div> | ||||
| <span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span> | ||||
| <tab-table v-else> | ||||
|     <template v-slot:thead> | ||||
|         <th scope="col">{{ $t('Object_workflow') }}</th> | ||||
|         <th scope="col">{{ $t('Step') }}</th> | ||||
|         <th scope="col">{{ $t('concerned_users') }}</th> | ||||
|         <th scope="col"></th> | ||||
|     </template> | ||||
|     <template v-slot:tbody> | ||||
|         <tr v-for="(w, i) in workflows.results" :key="`workflow-${i}`"> | ||||
|             <td>{{ w.title }}</td> | ||||
|             <td> | ||||
|                 <div class="workflow"> | ||||
|                     <div class="breadcrumb"> | ||||
|                         <i class="fa fa-circle me-1 text-chill-yellow mx-2"></i> | ||||
|                         <span class="mx-2">{{ getStep(w) }}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </td> | ||||
|             <td v-if="w.datas.persons !== null"> | ||||
|                 <span v-for="p in w.datas.persons" class="me-1" :key="p.id"> | ||||
|                     <on-the-fly | ||||
|                         :type="p.type" | ||||
|                         :id="p.id" | ||||
|                         :buttonText="p.textAge" | ||||
|                         :displayBadge="'true' === 'true'" | ||||
|                         action="show"> | ||||
|                     </on-the-fly> | ||||
|                 </span> | ||||
|             </td> | ||||
|             <td> | ||||
|             <a class="btn btn-sm btn-show" :href="getUrl(w)"> | ||||
|                 {{ $t('show_entity', { entity: $t('the_workflow') }) }} | ||||
|             </a> | ||||
|             </td> | ||||
|         </tr> | ||||
|     </template> | ||||
| </tab-table> | ||||
|  | ||||
|     <div class="alert alert-light">{{ $t('my_workflows.description_cc') }}</div> | ||||
|     <my-workflows-table :workflows="workflowsCc" /> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapState, mapGetters } from "vuex"; | ||||
| import TabTable from "./TabTable"; | ||||
| import OnTheFly from 'ChillMainAssets/vuejs/OnTheFly/components/OnTheFly'; | ||||
| import { mapState } from "vuex"; | ||||
| import MyWorkflowsTable from './MyWorkflowsTable.vue'; | ||||
|  | ||||
| export default { | ||||
|     name: "MyWorkflows", | ||||
|     components: { | ||||
|         TabTable, | ||||
|         OnTheFly | ||||
|         MyWorkflowsTable | ||||
|     }, | ||||
|     computed: { | ||||
|         ...mapState([ | ||||
|             'workflows', | ||||
|             'workflowsCc', | ||||
|         ]), | ||||
|         ...mapGetters([ | ||||
|             'isWorkflowsLoaded', | ||||
|         ]), | ||||
|         noResults() { | ||||
|             if (!this.isWorkflowsLoaded) { | ||||
|                 return false; | ||||
|             } else { | ||||
|                 return this.workflows.count === 0; | ||||
|             } | ||||
|         }, | ||||
|     }, | ||||
|     methods: { | ||||
|         getUrl(w) { | ||||
|             return `/fr/main/workflow/${w.id}/show`; | ||||
|         }, | ||||
|         getStep(w) { | ||||
|             const lastStep = w.steps.length - 1 | ||||
|             return w.steps[lastStep].currentStep.text; | ||||
|         } | ||||
|     }, | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| span.outdated { | ||||
|     font-weight: bold; | ||||
|     color: var(--bs-warning); | ||||
| } | ||||
| </style> | ||||
| </script> | ||||
| @@ -0,0 +1,83 @@ | ||||
| <template> | ||||
|     <span v-if="hasNoResults(workflows)" class="chill-no-data-statement">{{ $t('no_data') }}</span> | ||||
|     <tab-table v-else> | ||||
|         <template v-slot:thead> | ||||
|             <th scope="col">{{ $t('Object_workflow') }}</th> | ||||
|             <th scope="col">{{ $t('Step') }}</th> | ||||
|             <th scope="col">{{ $t('concerned_users') }}</th> | ||||
|             <th scope="col"></th> | ||||
|         </template> | ||||
|         <template v-slot:tbody> | ||||
|             <tr v-for="(w, i) in workflows.results" :key="`workflow-${i}`"> | ||||
|                 <td>{{ w.title }}</td> | ||||
|                 <td> | ||||
|                     <div class="workflow"> | ||||
|                         <div class="breadcrumb"> | ||||
|                             <i class="fa fa-circle me-1 text-chill-yellow mx-2"></i> | ||||
|                             <span class="mx-2">{{ getStep(w) }}</span> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </td> | ||||
|                 <td v-if="w.datas.persons !== null"> | ||||
|                     <span v-for="p in w.datas.persons" class="me-1" :key="p.id"> | ||||
|                         <on-the-fly | ||||
|                             :type="p.type" | ||||
|                             :id="p.id" | ||||
|                             :buttonText="p.textAge" | ||||
|                             :displayBadge="'true' === 'true'" | ||||
|                             action="show"> | ||||
|                         </on-the-fly> | ||||
|                     </span> | ||||
|                 </td> | ||||
|                 <td> | ||||
|                 <a class="btn btn-sm btn-show" :href="getUrl(w)"> | ||||
|                     {{ $t('show_entity', { entity: $t('the_workflow') }) }} | ||||
|                 </a> | ||||
|                 </td> | ||||
|             </tr> | ||||
|         </template> | ||||
|     </tab-table> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapGetters } from "vuex"; | ||||
| import TabTable from "./TabTable"; | ||||
| import OnTheFly from 'ChillMainAssets/vuejs/OnTheFly/components/OnTheFly'; | ||||
|  | ||||
| export default { | ||||
|     name: "MyWorkflows", | ||||
|     components: { | ||||
|         TabTable, | ||||
|         OnTheFly | ||||
|     }, | ||||
|     props: ['workflows'], | ||||
|     computed: { | ||||
|         ...mapGetters([ | ||||
|             'isWorkflowsLoaded', | ||||
|         ]), | ||||
|     }, | ||||
|     methods: { | ||||
|         hasNoResults(workflows) { | ||||
|             if (!this.isWorkflowsLoaded) { | ||||
|                 return false; | ||||
|             } else { | ||||
|                 return workflows.count === 0; | ||||
|             } | ||||
|         }, | ||||
|         getUrl(w) { | ||||
|             return `/fr/main/workflow/${w.id}/show`; | ||||
|         }, | ||||
|         getStep(w) { | ||||
|             const lastStep = w.steps.length - 1 | ||||
|             return w.steps[lastStep].currentStep.text; | ||||
|         } | ||||
|     }, | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| span.outdated { | ||||
|     font-weight: bold; | ||||
|     color: var(--bs-warning); | ||||
| } | ||||
| </style> | ||||
| @@ -24,7 +24,8 @@ const appMessages = { | ||||
|         }, | ||||
|         my_workflows: { | ||||
|             tab: "Mes workflows", | ||||
|             description: "Liste des workflows en attente d'une action." | ||||
|             description: "Liste des workflows en attente d'une action.", | ||||
|             description_cc: "Liste des workflows dont je suis en copie." | ||||
|         }, | ||||
|         opening_date: "Date d'ouverture", | ||||
|         social_issues: "Problématiques sociales", | ||||
|   | ||||
| @@ -22,6 +22,7 @@ const store = createStore({ | ||||
|         accompanyingCourses: {}, | ||||
|         notifications: {}, | ||||
|         workflows: {}, | ||||
|         workflowsCc: {}, | ||||
|         errorMsg: [], | ||||
|         loading: false | ||||
|     }, | ||||
| @@ -87,6 +88,9 @@ const store = createStore({ | ||||
|         addWorkflows(state, workflows) { | ||||
|             state.workflows = workflows; | ||||
|         }, | ||||
|         addWorkflowsCc(state, workflows) { | ||||
|             state.workflowsCc = workflows; | ||||
|         }, | ||||
|         setLoading(state, bool) { | ||||
|             state.loading = bool; | ||||
|         }, | ||||
| @@ -195,17 +199,23 @@ const store = createStore({ | ||||
|                 case 'MyWorkflows': | ||||
|                     if (!getters.isWorflowsLoaded) { | ||||
|                         commit('setLoading', true); | ||||
|                         const url = '/api/1.0/main/workflow/my'; | ||||
|                         makeFetch('GET', url) | ||||
|                         .then((response) => { | ||||
|                             console.log('workflows', response) | ||||
|                             commit('addWorkflows', response); | ||||
|                             commit('setLoading', false); | ||||
|                         }) | ||||
|                         .catch((error) => { | ||||
|                             commit('catchError', error); | ||||
|                             throw error; | ||||
|                         }); | ||||
|                         makeFetch('GET', '/api/1.0/main/workflow/my') | ||||
|                             .then((response) => { | ||||
|                                 commit('addWorkflows', response); | ||||
|                                 makeFetch('GET', '/api/1.0/main/workflow/my-cc') | ||||
|                                     .then((response) => { | ||||
|                                         commit('addWorkflowsCc', response); | ||||
|                                         commit('setLoading', false); | ||||
|                                     }) | ||||
|                                     .catch((error) => { | ||||
|                                         commit('catchError', error); | ||||
|                                         throw error; | ||||
|                                     }); | ||||
|                             }) | ||||
|                             .catch((error) => { | ||||
|                                 commit('catchError', error); | ||||
|                                 throw error; | ||||
|                             }); | ||||
|                     } | ||||
|                     break; | ||||
|                 default: | ||||
|   | ||||
| @@ -233,7 +233,7 @@ export default { | ||||
|                      // console.log('data original', data); | ||||
|                      data.parent = {type: "thirdparty", id: this.parent.id}; | ||||
|                      data.civility = data.civility !== null ? {type: 'chill_main_civility', id: data.civility.id} : null; | ||||
|                      data.profession = data.profession !== null ? {type: 'third_party_profession', id:  data.profession.id} : null; | ||||
|                      data.profession = data.profession !== '' ? data.profession : ''; | ||||
|                   } else { | ||||
|                      type = this.$refs.castNew.radioType; | ||||
|                      data = this.$refs.castNew.castDataByType(); | ||||
| @@ -241,8 +241,8 @@ export default { | ||||
|                      if (typeof data.civility !== 'undefined' && null !== data.civility) { | ||||
|                         data.civility = data.civility !== null ? {type: 'chill_main_civility', id: data.civility.id} : null; | ||||
|                      } | ||||
|                      if (typeof data.profession !== 'undefined' && null !== data.profession) { | ||||
|                         data.profession = data.profession !== null ? {type: 'third_party_profession', id: data.profession.id} : null; | ||||
|                      if (typeof data.profession !== 'undefined' && '' !== data.profession) { | ||||
|                         data.profession = data.profession !== '' ? data.profession : ''; | ||||
|                      } | ||||
|                      // console.log('onthefly data', data); | ||||
|                   } | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| <template> | ||||
| <span class="chill-entity entity-user"> | ||||
|     {{ user.label }} | ||||
|     <span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> | ||||
|     <span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span> | ||||
|     <span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> <span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span> <span v-if="user.isAbsent" class="badge bg-danger rounded-pill" :title="Absent">A</span> | ||||
| </span> | ||||
| </template> | ||||
|  | ||||
|   | ||||
| @@ -6,4 +6,7 @@ | ||||
|     {%- if opts['main_scope'] and user.mainScope is not null %} | ||||
|     <span class="main-scope">({{ user.mainScope.name|localize_translatable_string }})</span> | ||||
|     {%- endif -%} | ||||
|     {%- if opts['absence'] and user.isAbsent %} | ||||
|         <span class="badge bg-danger rounded-pill" title="{{ 'absence.Absent'|trans|escape('html_attr') }}">{{ 'absence.A'|trans }}</span> | ||||
|     {%- endif -%} | ||||
| </span> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| {# | ||||
|  * Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,  | ||||
|  * Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS, | ||||
|  <info@champs-libres.coop> / <http://www.champs-libres.coop> | ||||
|  * | ||||
|  * This program is free software: you can redistribute it and/or modify | ||||
| @@ -22,39 +22,34 @@ | ||||
|  | ||||
| {% block content %} | ||||
|     <div class="col-md-10"> | ||||
|      | ||||
|  | ||||
|         {{ include('@ChillMain/Export/_breadcrumb.html.twig') }} | ||||
|          | ||||
|  | ||||
|         <h1>{{ export.title|trans }}</h1> | ||||
|          | ||||
|  | ||||
|         <p>{{ export.description|trans }}</p> | ||||
|      | ||||
|  | ||||
|         {{ form_start(form) }} | ||||
|          | ||||
|  | ||||
|         <section class="center mb-4"> | ||||
|              | ||||
|  | ||||
|             <h2>{{ 'Pick centers'|trans }}</h2> | ||||
|              | ||||
|  | ||||
|             <p>{{ 'The export will contains only data from the picked centers.'|trans }} | ||||
|                 {{ 'This will eventually restrict your possibilities in filtering the data.'|trans }}</p> | ||||
|          | ||||
|             {{ form_widget(form.centers.c) }} | ||||
|              | ||||
|             {% if form.centers.children.g is defined %} | ||||
|                  | ||||
|                 <h3>{{ 'Pick aggregated centers'|trans }}</h3> | ||||
|                  | ||||
|                 {% for f in form.centers.children.g.children %} | ||||
|                     {{ form_row(f) }} | ||||
|                 {% endfor %} | ||||
|                  | ||||
|  | ||||
|             <h3 class="m-3">{{ 'Center'|trans }}</h3> | ||||
|             {{ form_widget(form.centers.center) }} | ||||
|  | ||||
|             {% if form.centers.regroupment is defined %} | ||||
|                 <h3 class="m-3">{{ 'Pick aggregated centers'|trans }}</h3> | ||||
|                 {{ form_widget(form.centers.regroupment) }} | ||||
|             {% endif %} | ||||
|              | ||||
|         </section> | ||||
|          | ||||
|  | ||||
|         <p>{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-action btn-create' }, 'label' : 'Go to export options' } ) }}</p> | ||||
|          | ||||
|  | ||||
|         {{ form_end(form) }} | ||||
|      | ||||
|  | ||||
|     </div> | ||||
| {% endblock content %} | ||||
|   | ||||
| @@ -1,15 +1,15 @@ | ||||
| <div class="col-10 mt-5"> | ||||
|      | ||||
|     {# vue component #} | ||||
|     <div id="homepage_widget"></div> | ||||
|      | ||||
|  | ||||
|     {% include '@ChillMain/Homepage/fast_actions.html.twig' %} | ||||
| </div> | ||||
|  | ||||
|  | ||||
| {% block css %} | ||||
|     {{ encore_entry_link_tags('page_homepage_widget') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ encore_entry_script_tags('page_homepage_widget') }} | ||||
| {% endblock %} | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -0,0 +1,43 @@ | ||||
| {% extends '@ChillMain/Admin/layout.html.twig' %} | ||||
|  | ||||
| {% block title %} | ||||
|     {{ 'absence.My absence'|trans }} | ||||
| {% endblock title %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
|     <div class="col-md-10"> | ||||
|         <h2>{{ 'absence.My absence'|trans }}</h2> | ||||
|  | ||||
|         {% if user.absenceStart is not null %} | ||||
|             <div> | ||||
|                 <p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p> | ||||
|                 <ul class="record_actions sticky-form-buttons"> | ||||
|                     <li> | ||||
|                         <a href="{{ path('chill_main_user_absence_unset') }}" | ||||
|                            class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a> | ||||
|                     </li> | ||||
|                 </ul> | ||||
|             </div> | ||||
|         {% else %} | ||||
|             <div> | ||||
|                 <p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p> | ||||
|             </div> | ||||
|             <div> | ||||
|                 {{ form_start(form) }} | ||||
|                 {{ form_row(form.absenceStart) }} | ||||
|  | ||||
|                 <ul class="record_actions sticky-form-buttons"> | ||||
|                     <li> | ||||
|                         <button class="btn btn-save" type="submit"> | ||||
|                             {{ 'Save'|trans }} | ||||
|                         </button> | ||||
|                     </li> | ||||
|                 </ul> | ||||
|  | ||||
|                 {{ form_end(form) }} | ||||
|             </div> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|  | ||||
| {% endblock %} | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user