Merge branch 'signature-app/OP630-user-group-in-workflows' into 'signature-app-master'

Implements feature to send a workfllow to a group of users

See merge request Chill-Projet/chill-bundles!744
This commit is contained in:
Julien Fastré 2024-10-01 19:46:50 +00:00
commit 6c52ff84a8
74 changed files with 2600 additions and 523 deletions

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Symfony\Component\HttpFoundation\Request;
class UserGroupAdminController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addSelect('JSON_EXTRACT(e.label, :lang) AS HIDDEN labeli18n')
->setParameter('lang', $request->getLocale());
$query->addOrderBy('labeli18n', 'ASC');
return $query;
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
class UserGroupApiController extends ApiController {}

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Repository\UserGroupRepositoryInterface;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
use Chill\MainBundle\Security\Authorization\UserGroupVoter;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Translation\TranslatableMessage;
use Twig\Environment;
/**
* Controller to see and manage user groups.
*/
final readonly class UserGroupController
{
public function __construct(
private UserGroupRepositoryInterface $userGroupRepository,
private Security $security,
private PaginatorFactoryInterface $paginatorFactory,
private Environment $twig,
private FormFactoryInterface $formFactory,
private ChillUrlGeneratorInterface $chillUrlGenerator,
private EntityManagerInterface $objectManager,
private ChillEntityRenderManagerInterface $chillEntityRenderManager,
) {}
#[Route('/{_locale}/main/user-groups/my', name: 'chill_main_user_groups_my')]
public function myUserGroups(): Response
{
if (!$this->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();
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\DataFixtures\ORM;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
class LoadUserGroup extends Fixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return ['user-group'];
}
public function load(ObjectManager $manager)
{
$centerASocial = $manager->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)
;
}
}

View File

@ -24,6 +24,8 @@ use Chill\MainBundle\Controller\LocationTypeController;
use Chill\MainBundle\Controller\NewsItemController; use Chill\MainBundle\Controller\NewsItemController;
use Chill\MainBundle\Controller\RegroupmentController; use Chill\MainBundle\Controller\RegroupmentController;
use Chill\MainBundle\Controller\UserController; use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserGroupAdminController;
use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController; use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController; use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; 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\NewsItem;
use Chill\MainBundle\Entity\Regroupment; use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\CenterType; use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType; use Chill\MainBundle\Form\CivilityType;
@ -68,6 +71,7 @@ use Chill\MainBundle\Form\LocationFormType;
use Chill\MainBundle\Form\LocationTypeType; use Chill\MainBundle\Form\LocationTypeType;
use Chill\MainBundle\Form\NewsItemType; use Chill\MainBundle\Form\NewsItemType;
use Chill\MainBundle\Form\RegroupmentType; use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType; use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType; use Chill\MainBundle\Form\UserType;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
@ -353,6 +357,28 @@ class ChillMainExtension extends Extension implements
{ {
$container->prependExtensionConfig('chill_main', [ $container->prependExtensionConfig('chill_main', [
'cruds' => [ '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, 'class' => UserJob::class,
'controller' => UserJobController::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,
],
],
],
],
], ],
]); ]);
} }

View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_user_group')]
// this discriminator key is required for automated denormalization
#[DiscriminatorMap('type', mapping: ['user_group' => 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<int, User>&Selectable<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_user_group_user')]
private Collection&Selectable $users;
/**
* @var Collection<int, User>&Selectable<int, User>
*/
#[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<int, User>&Collection<int, User>
*/
public function getUsers(): Collection&Selectable
{
return $this->users;
}
/**
* @return Selectable<int, User>&Collection<int, User>
*/
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);
}
}

View File

@ -442,18 +442,18 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
$newStep->addCcUser($user); $newStep->addCcUser($user);
} }
foreach ($transitionContextDTO->futureDestUsers as $user) { foreach ($transitionContextDTO->getFutureDestUsers() as $user) {
$newStep->addDestUser($user); $newStep->addDestUser($user);
} }
foreach ($transitionContextDTO->getFutureDestUserGroups() as $userGroup) {
$newStep->addDestUserGroup($userGroup);
}
if (null !== $transitionContextDTO->futureUserSignature) { if (null !== $transitionContextDTO->futureUserSignature) {
$newStep->addDestUser($transitionContextDTO->futureUserSignature); $newStep->addDestUser($transitionContextDTO->futureUserSignature);
} }
foreach ($transitionContextDTO->futureDestEmails as $email) {
$newStep->addDestEmail($email);
}
if (null !== $transitionContextDTO->futureUserSignature) { if (null !== $transitionContextDTO->futureUserSignature) {
new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature); new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature);
} else { } else {

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Entity\Workflow; namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@ -48,6 +49,13 @@ class EntityWorkflowStep
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user')] #[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user')]
private Collection $destUser; private Collection $destUser;
/**
* @var Collection<int, UserGroup>
*/
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user_group')]
private Collection $destUserGroups;
/** /**
* @var Collection<int, User> * @var Collection<int, User>
*/ */
@ -108,6 +116,7 @@ class EntityWorkflowStep
{ {
$this->ccUser = new ArrayCollection(); $this->ccUser = new ArrayCollection();
$this->destUser = new ArrayCollection(); $this->destUser = new ArrayCollection();
$this->destUserGroups = new ArrayCollection();
$this->destUserByAccessKey = new ArrayCollection(); $this->destUserByAccessKey = new ArrayCollection();
$this->signatures = new ArrayCollection(); $this->signatures = new ArrayCollection();
$this->holdsOnStep = new ArrayCollection(); $this->holdsOnStep = new ArrayCollection();
@ -123,6 +132,9 @@ class EntityWorkflowStep
return $this; return $this;
} }
/**
* @deprecated
*/
public function addDestEmail(string $email): self public function addDestEmail(string $email): self
{ {
if (!\in_array($email, $this->destEmail, true)) { if (!\in_array($email, $this->destEmail, true)) {
@ -141,6 +153,22 @@ class EntityWorkflowStep
return $this; 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 public function addDestUserByAccessKey(User $user): self
{ {
if (!$this->destUserByAccessKey->contains($user) && !$this->destUser->contains($user)) { 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 * 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 * @psalm-suppress DuplicateArrayKey
*/ */
@ -192,6 +222,14 @@ class EntityWorkflowStep
); );
} }
/**
* @return Collection<int, UserGroup>
*/
public function getDestUserGroups(): Collection
{
return $this->destUserGroups;
}
public function getCcUser(): Collection public function getCcUser(): Collection
{ {
return $this->ccUser; return $this->ccUser;
@ -207,6 +245,11 @@ class EntityWorkflowStep
return $this->currentStep; return $this->currentStep;
} }
/**
* @return array<string>
*
* @deprecated
*/
public function getDestEmail(): array public function getDestEmail(): array
{ {
return $this->destEmail; return $this->destEmail;

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\DataTransformer; namespace Chill\MainBundle\Form\Type\DataTransformer;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty; use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Symfony\Component\Form\DataTransformerInterface; use Symfony\Component\Form\DataTransformerInterface;
@ -74,15 +76,23 @@ class EntityToJsonTransformer implements DataTransformerInterface
'user' => User::class, 'user' => User::class,
'person' => Person::class, 'person' => Person::class,
'thirdparty' => ThirdParty::class, 'thirdparty' => ThirdParty::class,
'user_group' => UserGroup::class,
'user_group_or_user' => DiscriminatedObjectDenormalizer::TYPE,
default => throw new \UnexpectedValueException('This type is not supported'), 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 return
$this->denormalizer->denormalize( $this->denormalizer->denormalize(
['type' => $item['type'], 'id' => $item['id']], ['type' => $item['type'], 'id' => $item['id']],
$class, $class,
'json', 'json',
[AbstractNormalizer::GROUPS => ['read']], $context,
); );
} }
} }

View File

@ -24,6 +24,13 @@ use Symfony\Component\Serializer\SerializerInterface;
/** /**
* Pick user dymically, using vuejs module "AddPerson". * 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 class PickUserDynamicType extends AbstractType
{ {

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Form\Type\DataTransformer\EntityToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Entity which picks a user **or** a user group.
*/
final class PickUserGroupOrUserDynamicType extends AbstractType
{
public function __construct(private readonly DenormalizerInterface $denormalizer, private readonly SerializerInterface $serializer, private readonly NormalizerInterface $normalizer) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->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';
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ColorType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class UserGroupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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',
])
;
}
}

