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\RegroupmentController;
use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserGroupAdminController;
use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
@ -59,6 +61,7 @@ use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType;
@ -68,6 +71,7 @@ use Chill\MainBundle\Form\LocationFormType;
use Chill\MainBundle\Form\LocationTypeType;
use Chill\MainBundle\Form\NewsItemType;
use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
@ -353,6 +357,28 @@ class ChillMainExtension extends Extension implements
{
$container->prependExtensionConfig('chill_main', [
'cruds' => [
[
'class' => UserGroup::class,
'controller' => UserGroupAdminController::class,
'name' => 'admin_user_group',
'base_path' => '/admin/main/user-group',
'base_role' => 'ROLE_ADMIN',
'form_class' => UserGroupType::class,
'actions' => [
'index' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/UserGroup/index.html.twig',
],
'new' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/UserGroup/new.html.twig',
],
'edit' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/UserGroup/edit.html.twig',
],
],
],
[
'class' => UserJob::class,
'controller' => UserJobController::class,
@ -803,6 +829,21 @@ class ChillMainExtension extends Extension implements
],
],
],
[
'class' => UserGroup::class,
'controller' => UserGroupApiController::class,
'name' => 'user-group',
'base_path' => '/api/1.0/main/user-group',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
],
]);
}

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);
}
foreach ($transitionContextDTO->futureDestUsers as $user) {
foreach ($transitionContextDTO->getFutureDestUsers() as $user) {
$newStep->addDestUser($user);
}
foreach ($transitionContextDTO->getFutureDestUserGroups() as $userGroup) {
$newStep->addDestUserGroup($userGroup);
}
if (null !== $transitionContextDTO->futureUserSignature) {
$newStep->addDestUser($transitionContextDTO->futureUserSignature);
}
foreach ($transitionContextDTO->futureDestEmails as $email) {
$newStep->addDestEmail($email);
}
if (null !== $transitionContextDTO->futureUserSignature) {
new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature);
} else {

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@ -48,6 +49,13 @@ class EntityWorkflowStep
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user')]
private Collection $destUser;
/**
* @var Collection<int, UserGroup>
*/
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user_group')]
private Collection $destUserGroups;
/**
* @var Collection<int, User>
*/
@ -108,6 +116,7 @@ class EntityWorkflowStep
{
$this->ccUser = new ArrayCollection();
$this->destUser = new ArrayCollection();
$this->destUserGroups = new ArrayCollection();
$this->destUserByAccessKey = new ArrayCollection();
$this->signatures = new ArrayCollection();
$this->holdsOnStep = new ArrayCollection();
@ -123,6 +132,9 @@ class EntityWorkflowStep
return $this;
}
/**
* @deprecated
*/
public function addDestEmail(string $email): self
{
if (!\in_array($email, $this->destEmail, true)) {
@ -141,6 +153,22 @@ class EntityWorkflowStep
return $this;
}
public function addDestUserGroup(UserGroup $userGroup): self
{
if (!$this->destUserGroups->contains($userGroup)) {
$this->destUserGroups[] = $userGroup;
}
return $this;
}
public function removeDestUserGroup(UserGroup $userGroup): self
{
$this->destUserGroups->removeElement($userGroup);
return $this;
}
public function addDestUserByAccessKey(User $user): self
{
if (!$this->destUserByAccessKey->contains($user) && !$this->destUser->contains($user)) {
@ -178,7 +206,9 @@ class EntityWorkflowStep
/**
* get all the users which are allowed to apply a transition: those added manually, and
* those added automatically bu using an access key.
* those added automatically by using an access key.
*
* This method exclude the users associated with user groups
*
* @psalm-suppress DuplicateArrayKey
*/
@ -192,6 +222,14 @@ class EntityWorkflowStep
);
}
/**
* @return Collection<int, UserGroup>
*/
public function getDestUserGroups(): Collection
{
return $this->destUserGroups;
}
public function getCcUser(): Collection
{
return $this->ccUser;
@ -207,6 +245,11 @@ class EntityWorkflowStep
return $this->currentStep;
}
/**
* @return array<string>
*
* @deprecated
*/
public function getDestEmail(): array
{
return $this->destEmail;

View File

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

View File

@ -24,6 +24,13 @@ use Symfony\Component\Serializer\SerializerInterface;
/**
* Pick user dymically, using vuejs module "AddPerson".
*
* Possible options:
*
* - `multiple`: pick one or more users
* - `suggested`: a list of suggested users
* - `as_id`: only the id will be set in the returned data
* - `submit_on_adding_new_entity`: the browser will immediately submit the form when new users are checked
*/
class PickUserDynamicType extends AbstractType
{

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

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;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Doctrine\ORM\EntityManagerInterface;
@ -264,6 +265,7 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->isMemberOf(':user', 'step.destUserByAccessKey'),
$qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF step.destUserGroups AND :user MEMBER OF ug.users', UserGroup::class))
),
$qb->expr()->isNull('step.transitionAfter'),
$qb->expr()->eq('step.isFinal', "'FALSE'")

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
@ -65,7 +66,11 @@ readonly class EntityWorkflowStepRepository implements ObjectRepository
$qb->where(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'e.destUser'),
$qb->expr()->isMemberOf(':user', 'e.destUserByAccessKey'),
$qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF e.destUserGroups AND :user MEMBER OF ug.users', UserGroup::class))
),
$qb->expr()->isNull('e.transitionAt'),
$qb->expr()->eq('e.isFinal', ':bool'),
)

View File

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

View File

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

View File

@ -1,6 +1,6 @@
export interface DateTime {
datetime: string;
datetime8601: string
datetime8601: string;
}
export interface Civility {
@ -12,8 +12,8 @@ export interface Job {
id: number;
type: "user_job";
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 {
@ -26,8 +26,13 @@ export interface Scope {
id: number;
type: "scope";
name: {
"fr": string
}
fr: string;
};
}
export interface ResultItem<T> {
result: T;
relevance: number;
}
export interface User {
@ -42,15 +47,27 @@ export interface User {
// todo: mainCenter; mainJob; etc..
}
export interface UserGroup {
type: "user_group";
id: number;
label: TranslatableString;
backgroundColor: string;
foregroundColor: string;
excludeKey: string;
text: string;
}
export type UserGroupOrUser = User | UserGroup;
export interface UserAssociatedInterface {
type: "user";
id: number;
};
}
export type TranslatableString = {
fr?: string;
nl?: string;
}
};
export interface Postcode {
id: number;
@ -62,7 +79,7 @@ export interface Postcode {
export type Point = {
type: "Point";
coordinates: [lat: number, lon: number];
}
};
export interface Country {
id: number;
@ -70,7 +87,7 @@ export interface Country {
code: string;
}
export type AddressRefStatus = 'match'|'to_review'|'reviewed';
export type AddressRefStatus = "match" | "to_review" | "reviewed";
export interface Address {
type: "address";
@ -98,7 +115,7 @@ export interface Address {
}
export interface AddressWithPoint extends Address {
point: Point
point: Point;
}
export interface AddressReference {
@ -138,7 +155,7 @@ export interface Location {
createdBy: User | null;
updatedAt: DateTime | null;
updatedBy: User | null;
email: string | null
email: string | null;
name: string;
phonenumber1: string | null;
phonenumber2: string | null;

View File

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

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"
aria-haspopup="true"
aria-expanded="false">
<a href="#" class="more">{{ app.request.locale | capitalize }}</a>
{{ app.request.locale | capitalize }}
</a>
<div class="dropdown-menu dropdown-menu-end"
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_errors(transition_form.futureCcUsers) }}
</div>
<div id="future-dest-emails">
{{ form_row(transition_form.futureDestEmails) }}
{{ form_errors(transition_form.futureDestEmails) }}
</div>
</div>
<p>{{ form_label(transition_form.comment) }}</p>
{{ 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>
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
</li>
@ -115,15 +124,6 @@
</ul>
{% 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 %}
<p><b>{{ 'workflow.Those users are also granted to apply a transition by using an access key'|trans }}&nbsp;:</b></p>
<ul>

View File

@ -13,22 +13,22 @@
{{ 'workflow.No transitions'|trans }}
</div>
{% else %}
<div class="item-col">
{% 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>
{% 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 class="item-col flex-column align-items-end">
<div class="decided">
{{ place_label }}
</div>
{#
<div class="decided">
<i class="fa fa-times fa-fw text-danger"></i>
Refusé
</div>
#}
</div>
{% endif %}
@ -71,19 +71,33 @@
</blockquote>
</div>
{% 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>
{% 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>
<ul>
{% for u in step.destUser %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}
{% if entity_workflow.isOnHoldAtCurrentStep %}
<li>
<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>
{% endif %}
</li>
{% endfor %}
{% for u in step.destUserGroups %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</li>
{% endfor %}
</ul>
{% endif %}
@ -115,6 +129,10 @@
{% endif %}
</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 %}
</div>

View File

@ -1,54 +1,3 @@
<h2>{{ 'workflow.signature_zone.title'|trans }}</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>
<h2>{{ 'workflow.signature_required_title'|trans({'nb_signatures': signatures|length}) }}</h2>
{{ 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>
{% if signatures|length > 0 %}
<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 %}
<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>
<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>
{% 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') %}
<div class="alert alert-success flash_message">
<span>{{ flashMessage|raw }}</span>
<span>{{ flashMessage|trans }}</span>
</div>
{% endfor %}
{% for flashMessage in app.session.flashbag.get('error') %}
<div class="alert alert-danger flash_message">
<span>{{ flashMessage|raw }}</span>
<span>{{ flashMessage|trans }}</span>
</div>
{% endfor %}
{% for flashMessage in app.session.flashbag.get('notice') %}
<div class="alert alert-warning flash_message">
<span>{{ flashMessage|raw }}</span>
<span>{{ flashMessage|trans }}</span>
</div>
{% endfor %}

View File

@ -27,7 +27,7 @@ final readonly class ChillUrlGenerator implements ChillUrlGeneratorInterface
{
$uri = $this->requestStack->getCurrentRequest()->getRequestUri();
return $this->urlGenerator->generate($name, [$parameters, 'returnPath' => $uri], $referenceType);
return $this->urlGenerator->generate($name, [...$parameters, 'returnPath' => $uri], $referenceType);
}
public function returnPathOr(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string

View File

@ -67,6 +67,10 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface
'route' => 'chill_crud_admin_user_index',
])->setExtras(['order' => 1040]);
$menu->addChild('crud.admin_user_group.index.title', [
'route' => 'chill_crud_admin_user_group_index',
])->setExtras(['order' => 1042]);
$menu->addChild('User jobs', [
'route' => 'chill_crud_admin_user_job_index',
])->setExtras(['order' => 1050]);

View File

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

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 = [])

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;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@ -43,4 +44,14 @@ class EntityWorkflowRepositoryTest extends KernelTestCase
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->futureCcUsers[] = $user1 = new User();
$dto->futureDestUsers[] = $user2 = new User();
$dto->futureDestEmails[] = $email = 'test@example.com';
$markingStore->setMarking($workflow, new Marking(['foo' => 1]), [
'context' => $dto,
@ -55,7 +54,6 @@ class EntityWorkflowMarkingStoreTest extends TestCase
$currentStep = $workflow->getCurrentStep();
self::assertEquals('foo', $currentStep->getCurrentStep());
self::assertContains($email, $currentStep->getDestEmail());
self::assertContains($user1, $currentStep->getCcUser());
self::assertContains($user2, $currentStep->getDestUser());

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
use Chill\MainBundle\Templating\Entity\UserRender;
@ -145,6 +146,11 @@ class EntityWorkflowGuardTransitionTest extends TestCase
yield [self::buildEntityWorkflow([new User()]), 'transition1', null, false, 'd9e39a18-704c-11ef-b235-8fe0619caee7'];
yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, false, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'];
yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, true, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'];
$userGroup = new UserGroup();
$userGroup->addUser(new User());
yield [self::buildEntityWorkflow([$userGroup]), 'transition1', new User(), false, 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc'];
}
public static function provideValidTransition(): iterable
@ -159,6 +165,10 @@ class EntityWorkflowGuardTransitionTest extends TestCase
// transition allowed thanks to permission "apply all transitions"
yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), true, 'step1'];
yield [self::buildEntityWorkflow([new User()]), 'transition2', new User(), true, 'step2'];
$userGroup = new UserGroup();
$userGroup->addUser($u = new User());
yield [self::buildEntityWorkflow([$userGroup]), 'transition1', $u, false, 'step1'];
}
public static function buildEntityWorkflow(array $futureDestUsers): EntityWorkflow

View File

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

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;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepRepository;
use Chill\MainBundle\Templating\UI\NotificationCounterInterface;
@ -82,6 +83,18 @@ final readonly class WorkflowByUserCounter implements NotificationCounterInterfa
foreach ($step->getDestUser() as $user) {
$keys[] = self::generateCacheKeyWorkflowByUser($user);
}
foreach ($step->getDestUserGroups()->reduce(
function (array $accumulator, UserGroup $userGroup) {
foreach ($userGroup->getUsers() as $user) {
$accumulator[] = $user;
}
return $accumulator;
},
[]
) as $user) {
$keys[] = self::generateCacheKeyWorkflowByUser($user);
}
if ([] !== $keys) {
$this->cacheItemPool->deleteItems($keys);

View File

@ -87,6 +87,17 @@ class EntityWorkflowGuardTransition implements EventSubscriberInterface
return;
}
if (!$user instanceof User) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.Only regular user can apply a transition',
'04fb4f76-7c0e-11ef-afc3-877bad7b0fe7'
)
);
return;
}
// for users
if (!in_array('only-dest', $systemTransitions, true)) {
$event->addTransitionBlocker(
@ -108,6 +119,13 @@ class EntityWorkflowGuardTransition implements EventSubscriberInterface
return;
}
// we give a second chance, searching for the presence of the user within userGroups
foreach ($entityWorkflow->getCurrentStep()->getDestUserGroups() as $userGroup) {
if ($userGroup->contains($user)) {
return;
}
}
$event->addTransitionBlocker(new TransitionBlocker(
'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%',
'f3eeb57c-7532-11ec-9495-e7942a2ac7bc',

View File

@ -13,6 +13,7 @@ namespace Chill\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Doctrine\ORM\EntityManagerInterface;
@ -69,7 +70,18 @@ class NotificationOnTransition implements EventSubscriberInterface
// the dests for the current step
$entityWorkflow->getCurrentStep()->getDestUser()->toArray(),
// the cc users for the current step
$entityWorkflow->getCurrentStep()->getCcUser()->toArray()
$entityWorkflow->getCurrentStep()->getCcUser()->toArray(),
// the users within groups
$entityWorkflow->getCurrentStep()->getDestUserGroups()->reduce(
function (array $accumulator, UserGroup $userGroup) {
foreach ($userGroup->getUsers() as $user) {
$accumulator[] = $user;
}
return $accumulator;
},
[]
),
) as $dest) {
$dests[spl_object_hash($dest)] = $dest;
}

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;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Validator\Constraints as Assert;
@ -24,34 +25,23 @@ use Symfony\Component\Workflow\Transition;
class WorkflowTransitionContextDTO
{
/**
* a list of future dest users for the next steps.
* a list of future dest users or user groups for the next step.
*
* This is in used in order to let controller inform who will be the future users which will validate
* the next step. This is necessary to perform some computation about the next users, before they are
* associated to the entity EntityWorkflowStep.
*
* @var array|User[]
* @var list<User|UserGroup>
*/
public array $futureDestUsers = [];
/**
* a list of future cc users for the next steps.
* a list of future cc users for the next step.
*
* @var array|User[]
*/
public array $futureCcUsers = [];
/**
* a list of future dest emails for the next steps.
*
* This is in used in order to let controller inform who will be the future emails which will validate
* the next step. This is necessary to perform some computation about the next emails, before they are
* associated to the entity EntityWorkflowStep.
*
* @var array|string[]
*/
public array $futureDestEmails = [];
/**
* A list of future @see{Person} with will sign the next step.
*
@ -72,6 +62,22 @@ class WorkflowTransitionContextDTO
public EntityWorkflow $entityWorkflow,
) {}
/**
* @return list<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()]
public function validateCCUserIsNotInDest(ExecutionContextInterface $context, $payload): void
{

View File

@ -29,6 +29,42 @@ components:
type: string
text:
type: string
UserById:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user
UserGroup:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user_group
label:
type: object
additionalProperties: true
backgroundColor:
type: string
foregroundColor:
type: string
exclusionKey:
type: string
UserGroupById:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user_group
Center:
type: object
properties:
@ -200,6 +236,7 @@ paths:
- thirdparty
- user
- household
- user-group
responses:
200:
description: "OK"
@ -236,7 +273,7 @@ paths:
minItems: 2
maxItems: 2
postcode:
$ref: "#/components/schemas/PostalCode"
$ref: '#/components/schemas/PostalCode'
steps:
type: string
street:
@ -846,7 +883,7 @@ paths:
type: array
items:
type: integer
example: [1, 2, 3] # Example array of IDs
example: [ 1, 2, 3 ] # Example array of IDs
responses:
"202":
description: Notifications marked as unread successfully
@ -934,6 +971,22 @@ paths:
schema:
type: array
items:
$ref: "#/components/schemas/NewsItem"
$ref: '#/components/schemas/NewsItem'
403:
description: "Unauthorized"
/1.0/main/user-group.json:
get:
tags:
- user-group
summary: Return a list of users-groups
responses:
200:
description: "ok"
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UserGroup'
403:
description: "Unauthorized"

View File

@ -42,17 +42,6 @@ services:
arguments:
$handlers: !tagged_iterator chill_main.workflow_handler
Chill\MainBundle\Workflow\EventSubscriber\SendAccessKeyEventSubscriber:
autoconfigure: true
autowire: true
tags:
-
name: 'doctrine.orm.entity_listener'
event: 'postPersist'
entity: 'Chill\MainBundle\Entity\Workflow\EntityWorkflowStep'
# set the 'lazy' option to TRUE to only instantiate listeners when they are used
lazy: true
# other stuffes
chill.main.helper.translatable_string:

View File

@ -56,6 +56,10 @@ services:
Chill\MainBundle\Templating\Entity\UserRender: ~
Chill\MainBundle\Templating\Entity\UserGroupRender: ~
Chill\MainBundle\Templating\Entity\UserGroupRenderInterface:
alias: Chill\MainBundle\Templating\Entity\UserGroupRender
Chill\MainBundle\Templating\Listing\:
resource: './../../Templating/Listing'

View File

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

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}
}
user_group:
with_count_users: >-
{count, plural,
=0 {Aucun membre}
one {1 utilisateur}
many {# utilisateurs}
other {# utilisateurs}
}
user_removed: L'utilisateur {user} est enlevé du groupe {user_group} avec succès
user_added: L'utilisateur {user} est ajouté groupe {user_group} avec succès
notification:
My notifications with counter: >-
{nb, plural,
@ -47,9 +58,15 @@ workflow:
}
signature:
signed_statement: 'Signature appliquée le {datetime, date, short} à {datetime, time, short}'
rejected_statement: 'Signature rejectée le {datetime, date, short} à {datetime, time, short}'
rejected_statement: 'Signature rejetée le {datetime, date, short} à {datetime, time, short}'
canceled_statement: 'Signature annulée le {datetime, date, short} à {datetime, time, short}'
On hold by: En attente par {by}
signature_required_title: >-
{nb_signatures, plural,
=0 {Aucune signature demandée}
one {Signature demandée}
other {Signatures demandées}
}
duration:
minute: >-

View File

@ -51,6 +51,25 @@ user:
no job: Pas de métier assigné
no scope: Pas de cercle assigné
user_group:
inactive: Inactif
with_users: Membres
no_users: Aucun utilisateur associé
no_admin_users: Aucun administrateur
Label: Nom du groupe
BackgroundColor: Couleur de fond du badge
ForegroundColor: Couleur de la police du badge
ExcludeKey: Clé d'exclusion
ExcludeKeyHelp: Lorsque cela est pertinent, les groupes comportant la même clé d'exclusion s'excluent mutuellement.
Users: Membres du groupe
adminUsers: Administrateurs du groupe
adminUsersHelp: Les administrateurs du groupe peuvent ajouter ou retirer des membres dans le groupe.
my_groups: Mes groupes
me_and: Moi et
me_only: Uniquement moi
me: Moi
append_users: Ajouter des utilisateurs
inactive: inactif
Edit: Modifier
@ -395,6 +414,12 @@ crud:
add_new: Créer
title_new: Nouveau métier
title_edit: Modifier un métier
admin_user_group:
index:
title: Groupes d'utilisateurs
add_new: Créer
title_edit: Modifier un groupe d'utilisateur
title_new: Nouveau groupe utilisateur
main_location_type:
index:
title: Liste des types de localisations

View File

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

View File

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

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>