diff --git a/src/Bundle/ChillMainBundle/Controller/UserGroupAdminController.php b/src/Bundle/ChillMainBundle/Controller/UserGroupAdminController.php new file mode 100644 index 000000000..04a9a9910 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/UserGroupAdminController.php @@ -0,0 +1,28 @@ +addSelect('JSON_EXTRACT(e.label, :lang) AS HIDDEN labeli18n') + ->setParameter('lang', $request->getLocale()); + $query->addOrderBy('labeli18n', 'ASC'); + + return $query; + } +} diff --git a/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php b/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php new file mode 100644 index 000000000..602b84ec5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php @@ -0,0 +1,16 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException(); + } + + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedHttpException(); + } + + $nb = $this->userGroupRepository->countByUser($user); + $paginator = $this->paginatorFactory->create($nb); + + $groups = $this->userGroupRepository->findByUser($user, true, $paginator->getItemsPerPage(), $paginator->getCurrentPageFirstItemNumber()); + $forms = new \SplObjectStorage(); + + foreach ($groups as $group) { + $forms->attach($group, $this->createFormAppendUserForGroup($group)?->createView()); + } + + return new Response($this->twig->render('@ChillMain/UserGroup/my_user_groups.html.twig', [ + 'groups' => $groups, + 'paginator' => $paginator, + 'forms' => $forms, + ])); + } + + #[Route('/{_locale}/main/user-groups/{id}/append', name: 'chill_main_user_groups_append_users')] + public function appendUsersToGroup(UserGroup $userGroup, Request $request, Session $session): Response + { + if (!$this->security->isGranted(UserGroupVoter::APPEND_TO_GROUP, $userGroup)) { + throw new AccessDeniedHttpException(); + } + + $form = $this->createFormAppendUserForGroup($userGroup); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + foreach ($form['users']->getData() as $user) { + $userGroup->addUser($user); + + $session->getFlashBag()->add( + 'success', + new TranslatableMessage( + 'user_group.user_added', + [ + 'user_group' => $this->chillEntityRenderManager->renderString($userGroup, []), + 'user' => $this->chillEntityRenderManager->renderString($user, []), + ] + ) + ); + } + + $this->objectManager->flush(); + + return new RedirectResponse( + $this->chillUrlGenerator->returnPathOr('chill_main_user_groups_my') + ); + } + if ($form->isSubmitted()) { + $errors = []; + foreach ($form->getErrors() as $error) { + $errors[] = $error->getMessage(); + } + + return new Response(implode(', ', $errors)); + } + + return new RedirectResponse( + $this->chillUrlGenerator->returnPathOr('chill_main_user_groups_my') + ); + } + + /** + * @ParamConverter("user", class=User::class, options={"id" = "userId"}) + */ + #[Route('/{_locale}/main/user-group/{id}/user/{userId}/remove', name: 'chill_main_user_groups_remove_user')] + public function removeUserToGroup(UserGroup $userGroup, User $user, Session $session): Response + { + if (!$this->security->isGranted(UserGroupVoter::APPEND_TO_GROUP, $userGroup)) { + throw new AccessDeniedHttpException(); + } + + $userGroup->removeUser($user); + $this->objectManager->flush(); + + $session->getFlashBag()->add( + 'success', + new TranslatableMessage( + 'user_group.user_removed', + [ + 'user_group' => $this->chillEntityRenderManager->renderString($userGroup, []), + 'user' => $this->chillEntityRenderManager->renderString($user, []), + ] + ) + ); + + return new RedirectResponse( + $this->chillUrlGenerator->returnPathOr('chill_main_user_groups_my') + ); + } + + private function createFormAppendUserForGroup(UserGroup $group): ?FormInterface + { + if (!$this->security->isGranted(UserGroupVoter::APPEND_TO_GROUP, $group)) { + return null; + } + + $builder = $this->formFactory->createBuilder(FormType::class, ['users' => []], [ + 'action' => $this->chillUrlGenerator->generateWithReturnPath('chill_main_user_groups_append_users', ['id' => $group->getId()]), + ]); + $builder->add('users', PickUserDynamicType::class, [ + 'submit_on_adding_new_entity' => true, + 'label' => 'user_group.append_users', + 'mapped' => false, + 'multiple' => true, + ]); + + return $builder->getForm(); + } +} diff --git a/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadUserGroup.php b/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadUserGroup.php new file mode 100644 index 000000000..614a83515 --- /dev/null +++ b/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadUserGroup.php @@ -0,0 +1,68 @@ +getRepository(User::class)->findOneBy(['username' => 'center a_social']); + $centerBSocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_social']); + $multiCenter = $manager->getRepository(User::class)->findOneBy(['username' => 'multi_center']); + $administrativeA = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_administrative']); + $administrativeB = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_administrative']); + + $level1 = $this->generateLevelGroup('Niveau 1', '#eec84aff', '#000000ff', 'level'); + $level1->addUser($centerASocial)->addUser($centerBSocial); + $manager->persist($level1); + + $level2 = $this->generateLevelGroup('Niveau 2', ' #e2793dff', '#000000ff', 'level'); + $level2->addUser($multiCenter); + $manager->persist($level2); + + $level3 = $this->generateLevelGroup('Niveau 3', ' #df4949ff', '#000000ff', 'level'); + $level3->addUser($multiCenter); + $manager->persist($level3); + + $tss = $this->generateLevelGroup('Travailleur sociaux', '#43b29dff', '#000000ff', ''); + $tss->addUser($multiCenter)->addUser($centerASocial)->addUser($centerBSocial); + $manager->persist($tss); + $admins = $this->generateLevelGroup('Administratif', '#334d5cff', '#000000ff', ''); + $admins->addUser($administrativeA)->addUser($administrativeB); + $manager->persist($admins); + + $manager->flush(); + } + + private function generateLevelGroup(string $title, string $backgroundColor, string $foregroundColor, string $excludeKey): UserGroup + { + $userGroup = new UserGroup(); + + return $userGroup + ->setLabel(['fr' => $title]) + ->setBackgroundColor($backgroundColor) + ->setForegroundColor($foregroundColor) + ->setExcludeKey($excludeKey) + ; + } +} diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index 9d788c64e..999277ed1 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -24,6 +24,8 @@ use Chill\MainBundle\Controller\LocationTypeController; use Chill\MainBundle\Controller\NewsItemController; use Chill\MainBundle\Controller\RegroupmentController; use Chill\MainBundle\Controller\UserController; +use Chill\MainBundle\Controller\UserGroupAdminController; +use Chill\MainBundle\Controller\UserGroupApiController; use Chill\MainBundle\Controller\UserJobApiController; use Chill\MainBundle\Controller\UserJobController; use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; @@ -59,6 +61,7 @@ use Chill\MainBundle\Entity\LocationType; use Chill\MainBundle\Entity\NewsItem; use Chill\MainBundle\Entity\Regroupment; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Form\CenterType; use Chill\MainBundle\Form\CivilityType; @@ -68,6 +71,7 @@ use Chill\MainBundle\Form\LocationFormType; use Chill\MainBundle\Form\LocationTypeType; use Chill\MainBundle\Form\NewsItemType; use Chill\MainBundle\Form\RegroupmentType; +use Chill\MainBundle\Form\UserGroupType; use Chill\MainBundle\Form\UserJobType; use Chill\MainBundle\Form\UserType; use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; @@ -353,6 +357,28 @@ class ChillMainExtension extends Extension implements { $container->prependExtensionConfig('chill_main', [ 'cruds' => [ + [ + 'class' => UserGroup::class, + 'controller' => UserGroupAdminController::class, + 'name' => 'admin_user_group', + 'base_path' => '/admin/main/user-group', + 'base_role' => 'ROLE_ADMIN', + 'form_class' => UserGroupType::class, + 'actions' => [ + 'index' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillMain/UserGroup/index.html.twig', + ], + 'new' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillMain/UserGroup/new.html.twig', + ], + 'edit' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillMain/UserGroup/edit.html.twig', + ], + ], + ], [ 'class' => UserJob::class, 'controller' => UserJobController::class, @@ -803,6 +829,21 @@ class ChillMainExtension extends Extension implements ], ], ], + [ + 'class' => UserGroup::class, + 'controller' => UserGroupApiController::class, + 'name' => 'user-group', + 'base_path' => '/api/1.0/main/user-group', + 'base_role' => 'ROLE_USER', + 'actions' => [ + '_index' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true, + ], + ], + ], + ], ], ]); } diff --git a/src/Bundle/ChillMainBundle/Entity/UserGroup.php b/src/Bundle/ChillMainBundle/Entity/UserGroup.php new file mode 100644 index 000000000..b39216b11 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/UserGroup.php @@ -0,0 +1,222 @@ + UserGroup::class])] +class UserGroup +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])] + private bool $active = true; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] + private array $label = []; + + /** + * @var Collection&Selectable + */ + #[ORM\ManyToMany(targetEntity: User::class)] + #[ORM\JoinTable(name: 'chill_main_user_group_user')] + private Collection&Selectable $users; + + /** + * @var Collection&Selectable + */ + #[ORM\ManyToMany(targetEntity: User::class)] + #[ORM\JoinTable(name: 'chill_main_user_group_user_admin')] + private Collection&Selectable $adminUsers; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#ffffffff'])] + private string $backgroundColor = '#ffffffff'; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#000000ff'])] + private string $foregroundColor = '#000000ff'; + + /** + * Groups with same exclude key are mutually exclusive: adding one in a many-to-one relationship + * will exclude others. + * + * An empty string means "no exclusion" + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + private string $excludeKey = ''; + + public function __construct() + { + $this->adminUsers = new ArrayCollection(); + $this->users = new ArrayCollection(); + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function addAdminUser(User $user): self + { + if (!$this->adminUsers->contains($user)) { + $this->adminUsers[] = $user; + } + + return $this; + } + + public function removeAdminUser(User $user): self + { + $this->adminUsers->removeElement($user); + + return $this; + } + + public function addUser(User $user): self + { + if (!$this->users->contains($user)) { + $this->users[] = $user; + } + + return $this; + } + + public function removeUser(User $user): self + { + if ($this->users->contains($user)) { + $this->users->removeElement($user); + } + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): array + { + return $this->label; + } + + /** + * @return Selectable&Collection + */ + public function getUsers(): Collection&Selectable + { + return $this->users; + } + + /** + * @return Selectable&Collection + */ + public function getAdminUsers(): Collection&Selectable + { + return $this->adminUsers; + } + + public function getForegroundColor(): string + { + return $this->foregroundColor; + } + + public function getExcludeKey(): string + { + return $this->excludeKey; + } + + public function getBackgroundColor(): string + { + return $this->backgroundColor; + } + + public function setForegroundColor(string $foregroundColor): self + { + $this->foregroundColor = $foregroundColor; + + return $this; + } + + public function setBackgroundColor(string $backgroundColor): self + { + $this->backgroundColor = $backgroundColor; + + return $this; + } + + public function setExcludeKey(string $excludeKey): self + { + $this->excludeKey = $excludeKey; + + return $this; + } + + public function setLabel(array $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Checks if the current object is an instance of the UserGroup class. + * + * In use in twig template, to discriminate when there an object can be polymorphic. + * + * @return bool returns true if the current object is an instance of UserGroup, false otherwise + */ + public function isUserGroup(): bool + { + return true; + } + + public function contains(User $user): bool + { + return $this->users->contains($user); + } + + public function getUserListByLabelAscending(): ReadableCollection + { + $criteria = Criteria::create(); + $criteria->orderBy(['label' => Order::Ascending]); + + return $this->getUsers()->matching($criteria); + } + + public function getAdminUserListByLabelAscending(): ReadableCollection + { + $criteria = Criteria::create(); + $criteria->orderBy(['label' => Order::Ascending]); + + return $this->getAdminUsers()->matching($criteria); + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php index 89efde7c0..f68dc7b7e 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php @@ -442,18 +442,18 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface $newStep->addCcUser($user); } - foreach ($transitionContextDTO->futureDestUsers as $user) { + foreach ($transitionContextDTO->getFutureDestUsers() as $user) { $newStep->addDestUser($user); } + foreach ($transitionContextDTO->getFutureDestUserGroups() as $userGroup) { + $newStep->addDestUserGroup($userGroup); + } + if (null !== $transitionContextDTO->futureUserSignature) { $newStep->addDestUser($transitionContextDTO->futureUserSignature); } - foreach ($transitionContextDTO->futureDestEmails as $email) { - $newStep->addDestEmail($email); - } - if (null !== $transitionContextDTO->futureUserSignature) { new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature); } else { diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index c6a849da5..3daacd49d 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Entity\Workflow; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -48,6 +49,13 @@ class EntityWorkflowStep #[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user')] private Collection $destUser; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: UserGroup::class)] + #[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user_group')] + private Collection $destUserGroups; + /** * @var Collection */ @@ -108,6 +116,7 @@ class EntityWorkflowStep { $this->ccUser = new ArrayCollection(); $this->destUser = new ArrayCollection(); + $this->destUserGroups = new ArrayCollection(); $this->destUserByAccessKey = new ArrayCollection(); $this->signatures = new ArrayCollection(); $this->holdsOnStep = new ArrayCollection(); @@ -123,6 +132,9 @@ class EntityWorkflowStep return $this; } + /** + * @deprecated + */ public function addDestEmail(string $email): self { if (!\in_array($email, $this->destEmail, true)) { @@ -141,6 +153,22 @@ class EntityWorkflowStep return $this; } + public function addDestUserGroup(UserGroup $userGroup): self + { + if (!$this->destUserGroups->contains($userGroup)) { + $this->destUserGroups[] = $userGroup; + } + + return $this; + } + + public function removeDestUserGroup(UserGroup $userGroup): self + { + $this->destUserGroups->removeElement($userGroup); + + return $this; + } + public function addDestUserByAccessKey(User $user): self { if (!$this->destUserByAccessKey->contains($user) && !$this->destUser->contains($user)) { @@ -178,7 +206,9 @@ class EntityWorkflowStep /** * get all the users which are allowed to apply a transition: those added manually, and - * those added automatically bu using an access key. + * those added automatically by using an access key. + * + * This method exclude the users associated with user groups * * @psalm-suppress DuplicateArrayKey */ @@ -192,6 +222,14 @@ class EntityWorkflowStep ); } + /** + * @return Collection + */ + public function getDestUserGroups(): Collection + { + return $this->destUserGroups; + } + public function getCcUser(): Collection { return $this->ccUser; @@ -207,6 +245,11 @@ class EntityWorkflowStep return $this->currentStep; } + /** + * @return array + * + * @deprecated + */ public function getDestEmail(): array { return $this->destEmail; diff --git a/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php b/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php index d193ea2ef..c6839efa8 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php +++ b/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php @@ -12,6 +12,8 @@ declare(strict_types=1); namespace Chill\MainBundle\Form\Type\DataTransformer; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer; use Chill\PersonBundle\Entity\Person; use Chill\ThirdPartyBundle\Entity\ThirdParty; use Symfony\Component\Form\DataTransformerInterface; @@ -74,15 +76,23 @@ class EntityToJsonTransformer implements DataTransformerInterface 'user' => User::class, 'person' => Person::class, 'thirdparty' => ThirdParty::class, + 'user_group' => UserGroup::class, + 'user_group_or_user' => DiscriminatedObjectDenormalizer::TYPE, default => throw new \UnexpectedValueException('This type is not supported'), }; + $context = [AbstractNormalizer::GROUPS => ['read']]; + + if ('user_group_or_user' === $this->type) { + $context[DiscriminatedObjectDenormalizer::ALLOWED_TYPES] = [UserGroup::class, User::class]; + } + return $this->denormalizer->denormalize( ['type' => $item['type'], 'id' => $item['id']], $class, 'json', - [AbstractNormalizer::GROUPS => ['read']], + $context, ); } } diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickUserDynamicType.php b/src/Bundle/ChillMainBundle/Form/Type/PickUserDynamicType.php index aab9d4c51..2d6c5972a 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/PickUserDynamicType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/PickUserDynamicType.php @@ -24,6 +24,13 @@ use Symfony\Component\Serializer\SerializerInterface; /** * Pick user dymically, using vuejs module "AddPerson". + * + * Possible options: + * + * - `multiple`: pick one or more users + * - `suggested`: a list of suggested users + * - `as_id`: only the id will be set in the returned data + * - `submit_on_adding_new_entity`: the browser will immediately submit the form when new users are checked */ class PickUserDynamicType extends AbstractType { diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickUserGroupOrUserDynamicType.php b/src/Bundle/ChillMainBundle/Form/Type/PickUserGroupOrUserDynamicType.php new file mode 100644 index 000000000..c4e91cce4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/PickUserGroupOrUserDynamicType.php @@ -0,0 +1,68 @@ +addViewTransformer(new EntityToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'], 'user_group_or_user')); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['multiple'] = $options['multiple']; + $view->vars['types'] = ['user-group', 'user']; + $view->vars['uniqid'] = uniqid('pick_usergroup_dyn'); + $view->vars['suggested'] = []; + $view->vars['as_id'] = true === $options['as_id'] ? '1' : '0'; + $view->vars['submit_on_adding_new_entity'] = true === $options['submit_on_adding_new_entity'] ? '1' : '0'; + + foreach ($options['suggested'] as $userGroup) { + $view->vars['suggested'][] = $this->normalizer->normalize($userGroup, 'json', ['groups' => 'read']); + } + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefault('multiple', false) + ->setAllowedTypes('multiple', ['bool']) + ->setDefault('compound', false) + ->setDefault('suggested', []) + // if set to true, only the id will be set inside the content. The denormalization will not work. + ->setDefault('as_id', false) + ->setAllowedTypes('as_id', ['bool']) + ->setDefault('submit_on_adding_new_entity', false) + ->setAllowedTypes('submit_on_adding_new_entity', ['bool']); + } + + public function getBlockPrefix() + { + return 'pick_entity_dynamic'; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/UserGroupType.php b/src/Bundle/ChillMainBundle/Form/UserGroupType.php new file mode 100644 index 000000000..8a4685c7c --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/UserGroupType.php @@ -0,0 +1,58 @@ +add('label', TranslatableStringFormType::class, [ + 'label' => 'user_group.Label', + 'required' => true, + ]) + ->add('active') + ->add('backgroundColor', ColorType::class, [ + 'label' => 'user_group.BackgroundColor', + ]) + ->add('foregroundColor', ColorType::class, [ + 'label' => 'user_group.ForegroundColor', + ]) + ->add('excludeKey', TextType::class, [ + 'label' => 'user_group.ExcludeKey', + 'help' => 'user_group.ExcludeKeyHelp', + 'required' => false, + 'empty_data' => '', + ]) + ->add('users', PickUserDynamicType::class, [ + 'label' => 'user_group.Users', + 'multiple' => true, + 'required' => false, + 'empty_data' => [], + ]) + ->add('adminUsers', PickUserDynamicType::class, [ + 'label' => 'user_group.adminUsers', + 'multiple' => true, + 'required' => false, + 'empty_data' => [], + 'help' => 'user_group.adminUsersHelp', + ]) + ; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php index e2aece468..e55b1896d 100644 --- a/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php +++ b/src/Bundle/ChillMainBundle/Form/WorkflowStepType.php @@ -12,20 +12,17 @@ declare(strict_types=1); namespace Chill\MainBundle\Form; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; -use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\PickUserDynamicType; +use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; use Chill\PersonBundle\Form\Type\PickPersonDynamicType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\Callback; -use Symfony\Component\Validator\Constraints\Email; -use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Workflow\Registry; @@ -156,7 +153,7 @@ class WorkflowStepType extends AbstractType 'label' => 'workflow.signature_zone.user signature', 'multiple' => false, ]) - ->add('futureDestUsers', PickUserDynamicType::class, [ + ->add('futureDestUsers', PickUserGroupOrUserDynamicType::class, [ 'label' => 'workflow.dest for next steps', 'multiple' => true, 'empty_data' => '[]', @@ -169,21 +166,6 @@ class WorkflowStepType extends AbstractType 'suggested' => $options['suggested_users'], 'empty_data' => '[]', 'attr' => ['class' => 'future-cc-users'], - ]) - ->add('futureDestEmails', ChillCollectionType::class, [ - 'label' => 'workflow.dest by email', - 'help' => 'workflow.dest by email help', - 'allow_add' => true, - 'entry_type' => EmailType::class, - 'button_add_label' => 'workflow.Add an email', - 'button_remove_label' => 'workflow.Remove an email', - 'empty_collection_explain' => 'workflow.Any email', - 'entry_options' => [ - 'constraints' => [ - new NotNull(), new NotBlank(), new Email(), - ], - 'label' => 'Email', - ], ]); $builder @@ -222,9 +204,8 @@ class WorkflowStepType extends AbstractType } } $destUsers = $step->futureDestUsers; - $destEmails = $step->futureDestEmails; - if (!$toFinal && [] === $destUsers && [] === $destEmails) { + if (!$toFinal && [] === $destUsers) { $context ->buildViolation('workflow.You must add at least one dest user or email') ->atPath('future_dest_users') diff --git a/src/Bundle/ChillMainBundle/Repository/UserGroupRepository.php b/src/Bundle/ChillMainBundle/Repository/UserGroupRepository.php new file mode 100644 index 000000000..e1c54442d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/UserGroupRepository.php @@ -0,0 +1,133 @@ +repository = $em->getRepository(UserGroup::class); + } + + public function setLocale(string $locale): void + { + $this->locale = $locale; + } + + public function getLocale(): string + { + return $this->locale; + } + + public function find($id): ?UserGroup + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?UserGroup + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return UserGroup::class; + } + + public function provideSearchApiQuery(string $pattern, string $lang, string $selectKey = 'user-group'): SearchApiQuery + { + $query = new SearchApiQuery(); + $query + ->setSelectKey($selectKey) + ->setSelectJsonbMetadata("jsonb_build_object('id', ug.id)") + ->setSelectPertinence('3 + SIMILARITY(LOWER(UNACCENT(?)), ug.label->>?) + CASE WHEN (EXISTS(SELECT 1 FROM unnest(string_to_array(label->>?, \' \')) AS t WHERE LOWER(t) LIKE \'%\' || LOWER(UNACCENT(?)) || \'%\')) THEN 100 ELSE 0 END', [$pattern, $lang, $lang, $pattern]) + ->setFromClause('chill_main_user_group AS ug') + ->setWhereClauses(' + ug.active AND ( + SIMILARITY(LOWER(UNACCENT(?)), ug.label->>?) > 0.15 + OR ug.label->>? LIKE \'%\' || LOWER(UNACCENT(?)) || \'%\') + ', [$pattern, $lang, $pattern, $lang]); + + return $query; + } + + public function findByUser(User $user, bool $onlyActive = true, ?int $limit = null, ?int $offset = null): array + { + $qb = $this->buildQueryByUser($user, $onlyActive); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + // ordering thing + $qb->addSelect('JSON_EXTRACT(ug.label, :lang) AS HIDDEN label_ordering') + ->addOrderBy('label_ordering', 'ASC') + ->setParameter('lang', $this->getLocale()); + + return $qb->getQuery()->getResult(); + } + + public function countByUser(User $user, bool $onlyActive = true): int + { + $qb = $this->buildQueryByUser($user, $onlyActive); + $qb->select('count(ug)'); + + return $qb->getQuery()->getSingleScalarResult(); + } + + private function buildQueryByUser(User $user, bool $onlyActive): \Doctrine\ORM\QueryBuilder + { + $qb = $this->repository->createQueryBuilder('ug'); + $qb->where( + $qb->expr()->orX( + $qb->expr()->isMemberOf(':user', 'ug.users'), + $qb->expr()->isMemberOf(':user', 'ug.adminUsers') + ) + ); + + $qb->setParameter('user', $user); + + if ($onlyActive) { + $qb->andWhere( + $qb->expr()->eq('ug.active', ':active') + ); + $qb->setParameter('active', true); + } + + return $qb; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/UserGroupRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/UserGroupRepositoryInterface.php new file mode 100644 index 000000000..c3a729ad4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/UserGroupRepositoryInterface.php @@ -0,0 +1,32 @@ + + */ +interface UserGroupRepositoryInterface extends ObjectRepository +{ + /** + * Provide a SearchApiQuery for searching amongst user groups. + */ + public function provideSearchApiQuery(string $pattern, string $lang, string $selectKey = 'user-group'): SearchApiQuery; + + public function findByUser(User $user, bool $onlyActive = true, ?int $limit = null, ?int $offset = null): array; + + public function countByUser(User $user, bool $onlyActive = true): int; +} diff --git a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php index 13c900ffb..cd39b6e28 100644 --- a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Repository\Workflow; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; use Doctrine\ORM\EntityManagerInterface; @@ -264,6 +265,7 @@ class EntityWorkflowRepository implements ObjectRepository $qb->expr()->orX( $qb->expr()->isMemberOf(':user', 'step.destUser'), $qb->expr()->isMemberOf(':user', 'step.destUserByAccessKey'), + $qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF step.destUserGroups AND :user MEMBER OF ug.users', UserGroup::class)) ), $qb->expr()->isNull('step.transitionAfter'), $qb->expr()->eq('step.isFinal', "'FALSE'") diff --git a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowStepRepository.php b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowStepRepository.php index db57836c2..bdf47357a 100644 --- a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowStepRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowStepRepository.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Repository\Workflow; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; @@ -65,7 +66,11 @@ readonly class EntityWorkflowStepRepository implements ObjectRepository $qb->where( $qb->expr()->andX( - $qb->expr()->isMemberOf(':user', 'e.destUser'), + $qb->expr()->orX( + $qb->expr()->isMemberOf(':user', 'e.destUser'), + $qb->expr()->isMemberOf(':user', 'e.destUserByAccessKey'), + $qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF e.destUserGroups AND :user MEMBER OF ug.users', UserGroup::class)) + ), $qb->expr()->isNull('e.transitionAt'), $qb->expr()->eq('e.isFinal', ':bool'), ) diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/flex_table.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/flex_table.scss index 673d5129a..0adf2b628 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/flex_table.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/flex_table.scss @@ -233,7 +233,7 @@ div.wrap-header { } &:last-child {} - div.wh-col { + & > div.wh-col { &:first-child { flex-grow: 0; flex-shrink: 1; flex-basis: auto; } diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js index 03220630f..19194e318 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js +++ b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js @@ -44,7 +44,9 @@ function loadDynamicPicker(element) { ':suggested="notPickedSuggested" ' + ':label="label" ' + '@addNewEntity="addNewEntity" ' + - '@removeEntity="removeEntity">', + '@removeEntity="removeEntity" ' + + '@addNewEntityProcessEnded="addNewEntityProcessEnded"' + + '>', components: { PickEntity, }, @@ -97,10 +99,11 @@ function loadDynamicPicker(element) { } } } - - if (this.submit_on_adding_new_entity) { - input.form.submit(); - } + }, + addNewEntityProcessEnded() { + if (this.submit_on_adding_new_entity) { + input.form.submit(); + } }, removeEntity({entity}) { if (-1 === this.suggested.findIndex(e => e.type === entity.type && e.id === entity.id)) { diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts index 304ebd8e4..9df338549 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/types.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -1,164 +1,181 @@ export interface DateTime { - datetime: string; - datetime8601: string + datetime: string; + datetime8601: string; } export interface Civility { - id: number; - // TODO + id: number; + // TODO } export interface Job { - id: number; - type: "user_job"; - label: { - "fr": string; // could have other key. How to do that in ts ? - } + id: number; + type: "user_job"; + label: { + fr: string; // could have other key. How to do that in ts ? + }; } export interface Center { - id: number; - type: "center"; - name: string; + id: number; + type: "center"; + name: string; } export interface Scope { - id: number; - type: "scope"; - name: { - "fr": string - } + id: number; + type: "scope"; + name: { + fr: string; + }; +} + +export interface ResultItem { + result: T; + relevance: number; } export interface User { - type: "user"; - id: number; - username: string; - text: string; - text_without_absence: string; - email: string; - user_job: Job; - label: string; - // todo: mainCenter; mainJob; etc.. + type: "user"; + id: number; + username: string; + text: string; + text_without_absence: string; + email: string; + user_job: Job; + label: string; + // todo: mainCenter; mainJob; etc.. } +export interface UserGroup { + type: "user_group"; + id: number; + label: TranslatableString; + backgroundColor: string; + foregroundColor: string; + excludeKey: string; + text: string; +} + +export type UserGroupOrUser = User | UserGroup; + export interface UserAssociatedInterface { - type: "user"; - id: number; -}; - -export type TranslatableString = { - fr?: string; - nl?: string; + type: "user"; + id: number; } +export type TranslatableString = { + fr?: string; + nl?: string; +}; + export interface Postcode { - id: number; - name: string; - code: string; - center: Point; + id: number; + name: string; + code: string; + center: Point; } export type Point = { - type: "Point"; - coordinates: [lat: number, lon: number]; -} + type: "Point"; + coordinates: [lat: number, lon: number]; +}; export interface Country { - id: number; - name: TranslatableString; - code: string; + id: number; + name: TranslatableString; + code: string; } -export type AddressRefStatus = 'match'|'to_review'|'reviewed'; +export type AddressRefStatus = "match" | "to_review" | "reviewed"; export interface Address { - type: "address"; - address_id: number; - text: string; - street: string; - streetNumber: string; - postcode: Postcode; - country: Country; - floor: string | null; - corridor: string | null; - steps: string | null; - flat: string | null; - buildingName: string | null; - distribution: string | null; - extra: string | null; - confidential: boolean; - lines: string[]; - addressReference: AddressReference | null; - validFrom: DateTime; - validTo: DateTime | null; - point: Point | null; - refStatus: AddressRefStatus; - isNoAddress: boolean; + type: "address"; + address_id: number; + text: string; + street: string; + streetNumber: string; + postcode: Postcode; + country: Country; + floor: string | null; + corridor: string | null; + steps: string | null; + flat: string | null; + buildingName: string | null; + distribution: string | null; + extra: string | null; + confidential: boolean; + lines: string[]; + addressReference: AddressReference | null; + validFrom: DateTime; + validTo: DateTime | null; + point: Point | null; + refStatus: AddressRefStatus; + isNoAddress: boolean; } export interface AddressWithPoint extends Address { - point: Point + point: Point; } export interface AddressReference { - id: number; - createdAt: DateTime | null; - deletedAt: DateTime | null; - municipalityCode: string; - point: Point; - postcode: Postcode; - refId: string; - source: string; - street: string; - streetNumber: string; - updatedAt: DateTime | null; + id: number; + createdAt: DateTime | null; + deletedAt: DateTime | null; + municipalityCode: string; + point: Point; + postcode: Postcode; + refId: string; + source: string; + street: string; + streetNumber: string; + updatedAt: DateTime | null; } export interface SimpleGeographicalUnit { - id: number; - layerId: number; - unitName: string; - unitRefId: string; + id: number; + layerId: number; + unitName: string; + unitRefId: string; } export interface GeographicalUnitLayer { - id: number; - name: TranslatableString; - refId: string; + id: number; + name: TranslatableString; + refId: string; } export interface Location { - type: "location"; - id: number; - active: boolean; - address: Address | null; - availableForUsers: boolean; - createdAt: DateTime | null; - createdBy: User | null; - updatedAt: DateTime | null; - updatedBy: User | null; - email: string | null - name: string; - phonenumber1: string | null; - phonenumber2: string | null; - locationType: LocationType; + type: "location"; + id: number; + active: boolean; + address: Address | null; + availableForUsers: boolean; + createdAt: DateTime | null; + createdBy: User | null; + updatedAt: DateTime | null; + updatedBy: User | null; + email: string | null; + name: string; + phonenumber1: string | null; + phonenumber2: string | null; + locationType: LocationType; } export interface LocationAssociated { - type: "location"; - id: number; + type: "location"; + id: number; } export interface LocationType { - type: "location-type"; - id: number; - active: boolean; - addressRequired: "optional" | "required"; - availableForUsers: boolean; - editableByUsers: boolean; - contactData: "optional" | "required"; - title: TranslatableString; + type: "location-type"; + id: number; + active: boolean; + addressRequired: "optional" | "required"; + availableForUsers: boolean; + editableByUsers: boolean; + contactData: "optional" | "required"; + title: TranslatableString; } export interface NewsItemType { diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue index 75abc4fdd..e2ba91f8f 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue @@ -62,7 +62,7 @@ export default { required: false, } }, - emits: ['addNewEntity', 'removeEntity'], + emits: ['addNewEntity', 'removeEntity', 'addNewEntityProcessEnded'], components: { AddPersons, }, @@ -121,6 +121,7 @@ export default { ); this.$refs.addPersons.resetSearch(); // to cast child method modal.showModal = false; + this.$emit('addNewEntityProcessEnded'); }, removeEntity(entity) { if (!this.$props.removableIfSet) { diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserGroupRenderBox.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserGroupRenderBox.vue new file mode 100644 index 000000000..d25100776 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserGroupRenderBox.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/src/Bundle/ChillMainBundle/Resources/views/Entity/user_group.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Entity/user_group.html.twig new file mode 100644 index 000000000..cb6c3be40 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Entity/user_group.html.twig @@ -0,0 +1 @@ +{{ user_group.label|localize_translatable_string }} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Layout/_header.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Layout/_header.html.twig index ab823ff2e..035b8278a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Layout/_header.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Layout/_header.html.twig @@ -60,7 +60,7 @@ data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> - {{ app.request.locale | capitalize }} + {{ app.request.locale | capitalize }} -
- {{ form_row(transition_form.futureDestEmails) }} - {{ form_errors(transition_form.futureDestEmails) }} -