View File

@ -12,20 +12,17 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form; namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType; use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback; 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\Constraints\NotNull;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Registry;
@ -156,7 +153,7 @@ class WorkflowStepType extends AbstractType
'label' => 'workflow.signature_zone.user signature', 'label' => 'workflow.signature_zone.user signature',
'multiple' => false, 'multiple' => false,
]) ])
->add('futureDestUsers', PickUserDynamicType::class, [ ->add('futureDestUsers', PickUserGroupOrUserDynamicType::class, [
'label' => 'workflow.dest for next steps', 'label' => 'workflow.dest for next steps',
'multiple' => true, 'multiple' => true,
'empty_data' => '[]', 'empty_data' => '[]',
@ -169,21 +166,6 @@ class WorkflowStepType extends AbstractType
'suggested' => $options['suggested_users'], 'suggested' => $options['suggested_users'],
'empty_data' => '[]', 'empty_data' => '[]',
'attr' => ['class' => 'future-cc-users'], '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 $builder
@ -222,9 +204,8 @@ class WorkflowStepType extends AbstractType
} }
} }
$destUsers = $step->futureDestUsers; $destUsers = $step->futureDestUsers;
$destEmails = $step->futureDestEmails;
if (!$toFinal && [] === $destUsers && [] === $destEmails) { if (!$toFinal && [] === $destUsers) {
$context $context
->buildViolation('workflow.You must add at least one dest user or email') ->buildViolation('workflow.You must add at least one dest user or email')
->atPath('future_dest_users') ->atPath('future_dest_users')

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Contracts\Translation\LocaleAwareInterface;
final class UserGroupRepository implements UserGroupRepositoryInterface, LocaleAwareInterface
{
private readonly EntityRepository $repository;
private string $locale;
public function __construct(EntityManagerInterface $em)
{
$this->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;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-extends ObjectRepository<UserGroup>
*/
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;
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository\Workflow; namespace Chill\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -264,6 +265,7 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->expr()->orX( $qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'step.destUser'), $qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->isMemberOf(':user', 'step.destUserByAccessKey'), $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()->isNull('step.transitionAfter'),
$qb->expr()->eq('step.isFinal', "'FALSE'") $qb->expr()->eq('step.isFinal', "'FALSE'")

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository\Workflow; namespace Chill\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
@ -65,7 +66,11 @@ readonly class EntityWorkflowStepRepository implements ObjectRepository
$qb->where( $qb->where(
$qb->expr()->andX( $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()->isNull('e.transitionAt'),
$qb->expr()->eq('e.isFinal', ':bool'), $qb->expr()->eq('e.isFinal', ':bool'),
) )

View File

@ -233,7 +233,7 @@ div.wrap-header {
} }
&:last-child {} &:last-child {}
div.wh-col { & > div.wh-col {
&:first-child { &:first-child {
flex-grow: 0; flex-shrink: 1; flex-basis: auto; flex-grow: 0; flex-shrink: 1; flex-basis: auto;
} }

View File

@ -44,7 +44,9 @@ function loadDynamicPicker(element) {
':suggested="notPickedSuggested" ' + ':suggested="notPickedSuggested" ' +
':label="label" ' + ':label="label" ' +
'@addNewEntity="addNewEntity" ' + '@addNewEntity="addNewEntity" ' +
'@removeEntity="removeEntity"></pick-entity>', '@removeEntity="removeEntity" ' +
'@addNewEntityProcessEnded="addNewEntityProcessEnded"' +
'></pick-entity>',
components: { components: {
PickEntity, PickEntity,
}, },
@ -97,10 +99,11 @@ function loadDynamicPicker(element) {
} }
} }
} }
},
if (this.submit_on_adding_new_entity) { addNewEntityProcessEnded() {
input.form.submit(); if (this.submit_on_adding_new_entity) {
} input.form.submit();
}
}, },
removeEntity({entity}) { removeEntity({entity}) {
if (-1 === this.suggested.findIndex(e => e.type === entity.type && e.id === entity.id)) { if (-1 === this.suggested.findIndex(e => e.type === entity.type && e.id === entity.id)) {

View File

@ -1,164 +1,181 @@
export interface DateTime { export interface DateTime {
datetime: string; datetime: string;
datetime8601: string datetime8601: string;
} }
export interface Civility { export interface Civility {
id: number; id: number;
// TODO // TODO
} }
export interface Job { export interface Job {
id: number; id: number;
type: "user_job"; type: "user_job";
label: { label: {
"fr": string; // could have other key. How to do that in ts ? fr: string; // could have other key. How to do that in ts ?
} };
} }
export interface Center { export interface Center {
id: number; id: number;
type: "center"; type: "center";
name: string; name: string;
} }
export interface Scope { export interface Scope {
id: number; id: number;
type: "scope"; type: "scope";
name: { name: {
"fr": string fr: string;
} };
}
export interface ResultItem<T> {
result: T;
relevance: number;
} }
export interface User { export interface User {
type: "user"; type: "user";
id: number; id: number;
username: string; username: string;
text: string; text: string;
text_without_absence: string; text_without_absence: string;
email: string; email: string;
user_job: Job; user_job: Job;
label: string; label: string;
// todo: mainCenter; mainJob; etc.. // 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 { export interface UserAssociatedInterface {
type: "user"; type: "user";
id: number; id: number;
};
export type TranslatableString = {
fr?: string;
nl?: string;
} }
export type TranslatableString = {
fr?: string;
nl?: string;
};
export interface Postcode { export interface Postcode {
id: number; id: number;
name: string; name: string;
code: string; code: string;
center: Point; center: Point;
} }
export type Point = { export type Point = {
type: "Point"; type: "Point";
coordinates: [lat: number, lon: number]; coordinates: [lat: number, lon: number];
} };
export interface Country { export interface Country {
id: number; id: number;
name: TranslatableString; name: TranslatableString;
code: string; code: string;
} }
export type AddressRefStatus = 'match'|'to_review'|'reviewed'; export type AddressRefStatus = "match" | "to_review" | "reviewed";
export interface Address { export interface Address {
type: "address"; type: "address";
address_id: number; address_id: number;
text: string; text: string;
street: string; street: string;
streetNumber: string; streetNumber: string;
postcode: Postcode; postcode: Postcode;
country: Country; country: Country;
floor: string | null; floor: string | null;
corridor: string | null; corridor: string | null;
steps: string | null; steps: string | null;
flat: string | null; flat: string | null;
buildingName: string | null; buildingName: string | null;
distribution: string | null; distribution: string | null;
extra: string | null; extra: string | null;
confidential: boolean; confidential: boolean;
lines: string[]; lines: string[];
addressReference: AddressReference | null; addressReference: AddressReference | null;
validFrom: DateTime; validFrom: DateTime;
validTo: DateTime | null; validTo: DateTime | null;
point: Point | null; point: Point | null;
refStatus: AddressRefStatus; refStatus: AddressRefStatus;
isNoAddress: boolean; isNoAddress: boolean;
} }
export interface AddressWithPoint extends Address { export interface AddressWithPoint extends Address {
point: Point point: Point;
} }
export interface AddressReference { export interface AddressReference {
id: number; id: number;
createdAt: DateTime | null; createdAt: DateTime | null;
deletedAt: DateTime | null; deletedAt: DateTime | null;
municipalityCode: string; municipalityCode: string;
point: Point; point: Point;
postcode: Postcode; postcode: Postcode;
refId: string; refId: string;
source: string; source: string;
street: string; street: string;
streetNumber: string; streetNumber: string;
updatedAt: DateTime | null; updatedAt: DateTime | null;
} }
export interface SimpleGeographicalUnit { export interface SimpleGeographicalUnit {
id: number; id: number;
layerId: number; layerId: number;
unitName: string; unitName: string;
unitRefId: string; unitRefId: string;
} }
export interface GeographicalUnitLayer { export interface GeographicalUnitLayer {
id: number; id: number;
name: TranslatableString; name: TranslatableString;
refId: string; refId: string;
} }
export interface Location { export interface Location {
type: "location"; type: "location";
id: number; id: number;
active: boolean; active: boolean;
address: Address | null; address: Address | null;
availableForUsers: boolean; availableForUsers: boolean;
createdAt: DateTime | null; createdAt: DateTime | null;
createdBy: User | null; createdBy: User | null;
updatedAt: DateTime | null; updatedAt: DateTime | null;
updatedBy: User | null; updatedBy: User | null;
email: string | null email: string | null;
name: string; name: string;
phonenumber1: string | null; phonenumber1: string | null;
phonenumber2: string | null; phonenumber2: string | null;
locationType: LocationType; locationType: LocationType;
} }
export interface LocationAssociated { export interface LocationAssociated {
type: "location"; type: "location";
id: number; id: number;
} }
export interface LocationType { export interface LocationType {
type: "location-type"; type: "location-type";
id: number; id: number;
active: boolean; active: boolean;
addressRequired: "optional" | "required"; addressRequired: "optional" | "required";
availableForUsers: boolean; availableForUsers: boolean;
editableByUsers: boolean; editableByUsers: boolean;
contactData: "optional" | "required"; contactData: "optional" | "required";
title: TranslatableString; title: TranslatableString;
} }
export interface NewsItemType { export interface NewsItemType {

View File

@ -62,7 +62,7 @@ export default {
required: false, required: false,
} }
}, },
emits: ['addNewEntity', 'removeEntity'], emits: ['addNewEntity', 'removeEntity', 'addNewEntityProcessEnded'],
components: { components: {
AddPersons, AddPersons,
}, },
@ -121,6 +121,7 @@ export default {
); );
this.$refs.addPersons.resetSearch(); // to cast child method this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false; modal.showModal = false;
this.$emit('addNewEntityProcessEnded');
}, },
removeEntity(entity) { removeEntity(entity) {
if (!this.$props.removableIfSet) { if (!this.$props.removableIfSet) {

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import {UserGroup} from "../../../types";
import {computed} from "vue";
interface UserGroupRenderBoxProps {
userGroup: UserGroup;
}
const props = defineProps<UserGroupRenderBoxProps>();
const styles = computed<{color: string, "background-color": string}>(() => {
return {
color: props.userGroup.foregroundColor,
"background-color": props.userGroup.backgroundColor,
}
});
</script>
<template>
<span class="badge-user-group" :style="styles">{{ userGroup.label.fr }}</span>
</template>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1 @@
<span class="badge-user-group" style="color: {{ user_group.foregroundColor }}; background-color: {{ user_group.backgroundColor }};">{{ user_group.label|localize_translatable_string }}</span>

View File

@ -60,7 +60,7 @@
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false"> aria-expanded="false">
<a href="#" class="more">{{ app.request.locale | capitalize }}</a> {{ app.request.locale | capitalize }}
</a> </a>
<div class="dropdown-menu dropdown-menu-end" <div class="dropdown-menu dropdown-menu-end"
aria-labelledby="menu-languages"> aria-labelledby="menu-languages">

View File

@ -0,0 +1,21 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock admin_content %}

View File

@ -0,0 +1,86 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block table_entities %}
<div class="flex-table">
{% for entity in entities %}
<div class="item-bloc">
<div class="item-row">
<div class="wrap-header">
<div class="wh-row">
<div class="wh-col">
{{ entity|chill_entity_render_box }}
</div>
<div class="wh-col">
{%- if not entity.active -%}
<div>
<span class="badge bg-danger">{{ 'user_group.inactive'|trans }}</span>
</div>&nbsp;
{%- endif -%}
<div>{{ 'user_group.with_count_users'|trans({'count': entity.users|length}) }}</div>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title">
<strong>{{ 'user_group.with_users'|trans }}</strong>
</div>
<div class="wl-col list">
{% for user in entity.userListByLabelAscending %}
<p class="wl-item">
<span class="badge-user">
{{ user|chill_entity_render_box }}
</span>
</p>
{% else %}
<p class="wl-item chill-no-data-statement">{{ 'user_group.no_users'|trans }}</p>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title">
<strong>{{ 'user_group.adminUsers'|trans }}</strong>
</div>
<div class="wl-col list">
{% for user in entity.adminUserListByLabelAscending %}
<p class="wl-item">
<span class="badge-user">
{{ user|chill_entity_render_box }}
</span>
</p>
{% else %}
<p class="wl-item chill-no-data-statement">{{ 'user_group.no_admin_users'|trans }}</p>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="item-row separator">
<ul class="record_actions slim">
<li>
<a href="{{ chill_path_add_return_path('chill_crud_admin_user_group_edit', {'id': entity.id}) }}" class="btn btn-edit"></a>
</li>
</ul>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block actions_before %}
<li class='cancel'>
<a href="{{ path('chill_main_admin_central') }}" class="btn btn-cancel">{{'Back to the admin'|trans }}</a>
</li>
{% endblock %}
{% endembed %}
{% endblock %}

View File

@ -0,0 +1,160 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
<style type="text/css">
form.remove {
display: inline-block;
padding: 1px;
border: 1px solid transparent;
border-radius: 4px;
}
form:hover {
animation-duration: 0.5s;
animation-name: onHover;
animation-iteration-count: 1;
border: 1px solid #dee2e6;
border-radius: 4px;
}
form.remove button.remove {
display: inline;
background-color: unset;
border: none;
color: var(--bs-chill-red);
}
@keyframes onHover {
from {
border: 1px solid transparent;
}
to {
border: 1px solid #dee2e6;
}
}
</style>
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block title 'user_group.my_groups'|trans %}
{% block content %}
<h1>{{ block('title') }}</h1>
{% if paginator.totalItems == 0 %}
<p>{{ 'user_group.no_user_groups'|trans }}</p>
{% else %}
<div class="flex-table">
{% for entity in groups %}
<div class="item-bloc">
<div class="item-row">
<div class="wrap-header">
<div class="wh-row">
<div class="wh-col">
{{ entity|chill_entity_render_box }}
</div>
<div class="wh-col">
{%- if not entity.active -%}
<div>
<span class="badge bg-danger">{{ 'user_group.inactive'|trans }}</span>
</div>&nbsp;
{%- endif -%}
<div>{{ 'user_group.with_count_users'|trans({'count': entity.users|length}) }}</div>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title">
<strong>{{ 'user_group.with_users'|trans }}</strong>
</div>
<div class="wl-col list">
{% if entity.users.contains(app.user) %}
{% if is_granted('CHILL_MAIN_USER_GROUP_APPEND_TO_GROUP', entity) %}
<form class="remove" method="POST" action="{{ chill_path_add_return_path('chill_main_user_groups_remove_user', {'id': entity.id, 'userId': app.user.id}) }}">
<p class="wl-item">
{{ 'user_group.me'|trans }}
<button class="remove" type="submit"><i class="fa fa-times"></i></button>
</p>
</form>
{% else %}
<p class="wl-item">
{% if entity.users|length > 1 %}{{ 'user_group.me_and'|trans }}{% else %}{{ 'user_group.me_only'|trans }}{% endif %}
</p>
{% endif %}
{% endif %}
{% for user in entity.userListByLabelAscending %}
{% if user is not same as app.user %}
{% if is_granted('CHILL_MAIN_USER_GROUP_APPEND_TO_GROUP', entity) %}
<form class="remove" method="POST" action="{{ chill_path_add_return_path('chill_main_user_groups_remove_user', {'id': entity.id, 'userId': user.id}) }}">
<p class="wl-item">
<span class="badge-user">
{{ user|chill_entity_render_box }}
</span>
<button class="remove" type="submit"><i class="fa fa-times"></i></button>
</p>
</form>
{% else %}
<p class="wl-item">
<span class="badge-user">
{{ user|chill_entity_render_box }}
</span>
</p>
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% if entity.adminUsers|length > 0 %}
<div class="item-row separator">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title">
<strong>{{ 'user_group.adminUsers'|trans }}</strong>
</div>
<div class="wl-col list">
{% if entity.adminUsers.contains(app.user) %}
<p class="wl-item">{% if entity.adminUsers|length > 1 %}{{ 'user_group.me_and'|trans }}{% else %}{{ 'user_group.me_only'|trans }}{% endif %}</p>
{% endif %}
{% for user in entity.adminUserListByLabelAscending %}
{% if user is not same as app.user %}
<p class="wl-item">
<span class="badge-user">
{{ user|chill_entity_render_box }}
</span>
</p>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% endif -%}
{%- set form = forms.offsetGet(entity) %}
{%- if form is not null -%}
<div class="item-row separator">
<ul class="record_actions slim">
<li>
{{- form_start(form) -}}
{{- form_widget(form.users) -}}
{{- form_end(form) -}}
</li>
</ul>
</div>
{%- endif %}
</div>
{% endfor %}
</div>
{{ chill_pagination(paginator) }}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block title %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock admin_content %}