{{ form_label(transition_form.comment) }}

{{ form_widget(transition_form.comment) }} -
    + {% endif %} - {% if entity_workflow.currentStep.destEmail|length > 0 %} -

    {{ 'workflow.An access key was also sent to those addresses'|trans }} :

    -
      - {% for e in entity_workflow.currentStep.destEmail -%} -
    • {{ e }}
    • - {%- endfor %} -
    - {% endif %} - {% if entity_workflow.currentStep.destUserByAccessKey|length > 0 %}

    {{ 'workflow.Those users are also granted to apply a transition by using an access key'|trans }} :

      diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig index 1a37baf27..d6e5e3216 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_history.html.twig @@ -13,22 +13,22 @@ {{ 'workflow.No transitions'|trans }} {% else %} -
      {% if step.previous is not null and step.previous.freezeAfter == true %} {% endif %} + {% if loop.last %} + {% if entity_workflow.isOnHoldAtCurrentStep %} + {% for hold in step.holdsOnStep %} + {{ 'workflow.On hold by'|trans({'by': hold.byUser|chill_entity_render_string}) }} + {% endfor %} + {% endif %} + {% endif %}
      {{ place_label }}
      - {# -
      - - Refusé -
      - #}
      {% endif %} @@ -71,19 +71,33 @@ {% endif %} - {% if loop.last and step.allDestUser|length > 0 %} + {% if not loop.last and step.signatures|length > 0 %} +
      +
      +

      {{ 'workflow.signature_required_title'|trans({'nb_signatures': step.signatures|length}) }} :

      +
      + {{ include('@ChillMain/Workflow/_signature_list.html.twig', {'signatures': step.signatures, is_small: true }) }} +
      +
      +
      + {% endif %} + {% if loop.last and not step.isFinal %}
      - {% if step.destUser|length > 0 %} + {% if step.destUser|length > 0 or step.destUserGroups|length > 0 %}

      {{ 'workflow.Users allowed to apply transition'|trans }} :

        {% for u in step.destUser %} -
      • {{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }} - {% if entity_workflow.isOnHoldAtCurrentStep %} +
      • + {{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }} + {% if step.isOnHoldByUser(u) %} {{ 'workflow.On hold'|trans }} {% endif %}
      • {% endfor %} + {% for u in step.destUserGroups %} +
      • {{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}
      • + {% endfor %}
      {% endif %} @@ -115,6 +129,10 @@ {% endif %}
      + {% if step.signatures|length > 0 %} +

      {{ 'workflow.signature_required_title'|trans({'nb_signatures': step.signatures|length}) }} :

      + {{ include('@ChillMain/Workflow/_signature_list.html.twig', {'signatures': step.signatures, is_small: true }) }} + {% endif %} {% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig index 42a3b68d8..300465016 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig @@ -1,54 +1,3 @@ -

      {{ 'workflow.signature_zone.title'|trans }}

      - -
      - {% for s in signatures %} -
      -
      - {% if s.signerKind == 'person' %} - {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - action: 'show', displayBadge: true, - targetEntity: { name: 'person', id: s.signer.id }, - buttonText: s.signer|chill_entity_render_string, - isDead: s.signer.deathDate is not null - } %} - {% else %} - {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - action: 'show', displayBadge: true, - targetEntity: { name: 'user', id: s.signer.id }, - buttonText: s.signer|chill_entity_render_string, - } %} - {% endif %} -
      -
      - {% if s.isSigned %} - {{ 'workflow.signature.signed_statement'|trans({ 'datetime' : s.stateDate }) }} - {% elseif s.isCanceled %} - {{ 'workflow.signature.canceled_statement'|trans({ 'datetime' : s.stateDate }) }} - {% elseif s.isRejected%} - {{ 'workflow.signature.rejected_statement'|trans({ 'datetime' : s.stateDate }) }} - {% else %} - {% if (is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) or is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s)) %} - - {% endif %} - {% endif %} -
      -
      - {% endfor %} -
      +

      {{ 'workflow.signature_required_title'|trans({'nb_signatures': signatures|length}) }}

      +{{ include('@ChillMain/Workflow/_signature_list.html.twig') }} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature_list.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature_list.html.twig new file mode 100644 index 000000000..e72cb0ba3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature_list.html.twig @@ -0,0 +1,52 @@ +
      + {% for s in signatures %} +
      +
      + {% if s.signerKind == 'person' %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'person', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + isDead: s.signer.deathDate is not null + } %} + {% else %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'user', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + } %} + {% endif %} +
      +
      + {% if s.isSigned %} + {{ 'workflow.signature.signed_statement'|trans({ 'datetime' : s.stateDate }) }} + {% elseif s.isCanceled %} + {{ 'workflow.signature.canceled_statement'|trans({ 'datetime' : s.stateDate }) }} + {% elseif s.isRejected%} + {{ 'workflow.signature.rejected_statement'|trans({ 'datetime' : s.stateDate }) }} + {% else %} + {% if (is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) or is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s)) %} + + {% endif %} + {% endif %} +
      +
      + {% endfor %} +
      + diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig index c5daff201..4decb6fec 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/index.html.twig @@ -62,26 +62,12 @@
      {% include '@ChillMain/Workflow/_follow.html.twig' %}
      {% if signatures|length > 0 %}
      {% include '@ChillMain/Workflow/_signature.html.twig' %}
      + {% else %} +
      {% include '@ChillMain/Workflow/_decision.html.twig' %}
      {% endif %} -
      {% include '@ChillMain/Workflow/_decision.html.twig' %}
      {# -
      {% include '@ChillMain/Workflow/_comment.html.twig' %}
      #} + {#
      {% include '@ChillMain/Workflow/_comment.html.twig' %}
      #}
      {% include '@ChillMain/Workflow/_history.html.twig' %}
      - {% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key.fr.txt.twig deleted file mode 100644 index b4574567d..000000000 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key.fr.txt.twig +++ /dev/null @@ -1,15 +0,0 @@ -Madame, Monsieur, - -Un suivi "{{ workflow.text }}" a atteint une nouvelle étape: {{ workflow.text }}. - -Titre du workflow: "{{ entityTitle }}". - -Vous êtes invité·e à valider cette étape. Pour obtenir un accès, vous pouvez cliquer sur le lien suivant: - -{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey, '_locale': fr})) }} - -Dès que vous aurez cliqué une fois sur le lien, vous serez autorisé à valider cette étape. - -Notez que vous devez disposer d'un compte utilisateur valide dans Chill. - -Cordialement, diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key_title.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key_title.fr.txt.twig deleted file mode 100644 index b505a97ed..000000000 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_send_access_key_title.fr.txt.twig +++ /dev/null @@ -1 +0,0 @@ -Un suivi {{ workflow.text }} demande votre attention: {{ entityTitle }} diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index bef5c9abd..30f84c855 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -49,19 +49,19 @@ {% for flashMessage in app.session.flashbag.get('success') %}
      - {{ flashMessage|raw }} + {{ flashMessage|trans }}
      {% endfor %} {% for flashMessage in app.session.flashbag.get('error') %}
      - {{ flashMessage|raw }} + {{ flashMessage|trans }}
      {% endfor %} {% for flashMessage in app.session.flashbag.get('notice') %}
      - {{ flashMessage|raw }} + {{ flashMessage|trans }}
      {% endfor %} diff --git a/src/Bundle/ChillMainBundle/Routing/ChillUrlGenerator.php b/src/Bundle/ChillMainBundle/Routing/ChillUrlGenerator.php index eaecee628..f88ac0c8a 100644 --- a/src/Bundle/ChillMainBundle/Routing/ChillUrlGenerator.php +++ b/src/Bundle/ChillMainBundle/Routing/ChillUrlGenerator.php @@ -27,7 +27,7 @@ final readonly class ChillUrlGenerator implements ChillUrlGeneratorInterface { $uri = $this->requestStack->getCurrentRequest()->getRequestUri(); - return $this->urlGenerator->generate($name, [$parameters, 'returnPath' => $uri], $referenceType); + return $this->urlGenerator->generate($name, [...$parameters, 'returnPath' => $uri], $referenceType); } public function returnPathOr(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string diff --git a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php index 22bdfbeb8..6c2baf0fd 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php @@ -67,6 +67,10 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface 'route' => 'chill_crud_admin_user_index', ])->setExtras(['order' => 1040]); + $menu->addChild('crud.admin_user_group.index.title', [ + 'route' => 'chill_crud_admin_user_group_index', + ])->setExtras(['order' => 1042]); + $menu->addChild('User jobs', [ 'route' => 'chill_crud_admin_user_job_index', ])->setExtras(['order' => 1050]); diff --git a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php index 3427face3..228e2c967 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php @@ -60,7 +60,6 @@ class UserMenuBuilder implements LocalMenuBuilderInterface $nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user); - // TODO add an icon? How exactly? For example a clock icon... $menu ->addChild($this->translator->trans('absence.Set absence date'), [ 'route' => 'chill_main_user_absence_index', @@ -69,6 +68,14 @@ class UserMenuBuilder implements LocalMenuBuilderInterface 'order' => -8_888_888, ]); + $menu + ->addChild($this->translator->trans('user_group.my_groups'), [ + 'route' => 'chill_main_user_groups_my', + ]) + ->setExtras([ + 'order' => -7_777_777, + ]); + $menu ->addChild( $this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]), diff --git a/src/Bundle/ChillMainBundle/Search/Entity/SearchUserGroupApiProvider.php b/src/Bundle/ChillMainBundle/Search/Entity/SearchUserGroupApiProvider.php new file mode 100644 index 000000000..acdd99976 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Search/Entity/SearchUserGroupApiProvider.php @@ -0,0 +1,59 @@ +locale = $locale; + } + + public function getLocale(): string + { + return $this->locale; + } + + public function getResult(string $key, array $metadata, float $pertinence) + { + return $this->userGroupRepository->find($metadata['id']); + } + + public function prepare(array $metadatas): void {} + + public function provideQuery(string $pattern, array $parameters): SearchApiQuery + { + return $this->userGroupRepository->provideSearchApiQuery($pattern, $this->getLocale(), 'user-group'); + } + + public function supportsResult(string $key, array $metadatas): bool + { + return 'user-group' === $key; + } + + public function supportsTypes(string $pattern, array $types, array $parameters): bool + { + return in_array('user-group', $types, true); + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/UserGroupVoter.php b/src/Bundle/ChillMainBundle/Security/Authorization/UserGroupVoter.php new file mode 100644 index 000000000..7256c2567 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/UserGroupVoter.php @@ -0,0 +1,46 @@ +security->isGranted('ROLE_ADMIN')) { + return true; + } + + $user = $this->security->getUser(); + + if (!$user instanceof User) { + return false; + } + + return $subject->getAdminUsers()->contains($user); + } +} diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/DiscriminatedObjectDenormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/DiscriminatedObjectDenormalizer.php index 1ecb7824f..12b7a908f 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/DiscriminatedObjectDenormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/DiscriminatedObjectDenormalizer.php @@ -47,7 +47,7 @@ class DiscriminatedObjectDenormalizer implements ContextAwareDenormalizerInterfa } } - throw new RuntimeException(sprintf('Could not find any denormalizer for those ALLOWED_TYPES: %s', \implode(', ', $context[self::ALLOWED_TYPES]))); + throw new RuntimeException(sprintf('Could not find any denormalizer for those ALLOWED_TYPES: %s', \implode(', ', $context[self::ALLOWED_TYPES])), previous: $lastException ?? null); } public function supportsDenormalization($data, $type, $format = null, array $context = []) diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserGroupDenormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserGroupDenormalizer.php new file mode 100644 index 000000000..0960ea218 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserGroupDenormalizer.php @@ -0,0 +1,37 @@ +userGroupRepository->find($data['id']); + } + + public function supportsDenormalization($data, string $type, ?string $format = null): bool + { + return UserGroup::class === $type + && 'json' === $format + && is_array($data) + && array_key_exists('id', $data) + && 'user_group' === ($data['type'] ?? false) + && 2 === count(array_keys($data)) + ; + } +} diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserGroupNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserGroupNormalizer.php new file mode 100644 index 000000000..8738167eb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserGroupNormalizer.php @@ -0,0 +1,41 @@ + 'user_group', + 'id' => $object->getId(), + 'label' => $object->getLabel(), + 'backgroundColor' => $object->getBackgroundColor(), + 'foregroundColor' => $object->getForegroundColor(), + 'excludeKey' => $object->getExcludeKey(), + 'text' => $this->userGroupRender->renderString($object, []), + ]; + } + + public function supportsNormalization($data, ?string $format = null) + { + return $data instanceof UserGroup; + } +} diff --git a/src/Bundle/ChillMainBundle/Templating/Entity/UserGroupRender.php b/src/Bundle/ChillMainBundle/Templating/Entity/UserGroupRender.php new file mode 100644 index 000000000..f5a3c78aa --- /dev/null +++ b/src/Bundle/ChillMainBundle/Templating/Entity/UserGroupRender.php @@ -0,0 +1,38 @@ +environment->render('@ChillMain/Entity/user_group.html.twig', ['user_group' => $entity]); + } + + public function renderString($entity, array $options): string + { + /* @var $entity UserGroup */ + return (string) $this->translatableStringHelper->localize($entity->getLabel()); + } + + public function supports(object $entity, array $options): bool + { + return $entity instanceof UserGroup; + } +} diff --git a/src/Bundle/ChillMainBundle/Templating/Entity/UserGroupRenderInterface.php b/src/Bundle/ChillMainBundle/Templating/Entity/UserGroupRenderInterface.php new file mode 100644 index 000000000..7ebf70028 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Templating/Entity/UserGroupRenderInterface.php @@ -0,0 +1,14 @@ +em); + $user = $this->em->createQuery(sprintf('SELECT u FROM %s u', User::class)) + ->setMaxResults(1)->getSingleResult(); + $actual = $repository->countByDest($user); + + self::assertIsInt($actual); + } } diff --git a/src/Bundle/ChillMainBundle/Tests/Repository/EntityWorkflowStepRepositoryTest.php b/src/Bundle/ChillMainBundle/Tests/Repository/EntityWorkflowStepRepositoryTest.php new file mode 100644 index 000000000..572f7d66b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Repository/EntityWorkflowStepRepositoryTest.php @@ -0,0 +1,44 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testCountUnreadByUser(): void + { + $repository = new EntityWorkflowStepRepository($this->entityManager); + $user = $this->entityManager->createQuery(sprintf('SELECT u FROM %s u', User::class)) + ->setMaxResults(1)->getSingleResult(); + + $actual = $repository->countUnreadByUser($user); + + self::assertIsInt($actual); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Repository/UserGroupRepositoryTest.php b/src/Bundle/ChillMainBundle/Tests/Repository/UserGroupRepositoryTest.php new file mode 100644 index 000000000..1ea243541 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Repository/UserGroupRepositoryTest.php @@ -0,0 +1,50 @@ +entityManager = static::getContainer()->get(EntityManagerInterface::class); + + } + + public function testProvideSearchApiQuery(): void + { + $repository = new UserGroupRepository($this->entityManager); + + $apiQuery = $repository->provideSearchApiQuery('trav', 'fr'); + + // test that the query does works + $sql = $apiQuery->buildQuery(); + $params = $apiQuery->buildParameters(); + + $result = $this->entityManager->getConnection()->executeQuery($sql, $params); + $results = $result->fetchAllAssociative(); + + self::assertIsArray($results); + + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserGroupDenormalizerTest.php b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserGroupDenormalizerTest.php new file mode 100644 index 000000000..95a633519 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserGroupDenormalizerTest.php @@ -0,0 +1,62 @@ +createMock(UserGroupRepositoryInterface::class); + $denormalizer = new UserGroupDenormalizer($repository); + + $actual = $denormalizer->supportsDenormalization($data, $type, 'json'); + + self::assertSame($expected, $actual); + } + + public static function provideSupportsDenormalization(): iterable + { + yield [['type' => 'user_group', 'id' => 10], UserGroup::class, true]; + yield [['type' => 'person', 'id' => 10], UserGroup::class, false]; + yield [['type' => 'user_group', 'id' => 10], \stdClass::class, false]; + } + + public function testDenormalize(): void + { + $repository = $this->createMock(UserGroupRepositoryInterface::class); + $repository->expects($this->once()) + ->method('find') + ->with(10) + ->willReturn($userGroup = new UserGroup()); + + $denormalizer = new UserGroupDenormalizer($repository); + + $actual = $denormalizer->denormalize(['type' => 'user_group', 'id' => 10], UserGroup::class, 'json'); + + self::assertSame($userGroup, $actual); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserGroupNormalizerTest.php b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserGroupNormalizerTest.php new file mode 100644 index 000000000..eb85c99a7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserGroupNormalizerTest.php @@ -0,0 +1,56 @@ +setLabel(['fr' => 'test']) + ->setExcludeKey('top') + ->setForegroundColor('#123456') + ->setBackgroundColor('#456789'); + + $entityRender = $this->createMock(UserGroupRenderInterface::class); + $entityRender->expects($this->once()) + ->method('renderString') + ->with($userGroup, []) + ->willReturn('text'); + + $normalizer = new UserGroupNormalizer($entityRender); + + $actual = $normalizer->normalize($userGroup, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + self::assertEqualsCanonicalizing([ + 'type' => 'user_group', + 'text' => 'text', + 'label' => ['fr' => 'test'], + 'excludeKey' => 'top', + 'foregroundColor' => '#123456', + 'backgroundColor' => '#456789', + 'id' => null, + ], $actual); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/EntityWorkflowMarkingStoreTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/EntityWorkflowMarkingStoreTest.php index 4922cab17..952528dda 100644 --- a/src/Bundle/ChillMainBundle/Tests/Workflow/EntityWorkflowMarkingStoreTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/EntityWorkflowMarkingStoreTest.php @@ -44,7 +44,6 @@ class EntityWorkflowMarkingStoreTest extends TestCase $dto = new WorkflowTransitionContextDTO($workflow); $dto->futureCcUsers[] = $user1 = new User(); $dto->futureDestUsers[] = $user2 = new User(); - $dto->futureDestEmails[] = $email = 'test@example.com'; $markingStore->setMarking($workflow, new Marking(['foo' => 1]), [ 'context' => $dto, @@ -55,7 +54,6 @@ class EntityWorkflowMarkingStoreTest extends TestCase $currentStep = $workflow->getCurrentStep(); self::assertEquals('foo', $currentStep->getCurrentStep()); - self::assertContains($email, $currentStep->getDestEmail()); self::assertContains($user1, $currentStep->getCcUser()); self::assertContains($user2, $currentStep->getDestUser()); diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardTransitionTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardTransitionTest.php index dd3b12e86..eaef9b0c2 100644 --- a/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardTransitionTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardTransitionTest.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Tests\Workflow\EventSubscriber; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter; use Chill\MainBundle\Templating\Entity\UserRender; @@ -145,6 +146,11 @@ class EntityWorkflowGuardTransitionTest extends TestCase yield [self::buildEntityWorkflow([new User()]), 'transition1', null, false, 'd9e39a18-704c-11ef-b235-8fe0619caee7']; yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, false, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb']; yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, true, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb']; + + $userGroup = new UserGroup(); + $userGroup->addUser(new User()); + + yield [self::buildEntityWorkflow([$userGroup]), 'transition1', new User(), false, 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc']; } public static function provideValidTransition(): iterable @@ -159,6 +165,10 @@ class EntityWorkflowGuardTransitionTest extends TestCase // transition allowed thanks to permission "apply all transitions" yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), true, 'step1']; yield [self::buildEntityWorkflow([new User()]), 'transition2', new User(), true, 'step2']; + + $userGroup = new UserGroup(); + $userGroup->addUser($u = new User()); + yield [self::buildEntityWorkflow([$userGroup]), 'transition1', $u, false, 'step1']; } public static function buildEntityWorkflow(array $futureDestUsers): EntityWorkflow diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/NotificationOnTransitionTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/NotificationOnTransitionTest.php index fed7f2dce..e2fc30da8 100644 --- a/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/NotificationOnTransitionTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/NotificationOnTransitionTest.php @@ -13,6 +13,7 @@ namespace Chill\MainBundle\Tests\Workflow\EventSubscriber; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; use Chill\MainBundle\Workflow\EventSubscriber\NotificationOnTransition; @@ -20,8 +21,6 @@ use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\Call\Call; -use Prophecy\Exception\Prediction\FailedPredictionException; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Security\Core\Security; use Symfony\Component\Workflow\Event\Event; @@ -57,29 +56,23 @@ final class NotificationOnTransitionTest extends TestCase $id->setValue($entityWorkflow, 1); $step = new EntityWorkflowStep(); + $userGroup = (new UserGroup())->addUser($userInGroup = new User())->addUser($dest); $entityWorkflow->addStep($step); - $step->addDestUser($dest) + $step + ->addDestUser($dest) + ->addDestUserGroup($userGroup) ->setCurrentStep('to_state'); $em = $this->prophesize(EntityManagerInterface::class); - $em->persist(Argument::type(Notification::class))->should( - static function ($args) use ($dest) { - /** @var Call[] $args */ - if (1 !== \count($args)) { - throw new FailedPredictionException('no notification sent'); - } - $notification = $args[0]->getArguments()[0]; - - if (!$notification instanceof Notification) { - throw new FailedPredictionException('persist is not a notification'); - } - - if (!$notification->getAddressees()->contains($dest)) { - throw new FailedPredictionException('the dest is not notified'); - } - } - ); + // we check that both notification has been persisted once, + // eliminating doublons + $em->persist(Argument::that( + fn ($notificationCandidate) => $notificationCandidate instanceof Notification && $notificationCandidate->getAddressees()->contains($dest) + ))->shouldBeCalledOnce(); + $em->persist(Argument::that( + fn ($notificationCandidate) => $notificationCandidate instanceof Notification && $notificationCandidate->getAddressees()->contains($userInGroup) + ))->shouldBeCalledOnce(); $engine = $this->prophesize(\Twig\Environment::class); $engine->render(Argument::type('string'), Argument::type('array')) diff --git a/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php b/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php new file mode 100644 index 000000000..5ec688e5b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php @@ -0,0 +1,31 @@ +getExcludeKey()][] = $gr; + } + } + + foreach ($groups as $excludeKey => $groupByKey) { + if ('' === $excludeKey) { + continue; + } + + if (1 < count($groupByKey)) { + $excludedGroups = implode( + ', ', + array_map( + fn (UserGroup $group) => $this->translatableStringHelper->localize($group->getLabel()), + $groupByKey + ) + ); + + $this->context + ->buildViolation($constraint->message) + ->setCode($constraint->code) + ->setParameters(['excluded_groups' => $excludedGroups]) + ->addViolation(); + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/Counter/WorkflowByUserCounter.php b/src/Bundle/ChillMainBundle/Workflow/Counter/WorkflowByUserCounter.php index a1b631265..8a6474034 100644 --- a/src/Bundle/ChillMainBundle/Workflow/Counter/WorkflowByUserCounter.php +++ b/src/Bundle/ChillMainBundle/Workflow/Counter/WorkflowByUserCounter.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Workflow\Counter; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepRepository; use Chill\MainBundle\Templating\UI\NotificationCounterInterface; @@ -82,6 +83,18 @@ final readonly class WorkflowByUserCounter implements NotificationCounterInterfa foreach ($step->getDestUser() as $user) { $keys[] = self::generateCacheKeyWorkflowByUser($user); } + foreach ($step->getDestUserGroups()->reduce( + function (array $accumulator, UserGroup $userGroup) { + foreach ($userGroup->getUsers() as $user) { + $accumulator[] = $user; + } + + return $accumulator; + }, + [] + ) as $user) { + $keys[] = self::generateCacheKeyWorkflowByUser($user); + } if ([] !== $keys) { $this->cacheItemPool->deleteItems($keys); diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardTransition.php index 1c9d26fc7..5f9e0bca5 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardTransition.php +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardTransition.php @@ -87,6 +87,17 @@ class EntityWorkflowGuardTransition implements EventSubscriberInterface return; } + if (!$user instanceof User) { + $event->addTransitionBlocker( + new TransitionBlocker( + 'workflow.Only regular user can apply a transition', + '04fb4f76-7c0e-11ef-afc3-877bad7b0fe7' + ) + ); + + return; + } + // for users if (!in_array('only-dest', $systemTransitions, true)) { $event->addTransitionBlocker( @@ -108,6 +119,13 @@ class EntityWorkflowGuardTransition implements EventSubscriberInterface return; } + // we give a second chance, searching for the presence of the user within userGroups + foreach ($entityWorkflow->getCurrentStep()->getDestUserGroups() as $userGroup) { + if ($userGroup->contains($user)) { + return; + } + } + $event->addTransitionBlocker(new TransitionBlocker( 'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%', 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc', diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php index d955d777f..89ae65f4c 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php @@ -13,6 +13,7 @@ namespace Chill\MainBundle\Workflow\EventSubscriber; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Doctrine\ORM\EntityManagerInterface; @@ -69,7 +70,18 @@ class NotificationOnTransition implements EventSubscriberInterface // the dests for the current step $entityWorkflow->getCurrentStep()->getDestUser()->toArray(), // the cc users for the current step - $entityWorkflow->getCurrentStep()->getCcUser()->toArray() + $entityWorkflow->getCurrentStep()->getCcUser()->toArray(), + // the users within groups + $entityWorkflow->getCurrentStep()->getDestUserGroups()->reduce( + function (array $accumulator, UserGroup $userGroup) { + foreach ($userGroup->getUsers() as $user) { + $accumulator[] = $user; + } + + return $accumulator; + }, + [] + ), ) as $dest) { $dests[spl_object_hash($dest)] = $dest; } diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/SendAccessKeyEventSubscriber.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/SendAccessKeyEventSubscriber.php deleted file mode 100644 index a7bae81d4..000000000 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/SendAccessKeyEventSubscriber.php +++ /dev/null @@ -1,59 +0,0 @@ -getEntityWorkflow(); - - $place = $this->metadataExtractor->buildArrayPresentationForPlace($entityWorkflow); - $workflow = $this->metadataExtractor->buildArrayPresentationForWorkflow( - $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()) - ); - $handler = $this->entityWorkflowManager->getHandler($entityWorkflow); - - foreach ($step->getDestEmail() as $emailAddress) { - $context = [ - 'entity_workflow' => $entityWorkflow, - 'dest' => $emailAddress, - 'place' => $place, - 'workflow' => $workflow, - 'entityTitle' => $handler->getEntityTitle($entityWorkflow), - ]; - - $email = new Email(); - $email - ->addTo($emailAddress) - ->subject($this->engine->render('@ChillMain/Workflow/workflow_send_access_key_title.fr.txt.twig', $context)) - ->text($this->engine->render('@ChillMain/Workflow/workflow_send_access_key.fr.txt.twig', $context)); - - $this->mailer->send($email); - } - } -} diff --git a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php index f675b9646..370aef0cf 100644 --- a/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php +++ b/src/Bundle/ChillMainBundle/Workflow/WorkflowTransitionContextDTO.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Workflow; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\PersonBundle\Entity\Person; use Symfony\Component\Validator\Constraints as Assert; @@ -24,34 +25,23 @@ use Symfony\Component\Workflow\Transition; class WorkflowTransitionContextDTO { /** - * a list of future dest users for the next steps. + * a list of future dest users or user groups for the next step. * * This is in used in order to let controller inform who will be the future users which will validate * the next step. This is necessary to perform some computation about the next users, before they are * associated to the entity EntityWorkflowStep. * - * @var array|User[] + * @var list */ public array $futureDestUsers = []; /** - * a list of future cc users for the next steps. + * a list of future cc users for the next step. * * @var array|User[] */ public array $futureCcUsers = []; - /** - * a list of future dest emails for the next steps. - * - * This is in used in order to let controller inform who will be the future emails which will validate - * the next step. This is necessary to perform some computation about the next emails, before they are - * associated to the entity EntityWorkflowStep. - * - * @var array|string[] - */ - public array $futureDestEmails = []; - /** * A list of future @see{Person} with will sign the next step. * @@ -72,6 +62,22 @@ class WorkflowTransitionContextDTO public EntityWorkflow $entityWorkflow, ) {} + /** + * @return list + */ + public function getFutureDestUsers(): array + { + return array_values(array_filter($this->futureDestUsers, fn (User|UserGroup $user) => $user instanceof User)); + } + + /** + * @return list + */ + public function getFutureDestUserGroups(): array + { + return array_values(array_filter($this->futureDestUsers, fn (User|UserGroup $user) => $user instanceof UserGroup)); + } + #[Assert\Callback()] public function validateCCUserIsNotInDest(ExecutionContextInterface $context, $payload): void { diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index 1abdfc72e..ea1ae10bd 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -5,8 +5,8 @@ info: title: "Chill api" description: "Api documentation for chill. Currently, work in progress" servers: - - url: "/api" - description: "Your current dev server" + - url: "/api" + description: "Your current dev server" components: schemas: @@ -29,6 +29,42 @@ components: type: string text: type: string + UserById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user + UserGroup: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user_group + label: + type: object + additionalProperties: true + backgroundColor: + type: string + foregroundColor: + type: string + exclusionKey: + type: string + UserGroupById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user_group Center: type: object properties: @@ -181,25 +217,26 @@ paths: The results are ordered by relevance, from the most to the lowest relevant. parameters: - - name: q - in: query - required: true - description: the pattern to search - schema: - type: string - - name: type[] - in: query - required: true - description: the type entities amongst the search is performed - schema: - type: array - items: - type: string - enum: - - person - - thirdparty - - user - - household + - name: q + in: query + required: true + description: the pattern to search + schema: + type: string + - name: type[] + in: query + required: true + description: the type entities amongst the search is performed + schema: + type: array + items: + type: string + enum: + - person + - thirdparty + - user + - household + - user-group responses: 200: description: "OK" @@ -236,7 +273,7 @@ paths: minItems: 2 maxItems: 2 postcode: - $ref: "#/components/schemas/PostalCode" + $ref: '#/components/schemas/PostalCode' steps: type: string street: @@ -260,14 +297,14 @@ paths: - address summary: Return an address by id parameters: - - name: id - in: path - required: true - description: The address id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The address id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -284,14 +321,14 @@ paths: - address summary: patch an address parameters: - - name: id - in: path - required: true - description: The address id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The address id + schema: + type: integer + format: integer + minimum: 1 requestBody: required: true content: @@ -349,14 +386,14 @@ paths: - address summary: Duplicate an existing address parameters: - - name: id - in: path - required: true - description: The address id that will be duplicated - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The address id that will be duplicated + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -375,12 +412,12 @@ paths: - address summary: Return a list of all reference addresses parameters: - - in: query - name: postal_code - required: false - schema: - type: integer - description: The id of a postal code to filter the reference addresses + - in: query + name: postal_code + required: false + schema: + type: integer + description: The id of a postal code to filter the reference addresses responses: 200: description: "ok" @@ -390,14 +427,14 @@ paths: - address summary: Return a reference address by id parameters: - - name: id - in: path - required: true - description: The reference address id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The reference address id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -417,20 +454,20 @@ paths: - search summary: Return a reference address by id parameters: - - name: id - in: path - required: true - description: The reference address id - schema: - type: integer - format: integer - minimum: 1 - - name: q - in: query - required: true - description: The search pattern - schema: - type: string + - name: id + in: path + required: true + description: The reference address id + schema: + type: integer + format: integer + minimum: 1 + - name: q + in: query + required: true + description: The search pattern + schema: + type: string responses: 200: description: "ok" @@ -450,12 +487,12 @@ paths: - address summary: Return a list of all postal-code parameters: - - in: query - name: country - required: false - schema: - type: integer - description: The id of a country to filter the postal code + - in: query + name: country + required: false + schema: + type: integer + description: The id of a country to filter the postal code responses: 200: description: "ok" @@ -494,14 +531,14 @@ paths: - address summary: Return a postal code by id parameters: - - name: id - in: path - required: true - description: The postal code id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The postal code id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -521,18 +558,18 @@ paths: - search summary: Search a postal code parameters: - - name: q - in: query - required: true - description: The search pattern - schema: - type: string - - name: country - in: query - required: false - description: The country id - schema: - type: integer + - name: q + in: query + required: true + description: The search pattern + schema: + type: string + - name: country + in: query + required: false + description: The country id + schema: + type: integer responses: 200: description: "ok" @@ -559,14 +596,14 @@ paths: - address summary: Return a country by id parameters: - - name: id - in: path - required: true - description: The country id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The country id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -609,14 +646,14 @@ paths: - user summary: Return a user by id parameters: - - name: id - in: path - required: true - description: The user id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The user id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -646,14 +683,14 @@ paths: - scope summary: return a list of scopes parameters: - - name: id - in: path - required: true - description: The scope id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The scope id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -721,14 +758,14 @@ paths: - location summary: Return the given location parameters: - - name: id - in: path - required: true - description: The location id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The location id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -788,14 +825,14 @@ paths: - notification summary: mark a notification as read parameters: - - name: id - in: path - required: true - description: The notification id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The notification id + schema: + type: integer + format: integer + minimum: 1 responses: 202: description: "accepted" @@ -807,14 +844,14 @@ paths: - notification summary: mark a notification as unread parameters: - - name: id - in: path - required: true - description: The notification id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The notification id + schema: + type: integer + format: integer + minimum: 1 responses: 202: description: "accepted" @@ -846,7 +883,7 @@ paths: type: array items: type: integer - example: [1, 2, 3] # Example array of IDs + example: [ 1, 2, 3 ] # Example array of IDs responses: "202": description: Notifications marked as unread successfully @@ -934,6 +971,22 @@ paths: schema: type: array items: - $ref: "#/components/schemas/NewsItem" + $ref: '#/components/schemas/NewsItem' + 403: + description: "Unauthorized" + /1.0/main/user-group.json: + get: + tags: + - user-group + summary: Return a list of users-groups + responses: + 200: + description: "ok" + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserGroup' 403: description: "Unauthorized" diff --git a/src/Bundle/ChillMainBundle/config/services.yaml b/src/Bundle/ChillMainBundle/config/services.yaml index 5976cb8b0..8b4aaa7bf 100644 --- a/src/Bundle/ChillMainBundle/config/services.yaml +++ b/src/Bundle/ChillMainBundle/config/services.yaml @@ -42,17 +42,6 @@ services: arguments: $handlers: !tagged_iterator chill_main.workflow_handler - Chill\MainBundle\Workflow\EventSubscriber\SendAccessKeyEventSubscriber: - autoconfigure: true - autowire: true - tags: - - - name: 'doctrine.orm.entity_listener' - event: 'postPersist' - entity: 'Chill\MainBundle\Entity\Workflow\EntityWorkflowStep' - # set the 'lazy' option to TRUE to only instantiate listeners when they are used - lazy: true - # other stuffes chill.main.helper.translatable_string: diff --git a/src/Bundle/ChillMainBundle/config/services/templating.yaml b/src/Bundle/ChillMainBundle/config/services/templating.yaml index 281d4ad23..04117bf16 100644 --- a/src/Bundle/ChillMainBundle/config/services/templating.yaml +++ b/src/Bundle/ChillMainBundle/config/services/templating.yaml @@ -56,6 +56,10 @@ services: Chill\MainBundle\Templating\Entity\UserRender: ~ + Chill\MainBundle\Templating\Entity\UserGroupRender: ~ + Chill\MainBundle\Templating\Entity\UserGroupRenderInterface: + alias: Chill\MainBundle\Templating\Entity\UserGroupRender + Chill\MainBundle\Templating\Listing\: resource: './../../Templating/Listing' diff --git a/src/Bundle/ChillMainBundle/config/services/validator.yaml b/src/Bundle/ChillMainBundle/config/services/validator.yaml index b3b60b9d6..32b8903cc 100644 --- a/src/Bundle/ChillMainBundle/config/services/validator.yaml +++ b/src/Bundle/ChillMainBundle/config/services/validator.yaml @@ -3,6 +3,9 @@ services: autowire: true autoconfigure: true + Chill\MainBundle\Validation\: + resource: '../../Validation' + chill_main.validator_user_circle_consistency: class: Chill\MainBundle\Validator\Constraints\Entity\UserCircleConsistencyValidator arguments: diff --git a/src/Bundle/ChillMainBundle/migrations/Version20240416145021.php b/src/Bundle/ChillMainBundle/migrations/Version20240416145021.php new file mode 100644 index 000000000..f2f8d37dd --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20240416145021.php @@ -0,0 +1,41 @@ +addSql('CREATE SEQUENCE chill_main_user_group_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_main_user_group (id INT NOT NULL, label JSON DEFAULT \'[]\' NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE chill_main_user_group_user (usergroup_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(usergroup_id, user_id))'); + $this->addSql('CREATE INDEX IDX_1E07F044D2112630 ON chill_main_user_group_user (usergroup_id)'); + $this->addSql('CREATE INDEX IDX_1E07F044A76ED395 ON chill_main_user_group_user (user_id)'); + $this->addSql('ALTER TABLE chill_main_user_group_user ADD CONSTRAINT FK_1E07F044D2112630 FOREIGN KEY (usergroup_id) REFERENCES chill_main_user_group (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_user_group_user ADD CONSTRAINT FK_1E07F044A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_main_user_group_id_seq'); + $this->addSql('DROP TABLE chill_main_user_group_user'); + $this->addSql('DROP TABLE chill_main_user_group'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php b/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php new file mode 100644 index 000000000..960b3cc79 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE chill_main_user_group ADD backgroundColor TEXT DEFAULT \'#ffffffff\' NOT NULL'); + $this->addSql('ALTER TABLE chill_main_user_group ADD foregroundColor TEXT DEFAULT \'#000000ff\' NOT NULL'); + $this->addSql('ALTER TABLE chill_main_user_group ADD excludeKey TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER INDEX idx_1e07f044d2112630 RENAME TO IDX_738BC82BD2112630'); + $this->addSql('ALTER INDEX idx_1e07f044a76ed395 RENAME TO IDX_738BC82BA76ED395'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_user_group DROP backgroundColor'); + $this->addSql('ALTER TABLE chill_main_user_group DROP foregroundColor'); + $this->addSql('ALTER TABLE chill_main_user_group DROP excludeKey'); + $this->addSql('ALTER INDEX idx_738bc82bd2112630 RENAME TO idx_1e07f044d2112630'); + $this->addSql('ALTER INDEX idx_738bc82ba76ed395 RENAME TO idx_1e07f044a76ed395'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20240926132856.php b/src/Bundle/ChillMainBundle/migrations/Version20240926132856.php new file mode 100644 index 000000000..e14bf7e78 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20240926132856.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE chill_main_workflow_entity_step_user_group (entityworkflowstep_id INT NOT NULL, usergroup_id INT NOT NULL, PRIMARY KEY(entityworkflowstep_id, usergroup_id))'); + $this->addSql('CREATE INDEX IDX_AB433F907E6AF9D4 ON chill_main_workflow_entity_step_user_group (entityworkflowstep_id)'); + $this->addSql('CREATE INDEX IDX_AB433F90D2112630 ON chill_main_workflow_entity_step_user_group (usergroup_id)'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_user_group ADD CONSTRAINT FK_AB433F907E6AF9D4 FOREIGN KEY (entityworkflowstep_id) REFERENCES chill_main_workflow_entity_step (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_user_group ADD CONSTRAINT FK_AB433F90D2112630 FOREIGN KEY (usergroup_id) REFERENCES chill_main_user_group (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_user_group DROP CONSTRAINT FK_AB433F907E6AF9D4'); + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_user_group DROP CONSTRAINT FK_AB433F90D2112630'); + $this->addSql('DROP TABLE chill_main_workflow_entity_step_user_group'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20240927095751.php b/src/Bundle/ChillMainBundle/migrations/Version20240927095751.php new file mode 100644 index 000000000..788efb1e8 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20240927095751.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE chill_main_user_group_user_admin (usergroup_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(usergroup_id, user_id))'); + $this->addSql('CREATE INDEX IDX_DAD75036D2112630 ON chill_main_user_group_user_admin (usergroup_id)'); + $this->addSql('CREATE INDEX IDX_DAD75036A76ED395 ON chill_main_user_group_user_admin (user_id)'); + $this->addSql('ALTER TABLE chill_main_user_group_user_admin ADD CONSTRAINT FK_DAD75036D2112630 FOREIGN KEY (usergroup_id) REFERENCES chill_main_user_group (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_user_group_user_admin ADD CONSTRAINT FK_DAD75036A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_user_group ADD active BOOLEAN DEFAULT true NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_user_group_user_admin DROP CONSTRAINT FK_DAD75036D2112630'); + $this->addSql('ALTER TABLE chill_main_user_group_user_admin DROP CONSTRAINT FK_DAD75036A76ED395'); + $this->addSql('DROP TABLE chill_main_user_group_user_admin'); + $this->addSql('ALTER TABLE chill_main_user_group DROP active'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 7f941b13a..9100f8ab8 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -5,6 +5,17 @@ years_old: >- other {# ans} } +user_group: + with_count_users: >- + {count, plural, + =0 {Aucun membre} + one {1 utilisateur} + many {# utilisateurs} + other {# utilisateurs} + } + user_removed: L'utilisateur {user} est enlevé du groupe {user_group} avec succès + user_added: L'utilisateur {user} est ajouté groupe {user_group} avec succès + notification: My notifications with counter: >- {nb, plural, @@ -47,9 +58,15 @@ workflow: } signature: signed_statement: 'Signature appliquée le {datetime, date, short} à {datetime, time, short}' - rejected_statement: 'Signature rejectée le {datetime, date, short} à {datetime, time, short}' + rejected_statement: 'Signature rejetée le {datetime, date, short} à {datetime, time, short}' canceled_statement: 'Signature annulée le {datetime, date, short} à {datetime, time, short}' - + On hold by: En attente par {by} + signature_required_title: >- + {nb_signatures, plural, + =0 {Aucune signature demandée} + one {Signature demandée} + other {Signatures demandées} + } duration: minute: >- diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 8bade3ce6..bec5e1adb 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -51,6 +51,25 @@ user: no job: Pas de métier assigné no scope: Pas de cercle assigné +user_group: + inactive: Inactif + with_users: Membres + no_users: Aucun utilisateur associé + no_admin_users: Aucun administrateur + Label: Nom du groupe + BackgroundColor: Couleur de fond du badge + ForegroundColor: Couleur de la police du badge + ExcludeKey: Clé d'exclusion + ExcludeKeyHelp: Lorsque cela est pertinent, les groupes comportant la même clé d'exclusion s'excluent mutuellement. + Users: Membres du groupe + adminUsers: Administrateurs du groupe + adminUsersHelp: Les administrateurs du groupe peuvent ajouter ou retirer des membres dans le groupe. + my_groups: Mes groupes + me_and: Moi et + me_only: Uniquement moi + me: Moi + append_users: Ajouter des utilisateurs + inactive: inactif Edit: Modifier @@ -395,6 +414,12 @@ crud: add_new: Créer title_new: Nouveau métier title_edit: Modifier un métier + admin_user_group: + index: + title: Groupes d'utilisateurs + add_new: Créer + title_edit: Modifier un groupe d'utilisateur + title_new: Nouveau groupe utilisateur main_location_type: index: title: Liste des types de localisations diff --git a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss index 072f13949..cec272a3a 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss +++ b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss @@ -3,8 +3,10 @@ */ span.badge-user, +span.badge-user-group, span.badge-person, span.badge-thirdparty { + margin: 0.2rem 0.1rem; display: inline-block; padding: 0 0.5em !important; background-color: $white; @@ -18,6 +20,11 @@ span.badge-thirdparty { } } +span.badge-user-group { + font-weight: 600; + border-width: 0px; +} + span.badge-user { border-bottom-width: 1px; &.system { @@ -231,6 +238,10 @@ div[class*='budget-'] { background-color: $chill-ll-gray; color: $chill-blue; } + &.bg-user-group { + background-color: $chill-l-gray; + color: $chill-blue; + } &.bg-confidential { background-color: $chill-ll-gray; color: $chill-red; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue index b605cd5b5..ec9556a95 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue @@ -27,6 +27,11 @@ v-bind:item="item"> + + > + @@ -41,6 +46,7 @@ import SuggestionPerson from './TypePerson'; import SuggestionThirdParty from './TypeThirdParty'; import SuggestionUser from './TypeUser'; import SuggestionHousehold from './TypeHousehold'; +import SuggestionUserGroup from './TypeUserGroup'; export default { name: 'PersonSuggestion', @@ -49,6 +55,7 @@ export default { SuggestionThirdParty, SuggestionUser, SuggestionHousehold, + SuggestionUserGroup, }, props: [ 'item', diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue new file mode 100644 index 000000000..f1a83ff22 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue @@ -0,0 +1,30 @@ + + + + +