View File

@ -82,17 +82,26 @@
{{ form_row(transition_form.futureCcUsers) }} {{ form_row(transition_form.futureCcUsers) }}
{{ form_errors(transition_form.futureCcUsers) }} {{ form_errors(transition_form.futureCcUsers) }}
</div> </div>
<div id="future-dest-emails">
{{ form_row(transition_form.futureDestEmails) }}
{{ form_errors(transition_form.futureDestEmails) }}
</div>
</div> </div>
<p>{{ form_label(transition_form.comment) }}</p> <p>{{ form_label(transition_form.comment) }}</p>
{{ form_widget(transition_form.comment) }} {{ form_widget(transition_form.comment) }}
<ul class="record_actions"> <ul class="record_actions sticky-form-buttons">
{% if entity_workflow.isOnHoldByUser(app.user) %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_remove_hold', {'id': entity_workflow.currentStep.id }) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Remove hold'|trans }}
</a>
</li>
{% else %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_on_hold', {'id': entity_workflow.id}) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Put on hold'|trans }}
</a>
</li>
{% endif %}
<li> <li>
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button> <button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
</li> </li>
@ -115,15 +124,6 @@
</ul> </ul>
{% endif %} {% endif %}
{% if entity_workflow.currentStep.destEmail|length > 0 %}
<p><b>{{ 'workflow.An access key was also sent to those addresses'|trans }}&nbsp;:</b></p>
<ul>
{% for e in entity_workflow.currentStep.destEmail -%}
<li><a href="mailto:{{ e|escape('html_attr') }}">{{ e }}</a></li>
{%- endfor %}
</ul>
{% endif %}
{% if entity_workflow.currentStep.destUserByAccessKey|length > 0 %} {% if entity_workflow.currentStep.destUserByAccessKey|length > 0 %}
<p><b>{{ 'workflow.Those users are also granted to apply a transition by using an access key'|trans }}&nbsp;:</b></p> <p><b>{{ 'workflow.Those users are also granted to apply a transition by using an access key'|trans }}&nbsp;:</b></p>
<ul> <ul>

View File

@ -13,22 +13,22 @@
{{ 'workflow.No transitions'|trans }} {{ 'workflow.No transitions'|trans }}
</div> </div>
{% else %} {% else %}
<div class="item-col"> <div class="item-col">
{% if step.previous is not null and step.previous.freezeAfter == true %} {% if step.previous is not null and step.previous.freezeAfter == true %}
<i class="fa fa-snowflake-o fa-sm me-1" title="{{ 'workflow.Freezed'|trans }}"></i> <i class="fa fa-snowflake-o fa-sm me-1" title="{{ 'workflow.Freezed'|trans }}"></i>
{% endif %} {% endif %}
{% if loop.last %}
{% if entity_workflow.isOnHoldAtCurrentStep %}
{% for hold in step.holdsOnStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold by'|trans({'by': hold.byUser|chill_entity_render_string})|escape('html_attr') }}">{{ 'workflow.On hold by'|trans({'by': hold.byUser|chill_entity_render_string}) }}</span>
{% endfor %}
{% endif %}
{% endif %}
</div> </div>
<div class="item-col flex-column align-items-end"> <div class="item-col flex-column align-items-end">
<div class="decided"> <div class="decided">
{{ place_label }} {{ place_label }}
</div> </div>
{#
<div class="decided">
<i class="fa fa-times fa-fw text-danger"></i>
Refusé
</div>
#}
</div> </div>
{% endif %} {% endif %}
@ -71,19 +71,33 @@
</blockquote> </blockquote>
</div> </div>
{% endif %} {% endif %}
{% if loop.last and step.allDestUser|length > 0 %} {% if not loop.last and step.signatures|length > 0 %}
<div class="separator">
<div>
<p><b>{{ 'workflow.signature_required_title'|trans({'nb_signatures': step.signatures|length}) }}&nbsp;:</b></p>
<div>
{{ include('@ChillMain/Workflow/_signature_list.html.twig', {'signatures': step.signatures, is_small: true }) }}
</div>
</div>
</div>
{% endif %}
{% if loop.last and not step.isFinal %}
<div class="item-row separator"> <div class="item-row separator">
<div> <div>
{% if step.destUser|length > 0 %} {% if step.destUser|length > 0 or step.destUserGroups|length > 0 %}
<p><b>{{ 'workflow.Users allowed to apply transition'|trans }}&nbsp;: </b></p> <p><b>{{ 'workflow.Users allowed to apply transition'|trans }}&nbsp;: </b></p>
<ul> <ul>
{% for u in step.destUser %} {% for u in step.destUser %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }} <li>
{% if entity_workflow.isOnHoldAtCurrentStep %} <span class="badge-user">{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</span>
{% if step.isOnHoldByUser(u) %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span> <span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
{% for u in step.destUserGroups %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</li>
{% endfor %}
</ul> </ul>
{% endif %} {% endif %}
@ -115,6 +129,10 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if step.signatures|length > 0 %}
<p><b>{{ 'workflow.signature_required_title'|trans({'nb_signatures': step.signatures|length}) }}&nbsp;:</b></p>
{{ include('@ChillMain/Workflow/_signature_list.html.twig', {'signatures': step.signatures, is_small: true }) }}
{% endif %}
{% endif %} {% endif %}
</div> </div>

View File

@ -1,54 +1,3 @@
<h2>{{ 'workflow.signature_zone.title'|trans }}</h2> <h2>{{ 'workflow.signature_required_title'|trans({'nb_signatures': signatures|length}) }}</h2>
<div class="container">
{% for s in signatures %}
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-5">
{% 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 %}
</div>
<div class="col-sm-12 col-md-7 text-end">
{% if s.isSigned %}
<span class="text-end">{{ 'workflow.signature.signed_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% elseif s.isCanceled %}
<span class="text-end">{{ 'workflow.signature.canceled_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% elseif s.isRejected%}
<span class="text-end">{{ 'workflow.signature.rejected_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% else %}
{% if (is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) or is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s)) %}
<ul class="record_actions slim">
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_REJECT', s) %}
<li>
<a class="btn btn-remove" href="{{ chill_path_add_return_path('chill_main_workflow_signature_reject', { 'id': s.id}) }}">{{ 'workflow.signature_zone.button_reject'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) %}
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_cancel', { 'id': s.id}) }}">{{ 'workflow.signature_zone.button_cancel'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s) %}
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a>
</li>
{% endif %}
</ul>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{{ include('@ChillMain/Workflow/_signature_list.html.twig') }}

View File

@ -0,0 +1,52 @@
<div class="container">
{% for s in signatures %}
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-5">
{% 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 %}
</div>
<div class="col-sm-12 col-md-7 text-end">
{% if s.isSigned %}
<span class="text-end">{{ 'workflow.signature.signed_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% elseif s.isCanceled %}
<span class="text-end">{{ 'workflow.signature.canceled_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% elseif s.isRejected%}
<span class="text-end">{{ 'workflow.signature.rejected_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% else %}
{% if (is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) or is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s)) %}
<ul class="record_actions slim {% if is_small|default(false) %}small{% endif %}">
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_REJECT', s) %}
<li>
<a class="btn btn-remove" href="{{ chill_path_add_return_path('chill_main_workflow_signature_reject', { 'id': s.id}) }}">{{ 'workflow.signature_zone.button_reject'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) %}
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_cancel', { 'id': s.id}) }}">{{ 'workflow.signature_zone.button_cancel'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s) %}
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a>
</li>
{% endif %}
</ul>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>

View File

@ -62,26 +62,12 @@
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
{% if signatures|length > 0 %} {% if signatures|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% else %}
<section class="step my-4">{% include '@ChillMain/Workflow/_decision.html.twig' %}</section>
{% endif %} {% endif %}
<section class="step my-4">{% include '@ChillMain/Workflow/_decision.html.twig' %}</section>{# {# <section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
<section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
<section class="step my-4">{% include '@ChillMain/Workflow/_history.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_history.html.twig' %}</section>
<ul class="record_actions sticky-form-buttons">
{% if entity_workflow.isOnHoldByUser(app.user) %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_remove_hold', {'id': entity_workflow.currentStep.id }) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Remove hold'|trans }}
</a>
</li>
{% else %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_on_hold', {'id': entity_workflow.id}) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Put on hold'|trans }}
</a>
</li>
{% endif %}
</ul>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -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,

View File

@ -1 +0,0 @@
Un suivi {{ workflow.text }} demande votre attention: {{ entityTitle }}

View File

@ -49,19 +49,19 @@
{% for flashMessage in app.session.flashbag.get('success') %} {% for flashMessage in app.session.flashbag.get('success') %}
<div class="alert alert-success flash_message"> <div class="alert alert-success flash_message">
<span>{{ flashMessage|raw }}</span> <span>{{ flashMessage|trans }}</span>
</div> </div>
{% endfor %} {% endfor %}
{% for flashMessage in app.session.flashbag.get('error') %} {% for flashMessage in app.session.flashbag.get('error') %}
<div class="alert alert-danger flash_message"> <div class="alert alert-danger flash_message">
<span>{{ flashMessage|raw }}</span> <span>{{ flashMessage|trans }}</span>
</div> </div>
{% endfor %} {% endfor %}
{% for flashMessage in app.session.flashbag.get('notice') %} {% for flashMessage in app.session.flashbag.get('notice') %}
<div class="alert alert-warning flash_message"> <div class="alert alert-warning flash_message">
<span>{{ flashMessage|raw }}</span> <span>{{ flashMessage|trans }}</span>
</div> </div>
{% endfor %} {% endfor %}

View File

@ -27,7 +27,7 @@ final readonly class ChillUrlGenerator implements ChillUrlGeneratorInterface
{ {
$uri = $this->requestStack->getCurrentRequest()->getRequestUri(); $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 public function returnPathOr(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string

View File

@ -67,6 +67,10 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface
'route' => 'chill_crud_admin_user_index', 'route' => 'chill_crud_admin_user_index',
])->setExtras(['order' => 1040]); ])->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', [ $menu->addChild('User jobs', [
'route' => 'chill_crud_admin_user_job_index', 'route' => 'chill_crud_admin_user_job_index',
])->setExtras(['order' => 1050]); ])->setExtras(['order' => 1050]);

View File

@ -60,7 +60,6 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
$nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user); $nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user);
// TODO add an icon? How exactly? For example a clock icon...
$menu $menu
->addChild($this->translator->trans('absence.Set absence date'), [ ->addChild($this->translator->trans('absence.Set absence date'), [
'route' => 'chill_main_user_absence_index', 'route' => 'chill_main_user_absence_index',
@ -69,6 +68,14 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
'order' => -8_888_888, '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 $menu
->addChild( ->addChild(
$this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]), $this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]),

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Search\Entity;
use Chill\MainBundle\Repository\UserGroupRepositoryInterface;
use Chill\MainBundle\Search\SearchApiInterface;
use Chill\MainBundle\Search\SearchApiQuery;
use Symfony\Contracts\Translation\LocaleAwareInterface;
/**
* Provide search api for user group.
*/
class SearchUserGroupApiProvider implements SearchApiInterface, LocaleAwareInterface
{
private string $locale;
public function __construct(private readonly UserGroupRepositoryInterface $userGroupRepository) {}
public function setLocale(string $locale): void
{
$this->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);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
final class UserGroupVoter extends Voter
{
public const APPEND_TO_GROUP = 'CHILL_MAIN_USER_GROUP_APPEND_TO_GROUP';
public function __construct(private readonly Security $security) {}
protected function supports(string $attribute, $subject)
{
return self::APPEND_TO_GROUP === $attribute && $subject instanceof UserGroup;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token)
{
/* @var UserGroup $subject */
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return false;
}
return $subject->getAdminUsers()->contains($user);
}
}

View File

@ -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 = []) public function supportsDenormalization($data, $type, $format = null, array $context = [])

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Repository\UserGroupRepositoryInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
class UserGroupDenormalizer implements DenormalizerInterface
{
public function __construct(private readonly UserGroupRepositoryInterface $userGroupRepository) {}
public function denormalize($data, string $type, ?string $format = null, array $context = []): ?UserGroup
{
return $this->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))
;
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Templating\Entity\UserGroupRenderInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class UserGroupNormalizer implements NormalizerInterface
{
public function __construct(private readonly UserGroupRenderInterface $userGroupRender) {}
public function normalize($object, ?string $format = null, array $context = [])
{
/* @var UserGroup $object */
return [
'type' => '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;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Templating\Entity;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Twig\Environment;
final readonly class UserGroupRender implements UserGroupRenderInterface
{
public function __construct(private TranslatableStringHelperInterface $translatableStringHelper, private Environment $environment) {}
public function renderBox($entity, array $options): string
{
/* @var $entity UserGroup */
return $this->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;
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Templating\Entity;
interface UserGroupRenderInterface extends ChillEntityRenderInterface {}

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace ChillMainBundle\Tests\Repository; namespace ChillMainBundle\Tests\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@ -43,4 +44,14 @@ class EntityWorkflowRepositoryTest extends KernelTestCase
self::assertIsArray($actual, 'check that the query is successful'); self::assertIsArray($actual, 'check that the query is successful');
} }
public function testCountQueryByDest(): void
{
$repository = new EntityWorkflowRepository($this->em);
$user = $this->em->createQuery(sprintf('SELECT u FROM %s u', User::class))
->setMaxResults(1)->getSingleResult();
$actual = $repository->countByDest($user);
self::assertIsInt($actual);
}
} }

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace ChillMainBundle\Tests\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class EntityWorkflowStepRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->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);
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Repository\UserGroupRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class UserGroupRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->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);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Serializer\Normalizer;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Repository\UserGroupRepositoryInterface;
use Chill\MainBundle\Serializer\Normalizer\UserGroupDenormalizer;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class UserGroupDenormalizerTest extends TestCase
{
/**
* @throws \PHPUnit\Framework\MockObject\Exception
*
* @dataProvider provideSupportsDenormalization
*/
public function testSupportsDenormalization($data, string $type, bool $expected): void
{
$repository = $this->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);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Serializer\Normalizer;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Serializer\Normalizer\UserGroupNormalizer;
use Chill\MainBundle\Templating\Entity\UserGroupRenderInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
/**
* @internal
*
* @coversNothing
*/
class UserGroupNormalizerTest extends TestCase
{
public function testNormalize()
{
$userGroup = new UserGroup();
$userGroup
->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);
}
}

View File

@ -44,7 +44,6 @@ class EntityWorkflowMarkingStoreTest extends TestCase
$dto = new WorkflowTransitionContextDTO($workflow); $dto = new WorkflowTransitionContextDTO($workflow);
$dto->futureCcUsers[] = $user1 = new User(); $dto->futureCcUsers[] = $user1 = new User();
$dto->futureDestUsers[] = $user2 = new User(); $dto->futureDestUsers[] = $user2 = new User();
$dto->futureDestEmails[] = $email = 'test@example.com';
$markingStore->setMarking($workflow, new Marking(['foo' => 1]), [ $markingStore->setMarking($workflow, new Marking(['foo' => 1]), [
'context' => $dto, 'context' => $dto,
@ -55,7 +54,6 @@ class EntityWorkflowMarkingStoreTest extends TestCase
$currentStep = $workflow->getCurrentStep(); $currentStep = $workflow->getCurrentStep();
self::assertEquals('foo', $currentStep->getCurrentStep()); self::assertEquals('foo', $currentStep->getCurrentStep());
self::assertContains($email, $currentStep->getDestEmail());
self::assertContains($user1, $currentStep->getCcUser()); self::assertContains($user1, $currentStep->getCcUser());
self::assertContains($user2, $currentStep->getDestUser()); self::assertContains($user2, $currentStep->getDestUser());

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\EventSubscriber; namespace Chill\MainBundle\Tests\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter; use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
use Chill\MainBundle\Templating\Entity\UserRender; 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([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, false, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'];
yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, true, '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 public static function provideValidTransition(): iterable
@ -159,6 +165,10 @@ class EntityWorkflowGuardTransitionTest extends TestCase
// transition allowed thanks to permission "apply all transitions" // transition allowed thanks to permission "apply all transitions"
yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), true, 'step1']; yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), true, 'step1'];
yield [self::buildEntityWorkflow([new User()]), 'transition2', new User(), true, 'step2']; 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 public static function buildEntityWorkflow(array $futureDestUsers): EntityWorkflow

View File

@ -13,6 +13,7 @@ namespace Chill\MainBundle\Tests\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Workflow\EventSubscriber\NotificationOnTransition; use Chill\MainBundle\Workflow\EventSubscriber\NotificationOnTransition;
@ -20,8 +21,6 @@ use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Call\Call;
use Prophecy\Exception\Prediction\FailedPredictionException;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\Event; use Symfony\Component\Workflow\Event\Event;
@ -57,29 +56,23 @@ final class NotificationOnTransitionTest extends TestCase
$id->setValue($entityWorkflow, 1); $id->setValue($entityWorkflow, 1);
$step = new EntityWorkflowStep(); $step = new EntityWorkflowStep();
$userGroup = (new UserGroup())->addUser($userInGroup = new User())->addUser($dest);
$entityWorkflow->addStep($step); $entityWorkflow->addStep($step);
$step->addDestUser($dest) $step
->addDestUser($dest)
->addDestUserGroup($userGroup)
->setCurrentStep('to_state'); ->setCurrentStep('to_state');
$em = $this->prophesize(EntityManagerInterface::class); $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]; // we check that both notification has been persisted once,
// eliminating doublons
if (!$notification instanceof Notification) { $em->persist(Argument::that(
throw new FailedPredictionException('persist is not a notification'); fn ($notificationCandidate) => $notificationCandidate instanceof Notification && $notificationCandidate->getAddressees()->contains($dest)
} ))->shouldBeCalledOnce();
$em->persist(Argument::that(
if (!$notification->getAddressees()->contains($dest)) { fn ($notificationCandidate) => $notificationCandidate instanceof Notification && $notificationCandidate->getAddressees()->contains($userInGroup)
throw new FailedPredictionException('the dest is not notified'); ))->shouldBeCalledOnce();
}
}
);
$engine = $this->prophesize(\Twig\Environment::class); $engine = $this->prophesize(\Twig\Environment::class);
$engine->render(Argument::type('string'), Argument::type('array')) $engine->render(Argument::type('string'), Argument::type('array'))

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UserGroupDoNotExclude extends Constraint
{
public string $message = 'The groups {{ excluded_groups }} do exclude themselves. Please choose one between them';
public string $code = 'e16c8226-0090-11ef-8560-f7239594db09';
public function getTargets()
{
return [self::PROPERTY_CONSTRAINT];
}
public function validatedBy()
{
return \Chill\MainBundle\Validation\Validator\UserGroupDoNotExclude::class;
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Validation\Validator;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class UserGroupDoNotExclude extends ConstraintValidator
{
public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude) {
throw new UnexpectedTypeException($constraint, UserGroupDoNotExclude::class);
}
if (null === $value) {
return;
}
if (!is_iterable($value)) {
throw new UnexpectedValueException($value, 'iterable');
}
$groups = [];
foreach ($value as $gr) {
if ($gr instanceof UserGroup) {
$groups[$gr->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();
}
}
}
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\Counter; namespace Chill\MainBundle\Workflow\Counter;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepRepository;
use Chill\MainBundle\Templating\UI\NotificationCounterInterface; use Chill\MainBundle\Templating\UI\NotificationCounterInterface;
@ -82,6 +83,18 @@ final readonly class WorkflowByUserCounter implements NotificationCounterInterfa
foreach ($step->getDestUser() as $user) { foreach ($step->getDestUser() as $user) {
$keys[] = self::generateCacheKeyWorkflowByUser($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) { if ([] !== $keys) {
$this->cacheItemPool->deleteItems($keys); $this->cacheItemPool->deleteItems($keys);

View File

@ -87,6 +87,17 @@ class EntityWorkflowGuardTransition implements EventSubscriberInterface
return; 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 // for users
if (!in_array('only-dest', $systemTransitions, true)) { if (!in_array('only-dest', $systemTransitions, true)) {
$event->addTransitionBlocker( $event->addTransitionBlocker(
@ -108,6 +119,13 @@ class EntityWorkflowGuardTransition implements EventSubscriberInterface
return; 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( $event->addTransitionBlocker(new TransitionBlocker(
'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%', 'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%',
'f3eeb57c-7532-11ec-9495-e7942a2ac7bc', 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc',

View File

@ -13,6 +13,7 @@ namespace Chill\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -69,7 +70,18 @@ class NotificationOnTransition implements EventSubscriberInterface
// the dests for the current step // the dests for the current step
$entityWorkflow->getCurrentStep()->getDestUser()->toArray(), $entityWorkflow->getCurrentStep()->getDestUser()->toArray(),
// the cc users for the current step // 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) { ) as $dest) {
$dests[spl_object_hash($dest)] = $dest; $dests[spl_object_hash($dest)] = $dest;
} }

View File

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Workflow\Registry;
class SendAccessKeyEventSubscriber
{
public function __construct(
private readonly \Twig\Environment $engine,
private readonly MetadataExtractor $metadataExtractor,
private readonly Registry $registry,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly MailerInterface $mailer,
) {}
public function postPersist(EntityWorkflowStep $step): void
{
$entityWorkflow = $step->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);
}
}
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow; namespace Chill\MainBundle\Workflow;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@ -24,34 +25,23 @@ use Symfony\Component\Workflow\Transition;
class WorkflowTransitionContextDTO 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 * 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 * the next step. This is necessary to perform some computation about the next users, before they are
* associated to the entity EntityWorkflowStep. * associated to the entity EntityWorkflowStep.
* *
* @var array|User[] * @var list<User|UserGroup>
*/ */
public array $futureDestUsers = []; 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[] * @var array|User[]
*/ */
public array $futureCcUsers = []; 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. * A list of future @see{Person} with will sign the next step.
* *
@ -72,6 +62,22 @@ class WorkflowTransitionContextDTO
public EntityWorkflow $entityWorkflow, public EntityWorkflow $entityWorkflow,
) {} ) {}
/**
* @return list<User>
*/
public function getFutureDestUsers(): array
{
return array_values(array_filter($this->futureDestUsers, fn (User|UserGroup $user) => $user instanceof User));
}
/**
* @return list<UserGroup>
*/
public function getFutureDestUserGroups(): array
{
return array_values(array_filter($this->futureDestUsers, fn (User|UserGroup $user) => $user instanceof UserGroup));
}
#[Assert\Callback()] #[Assert\Callback()]
public function validateCCUserIsNotInDest(ExecutionContextInterface $context, $payload): void public function validateCCUserIsNotInDest(ExecutionContextInterface $context, $payload): void
{ {

View File

@ -5,8 +5,8 @@ info:
title: "Chill api" title: "Chill api"
description: "Api documentation for chill. Currently, work in progress" description: "Api documentation for chill. Currently, work in progress"
servers: servers:
- url: "/api" - url: "/api"
description: "Your current dev server" description: "Your current dev server"
components: components:
schemas: schemas:
@ -29,6 +29,42 @@ components:
type: string type: string
text: text:
type: string 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: Center:
type: object type: object
properties: properties:
@ -181,25 +217,26 @@ paths:
The results are ordered by relevance, from the most to the lowest relevant. The results are ordered by relevance, from the most to the lowest relevant.
parameters: parameters:
- name: q - name: q
in: query in: query
required: true required: true
description: the pattern to search description: the pattern to search
schema: schema:
type: string type: string
- name: type[] - name: type[]
in: query in: query
required: true required: true
description: the type entities amongst the search is performed description: the type entities amongst the search is performed
schema: schema:
type: array type: array
items: items:
type: string type: string
enum: enum:
- person - person
- thirdparty - thirdparty
- user - user
- household - household
- user-group
responses: responses:
200: 200:
description: "OK" description: "OK"
@ -236,7 +273,7 @@ paths:
minItems: 2 minItems: 2
maxItems: 2 maxItems: 2
postcode: postcode:
$ref: "#/components/schemas/PostalCode" $ref: '#/components/schemas/PostalCode'
steps: steps:
type: string type: string
street: street:
@ -260,14 +297,14 @@ paths:
- address - address
summary: Return an address by id summary: Return an address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id description: The address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -284,14 +321,14 @@ paths:
- address - address
summary: patch an address summary: patch an address
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id description: The address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
requestBody: requestBody:
required: true required: true
content: content:
@ -349,14 +386,14 @@ paths:
- address - address
summary: Duplicate an existing address summary: Duplicate an existing address
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id that will be duplicated description: The address id that will be duplicated
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -375,12 +412,12 @@ paths:
- address - address
summary: Return a list of all reference addresses summary: Return a list of all reference addresses
parameters: parameters:
- in: query - in: query
name: postal_code name: postal_code
required: false required: false
schema: schema:
type: integer type: integer
description: The id of a postal code to filter the reference addresses description: The id of a postal code to filter the reference addresses
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -390,14 +427,14 @@ paths:
- address - address
summary: Return a reference address by id summary: Return a reference address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The reference address id description: The reference address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -417,20 +454,20 @@ paths:
- search - search
summary: Return a reference address by id summary: Return a reference address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The reference address id description: The reference address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
- name: q - name: q
in: query in: query
required: true required: true
description: The search pattern description: The search pattern
schema: schema:
type: string type: string
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -450,12 +487,12 @@ paths:
- address - address
summary: Return a list of all postal-code summary: Return a list of all postal-code
parameters: parameters:
- in: query - in: query
name: country name: country
required: false required: false
schema: schema:
type: integer type: integer
description: The id of a country to filter the postal code description: The id of a country to filter the postal code
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -494,14 +531,14 @@ paths:
- address - address
summary: Return a postal code by id summary: Return a postal code by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The postal code id description: The postal code id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -521,18 +558,18 @@ paths:
- search - search
summary: Search a postal code summary: Search a postal code
parameters: parameters:
- name: q - name: q
in: query in: query
required: true required: true
description: The search pattern description: The search pattern
schema: schema:
type: string type: string
- name: country - name: country
in: query in: query
required: false required: false
description: The country id description: The country id
schema: schema:
type: integer type: integer
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -559,14 +596,14 @@ paths:
- address - address
summary: Return a country by id summary: Return a country by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The country id description: The country id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -609,14 +646,14 @@ paths:
- user - user
summary: Return a user by id summary: Return a user by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The user id description: The user id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -646,14 +683,14 @@ paths:
- scope - scope
summary: return a list of scopes summary: return a list of scopes
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The scope id description: The scope id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -721,14 +758,14 @@ paths:
- location - location
summary: Return the given location summary: Return the given location
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The location id description: The location id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -788,14 +825,14 @@ paths:
- notification - notification
summary: mark a notification as read summary: mark a notification as read
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The notification id description: The notification id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
202: 202:
description: "accepted" description: "accepted"
@ -807,14 +844,14 @@ paths:
- notification - notification
summary: mark a notification as unread summary: mark a notification as unread
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The notification id description: The notification id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
202: 202:
description: "accepted" description: "accepted"
@ -846,7 +883,7 @@ paths:
type: array type: array
items: items:
type: integer type: integer
example: [1, 2, 3] # Example array of IDs example: [ 1, 2, 3 ] # Example array of IDs
responses: responses:
"202": "202":
description: Notifications marked as unread successfully description: Notifications marked as unread successfully
@ -934,6 +971,22 @@ paths:
schema: schema:
type: array type: array
items: 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: 403:
description: "Unauthorized" description: "Unauthorized"

View File

@ -42,17 +42,6 @@ services:
arguments: arguments:
$handlers: !tagged_iterator chill_main.workflow_handler $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 # other stuffes
chill.main.helper.translatable_string: chill.main.helper.translatable_string:

View File

@ -56,6 +56,10 @@ services:
Chill\MainBundle\Templating\Entity\UserRender: ~ 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\: Chill\MainBundle\Templating\Listing\:
resource: './../../Templating/Listing' resource: './../../Templating/Listing'

View File

@ -3,6 +3,9 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\MainBundle\Validation\:
resource: '../../Validation'
chill_main.validator_user_circle_consistency: chill_main.validator_user_circle_consistency:
class: Chill\MainBundle\Validator\Constraints\Entity\UserCircleConsistencyValidator class: Chill\MainBundle\Validator\Constraints\Entity\UserCircleConsistencyValidator
arguments: arguments:

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240416145021 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create tables for user_group';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240422091752 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add colors and exclude string to user groups';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240926132856 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a relation between entityworkflow step and user groups';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240927095751 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add admin users and active on user groups';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@ -5,6 +5,17 @@ years_old: >-
other {# ans} 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: notification:
My notifications with counter: >- My notifications with counter: >-
{nb, plural, {nb, plural,
@ -47,9 +58,15 @@ workflow:
} }
signature: signature:
signed_statement: 'Signature appliquée le {datetime, date, short} à {datetime, time, short}' 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}' 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: duration:
minute: >- minute: >-

View File

@ -51,6 +51,25 @@ user:
no job: Pas de métier assigné no job: Pas de métier assigné
no scope: Pas de cercle 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 inactive: inactif
Edit: Modifier Edit: Modifier
@ -395,6 +414,12 @@ crud:
add_new: Créer add_new: Créer
title_new: Nouveau métier title_new: Nouveau métier
title_edit: Modifier un 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: main_location_type:
index: index:
title: Liste des types de localisations title: Liste des types de localisations

View File

@ -3,8 +3,10 @@
*/ */
span.badge-user, span.badge-user,
span.badge-user-group,
span.badge-person, span.badge-person,
span.badge-thirdparty { span.badge-thirdparty {
margin: 0.2rem 0.1rem;
display: inline-block; display: inline-block;
padding: 0 0.5em !important; padding: 0 0.5em !important;
background-color: $white; background-color: $white;
@ -18,6 +20,11 @@ span.badge-thirdparty {
} }
} }
span.badge-user-group {
font-weight: 600;
border-width: 0px;
}
span.badge-user { span.badge-user {
border-bottom-width: 1px; border-bottom-width: 1px;
&.system { &.system {
@ -231,6 +238,10 @@ div[class*='budget-'] {
background-color: $chill-ll-gray; background-color: $chill-ll-gray;
color: $chill-blue; color: $chill-blue;
} }
&.bg-user-group {
background-color: $chill-l-gray;
color: $chill-blue;
}
&.bg-confidential { &.bg-confidential {
background-color: $chill-ll-gray; background-color: $chill-ll-gray;
color: $chill-red; color: $chill-red;

View File

@ -27,6 +27,11 @@
v-bind:item="item"> v-bind:item="item">
</suggestion-user> </suggestion-user>
<suggestion-user-group
v-if="item.result.type === 'user_group'"
v-bind:item="item">
></suggestion-user-group>
<suggestion-household <suggestion-household
v-if="item.result.type === 'household'" v-if="item.result.type === 'household'"
v-bind:item="item"> v-bind:item="item">
@ -41,6 +46,7 @@ import SuggestionPerson from './TypePerson';
import SuggestionThirdParty from './TypeThirdParty'; import SuggestionThirdParty from './TypeThirdParty';
import SuggestionUser from './TypeUser'; import SuggestionUser from './TypeUser';
import SuggestionHousehold from './TypeHousehold'; import SuggestionHousehold from './TypeHousehold';
import SuggestionUserGroup from './TypeUserGroup';
export default { export default {
name: 'PersonSuggestion', name: 'PersonSuggestion',
@ -49,6 +55,7 @@ export default {
SuggestionThirdParty, SuggestionThirdParty,
SuggestionUser, SuggestionUser,
SuggestionHousehold, SuggestionHousehold,
SuggestionUserGroup,
}, },
props: [ props: [
'item', 'item',

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import {ResultItem, UserGroup} from "../../../../../../ChillMainBundle/Resources/public/types";
import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import UserGroupRenderBox from "ChillMainAssets/vuejs/_components/Entity/UserGroupRenderBox.vue";
interface TypeUserGroupProps {
item: ResultItem<UserGroup>;
}
const props = defineProps<TypeUserGroupProps>();
</script>
<template>
<div class="container user-group-container">
<div class="user-group-identification">
<user-group-render-box :user-group="props.item.result"></user-group-render-box>
</div>
</div>
<div class="right_actions">
<span class="badge rounded-pill bg-user-group">
Groupe d'utilisateur
</span>
</div>
</template>
<style scoped lang="scss">
</style>