Merge branch 'signature-app-master' into 'master'

Signature app master

Closes #307

See merge request Chill-Projet/chill-bundles!743
This commit is contained in:
2024-11-12 20:30:00 +00:00
426 changed files with 22236 additions and 2253 deletions

View File

@@ -96,7 +96,7 @@ class SearchController extends AbstractController
return $this->render('@ChillMain/Search/choose_list.html.twig');
}
#[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json'], defaults: ['_format' => 'html'])]
#[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json', '_locale' => '[a-z]{1,3}'], defaults: ['_format' => 'html'])]
public function searchAction(Request $request, mixed $_format)
{
$pattern = trim((string) $request->query->get('q', ''));

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,79 @@
<?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\DocStoreBundle\Service\Signature\PDFSignatureZoneAvailable;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment;
final readonly class WorkflowAddSignatureController
{
public function __construct(
private EntityWorkflowManager $entityWorkflowManager,
private PDFSignatureZoneAvailable $PDFSignatureZoneAvailable,
private NormalizerInterface $normalizer,
private Environment $twig,
private UrlGeneratorInterface $urlGenerator,
private Security $security,
) {}
#[Route(path: '/{_locale}/main/workflow/signature/{id}/sign', name: 'chill_main_workflow_signature_add', methods: 'GET')]
public function __invoke(EntityWorkflowStepSignature $signature, Request $request): Response
{
if (!$this->security->isGranted(EntityWorkflowStepSignatureVoter::SIGN, $signature)) {
throw new AccessDeniedHttpException('not authorized to sign this step');
}
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
if ($request->query->has('returnPath')) {
return new RedirectResponse($request->query->get('returnPath'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
throw new NotFoundHttpException('No stored object found');
}
$zones = $this->PDFSignatureZoneAvailable->getAvailableSignatureZones($entityWorkflow);
$signatureClient = [];
$signatureClient['id'] = $signature->getId();
$signatureClient['storedObject'] = $this->normalizer->normalize($storedObject, 'json');
$signatureClient['zones'] = $zones;
return new Response(
$this->twig->render(
'@ChillMain/Workflow/signature_sign.html.twig',
['signature' => $signatureClient]
)
);
}
}

View File

@@ -13,23 +13,27 @@ namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowComment;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Form\EntityWorkflowCommentType;
use Chill\MainBundle\Form\WorkflowSignatureMetadataType;
use Chill\MainBundle\Form\WorkflowStepType;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Chill\MainBundle\Security\ChillSecurity;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Registry;
@@ -38,7 +42,19 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class WorkflowController extends AbstractController
{
public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly ValidatorInterface $validator, private readonly PaginatorFactory $paginatorFactory, private readonly Registry $registry, private readonly EntityManagerInterface $entityManager, private readonly TranslatorInterface $translator, private readonly ChillSecurity $security, private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry) {}
public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowRepository $entityWorkflowRepository,
private readonly ValidatorInterface $validator,
private readonly PaginatorFactory $paginatorFactory,
private readonly Registry $registry,
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator,
private readonly ChillSecurity $security,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
private readonly ClockInterface $clock,
private readonly EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
) {}
#[Route(path: '/{_locale}/main/workflow/create', name: 'chill_main_workflow_create')]
public function create(Request $request): Response
@@ -268,6 +284,9 @@ class WorkflowController extends AbstractController
);
}
/**
* @throws NonUniqueResultException
*/
#[Route(path: '/{_locale}/main/workflow/{id}/show', name: 'chill_main_workflow_show')]
public function show(EntityWorkflow $entityWorkflow, Request $request): Response
{
@@ -276,24 +295,17 @@ class WorkflowController extends AbstractController
$handler = $this->entityWorkflowManager->getHandler($entityWorkflow);
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$errors = [];
$signatures = $entityWorkflow->getCurrentStep()->getSignatures();
if (\count($workflow->getEnabledTransitions($entityWorkflow)) > 0) {
// possible transition
$usersInvolved = $entityWorkflow->getUsersInvolved();
$currentUserFound = array_search($this->security->getUser(), $usersInvolved, true);
if (false !== $currentUserFound) {
unset($usersInvolved[$currentUserFound]);
}
$stepDTO = new WorkflowTransitionContextDTO($entityWorkflow);
$transitionForm = $this->createForm(
WorkflowStepType::class,
$entityWorkflow->getCurrentStep(),
$stepDTO,
[
'transition' => true,
'entity_workflow' => $entityWorkflow,
'suggested_users' => $usersInvolved,
]
);
@@ -310,12 +322,14 @@ class WorkflowController extends AbstractController
throw $this->createAccessDeniedException(sprintf("not allowed to apply transition {$transition}: %s", implode(', ', $msgs)));
}
// TODO symfony 5: add those "future" on context ($workflow->apply($entityWorkflow, $transition, $context)
$entityWorkflow->futureCcUsers = $transitionForm['future_cc_users']->getData() ?? [];
$entityWorkflow->futureDestUsers = $transitionForm['future_dest_users']->getData() ?? [];
$entityWorkflow->futureDestEmails = $transitionForm['future_dest_emails']->getData() ?? [];
$byUser = $this->security->getUser();
$workflow->apply($entityWorkflow, $transition);
$workflow->apply($entityWorkflow, $transition, [
'context' => $stepDTO,
'byUser' => $byUser,
'transition' => $transition,
'transitionAt' => $this->clock->now(),
]);
$this->entityManager->flush();
@@ -327,22 +341,6 @@ class WorkflowController extends AbstractController
}
}
/*
$commentForm = $this->createForm(EntityWorkflowCommentType::class, $newComment = new EntityWorkflowComment());
$commentForm->handleRequest($request);
if ($commentForm->isSubmitted() && $commentForm->isValid()) {
$this->entityManager->persist($newComment);
$this->entityManager->flush();
$this->addFlash('success', $this->translator->trans('workflow.Comment added'));
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
} elseif ($commentForm->isSubmitted() && !$commentForm->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
*/
return $this->render(
'@ChillMain/Workflow/index.html.twig',
[
@@ -352,7 +350,7 @@ class WorkflowController extends AbstractController
'transition_form' => isset($transitionForm) ? $transitionForm->createView() : null,
'entity_workflow' => $entityWorkflow,
'transition_form_errors' => $errors,
// 'comment_form' => $commentForm->createView(),
'signatures' => $signatures,
]
);
}
@@ -371,4 +369,65 @@ class WorkflowController extends AbstractController
return $lines;
}
#[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/metadata', name: 'chill_main_workflow_signature_metadata')]
public function addSignatureMetadata(int $signature_id, Request $request): Response
{
$signature = $this->entityWorkflowStepSignatureRepository->find($signature_id);
if (null === $signature) {
throw new NotFoundHttpException('signature not found');
}
if ($signature->isSigned()) {
$this->addFlash(
'notice',
$this->translator->trans('workflow.signature_zone.already_signed_alert')
);
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]);
}
if ($signature->getSigner() instanceof User) {
return $this->redirectToRoute('chill_main_workflow_signature_add', [
'id' => $signature_id,
'returnPath' => $request->query->get('returnPath', null),
]);
}
$metadataForm = $this->createForm(WorkflowSignatureMetadataType::class);
$metadataForm->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]);
$metadataForm->handleRequest($request);
if ($metadataForm->isSubmitted() && $metadataForm->isValid()) {
$data = $metadataForm->getData();
$signature->setSignatureMetadata(
[
'base_signer' => [
'document_type' => $data['documentType'],
'document_number' => $data['documentNumber'],
'expiration_date' => $data['expirationDate'],
],
]
);
$this->entityManager->persist($signature);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_workflow_signature_add', [
'id' => $signature_id,
'returnPath' => $request->query->get('returnPath', null),
]);
}
return $this->render(
'@ChillMain/Workflow/signature_metadata.html.twig',
[
'metadata_form' => $metadataForm->createView(),
'person' => $signature->getSigner(),
]
);
}
}

View File

@@ -0,0 +1,98 @@
<?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\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Registry;
class WorkflowOnHoldController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly Registry $registry,
private readonly UrlGeneratorInterface $urlGenerator,
) {}
#[Route(path: '/{_locale}/main/workflow/{id}/hold', name: 'chill_main_workflow_on_hold')]
public function putOnHold(EntityWorkflow $entityWorkflow, Request $request): Response
{
$currentStep = $entityWorkflow->getCurrentStep();
$currentUser = $this->security->getUser();
if (!$currentUser instanceof User) {
throw new AccessDeniedHttpException('only user can put a workflow on hold');
}
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$enabledTransitions = $workflow->getEnabledTransitions($entityWorkflow);
if (0 === count($enabledTransitions)) {
throw new AccessDeniedHttpException('You are not allowed to apply any transitions to this workflow, therefore you cannot toggle the hold status.');
}
$stepHold = new EntityWorkflowStepHold($currentStep, $currentUser);
$this->entityManager->persist($stepHold);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate(
'chill_main_workflow_show',
['id' => $entityWorkflow->getId()]
)
);
}
#[Route(path: '/{_locale}/main/workflow/{id}/remove_hold', name: 'chill_main_workflow_remove_hold')]
public function removeOnHold(EntityWorkflowStep $entityWorkflowStep): Response
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('only user can remove workflow on hold');
}
if (!$entityWorkflowStep->isOnHoldByUser($user)) {
throw new AccessDeniedHttpException('You are not allowed to remove workflow on hold');
}
$hold = $entityWorkflowStep->getHoldsOnStep()->findFirst(fn (int $index, EntityWorkflowStepHold $entityWorkflowStepHold) => $user === $entityWorkflowStepHold->getByUser());
if (null === $hold) {
// this should not happens...
throw new NotFoundHttpException();
}
$this->entityManager->remove($hold);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate(
'chill_main_workflow_show',
['id' => $entityWorkflowStep->getEntityWorkflow()->getId()]
)
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
final readonly class WorkflowSignatureCancelController
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private FormFactoryInterface $formFactory,
private Environment $twig,
private SignatureStepStateChanger $signatureStepStateChanger,
private ChillUrlGeneratorInterface $chillUrlGenerator,
) {}
#[Route('/{_locale}/main/workflow/signature/{id}/cancel', name: 'chill_main_workflow_signature_cancel')]
public function cancelSignature(EntityWorkflowStepSignature $signature, Request $request): Response
{
return $this->markSignatureAction(
$signature,
$request,
EntityWorkflowStepSignatureVoter::CANCEL,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); },
'@ChillMain/WorkflowSignature/cancel.html.twig',
);
}
#[Route('/{_locale}/main/workflow/signature/{id}/reject', name: 'chill_main_workflow_signature_reject')]
public function rejectSignature(EntityWorkflowStepSignature $signature, Request $request): Response
{
return $this->markSignatureAction(
$signature,
$request,
EntityWorkflowStepSignatureVoter::REJECT,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); },
'@ChillMain/WorkflowSignature/reject.html.twig',
);
}
private function markSignatureAction(
EntityWorkflowStepSignature $signature,
Request $request,
string $permissionAttribute,
callable $markSignature,
string $template,
): Response {
if (!$this->security->isGranted($permissionAttribute, $signature)) {
throw new AccessDeniedHttpException('not allowed to cancel this signature');
}
$form = $this->formFactory->create();
$form->add('confirm', SubmitType::class, ['label' => 'Confirm']);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$markSignature($signature);
$this->entityManager->flush();
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()])
);
}
return
new Response(
$this->twig->render(
$template,
['form' => $form->createView(), 'signature' => $signature]
)
);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSendView;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Exception\HandlerWithPublicViewNotFoundException;
use Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage;
use Chill\MainBundle\Workflow\Templating\EntityWorkflowViewMetadataDTO;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class WorkflowViewSendPublicController
{
public const LOG_PREFIX = '[workflow-view-send-public-controller] ';
public function __construct(
private EntityManagerInterface $entityManager,
private LoggerInterface $chillLogger,
private EntityWorkflowManager $entityWorkflowManager,
private ClockInterface $clock,
private Environment $environment,
private MessageBusInterface $messageBus,
) {}
#[Route('/public/main/workflow/send/{uuid}/view/{verificationKey}', name: 'chill_main_workflow_send_view_public', methods: ['GET'])]
public function __invoke(EntityWorkflowSend $workflowSend, string $verificationKey, Request $request): Response
{
if (50 < $workflowSend->getNumberOfErrorTrials()) {
throw new AccessDeniedHttpException('number of trials exceeded, no more access allowed');
}
if ($verificationKey !== $workflowSend->getPrivateToken()) {
$this->chillLogger->info(self::LOG_PREFIX.'Invalid trial for this send', ['client_ip' => $request->getClientIp()]);
$workflowSend->increaseErrorTrials();
$this->entityManager->flush();
throw new AccessDeniedHttpException('invalid verification key');
}
if ($this->clock->now() > $workflowSend->getExpireAt()) {
return new Response(
$this->environment->render('@ChillMain/Workflow/workflow_view_send_public_expired.html.twig'),
409
);
}
if (100 < $workflowSend->getViews()->count()) {
$this->chillLogger->info(self::LOG_PREFIX.'100 view reached, not allowed to see it again');
throw new AccessDeniedHttpException('100 views reached, not allowed to see it again');
}
try {
$metadata = new EntityWorkflowViewMetadataDTO(
$workflowSend->getViews()->count(),
100 - $workflowSend->getViews()->count(),
);
$response = new Response(
$this->entityWorkflowManager->renderPublicView($workflowSend, $metadata),
);
$view = new EntityWorkflowSendView($workflowSend, $this->clock->now(), $request->getClientIp());
$this->entityManager->persist($view);
$this->messageBus->dispatch(new PostPublicViewMessage($view->getId()));
$this->entityManager->flush();
return $response;
} catch (HandlerWithPublicViewNotFoundException $e) {
throw new \RuntimeException('Could not render the public view', previous: $e);
}
}
}

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

@@ -26,6 +26,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;
@@ -62,6 +64,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;
@@ -72,6 +75,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;
@@ -357,6 +361,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,
@@ -844,6 +870,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

@@ -85,6 +85,29 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end() // end of notifications
->arrayNode('workflow_signature')
->children()
->arrayNode('base_signer')
->children()
->arrayNode('document_kinds')
->arrayPrototype()
->children()
->scalarNode('key')->cannotBeEmpty()->end()
->arrayNode('labels')
->arrayPrototype()
->children()
->scalarNode('lang')->cannotBeEmpty()->end()
->scalarNode('label')->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end() // end of workflow signature document types
->arrayNode('phone_helper')
->canBeUnset()
->children()

View File

@@ -0,0 +1,244 @@
<?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;
use Symfony\Component\Validator\Constraints as Assert;
#[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 = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Assert\Email]
private string $email = '';
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;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function hasEmail(): bool
{
return '' !== $this->email;
}
/**
* 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

@@ -17,9 +17,10 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
@@ -34,39 +35,10 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
use TrackUpdateTrait;
/**
* a list of future cc users for the next steps.
*
* @var array|User[]
*/
public array $futureCcUsers = [];
/**
* a list of future dest emails for the next steps.
*
* 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 dest users for the next steps.
*
* 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[]
*/
public array $futureDestUsers = [];
/**
* @var Collection<int, \Chill\MainBundle\Entity\Workflow\EntityWorkflowComment>
*/
#[ORM\OneToMany(targetEntity: EntityWorkflowComment::class, mappedBy: 'entityWorkflow', orphanRemoval: true)]
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowComment::class, orphanRemoval: true)]
private Collection $comments;
#[ORM\Id]
@@ -81,12 +53,12 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
private int $relatedEntityId;
/**
* @var Collection<int, EntityWorkflowStep>
* @var Collection<int, EntityWorkflowStep>&Selectable<int, EntityWorkflowStep>
*/
#[Assert\Valid(traverse: true)]
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist'], orphanRemoval: true)]
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['transitionAt' => \Doctrine\Common\Collections\Criteria::ASC, 'id' => 'ASC'])]
private Collection $steps;
private Collection&Selectable $steps;
/**
* @var array|EntityWorkflowStep[]|null
@@ -271,7 +243,10 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
throw new \RuntimeException();
}
public function getSteps(): ArrayCollection|Collection
/**
* @return Selectable<int, EntityWorkflowStep>&Collection<int, EntityWorkflowStep>
*/
public function getSteps(): Collection&Selectable
{
return $this->steps;
}
@@ -346,7 +321,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
}
}
return $usersInvolved;
return array_values($usersInvolved);
}
public function getWorkflowName(): string
@@ -367,8 +342,6 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
public function isFreeze(): bool
{
$steps = $this->getStepsChained();
foreach ($this->getStepsChained() as $step) {
if ($step->isFreezeAfter()) {
return true;
@@ -378,6 +351,11 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return false;
}
public function isOnHoldByUser(User $user): bool
{
return $this->getCurrentStep()->isOnHoldByUser($user);
}
public function isUserSubscribedToFinal(User $user): bool
{
return $this->subscriberToFinal->contains($user);
@@ -446,11 +424,54 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
*
* @return $this
*/
public function setStep(string $step): self
{
public function setStep(
string $step,
WorkflowTransitionContextDTO $transitionContextDTO,
string $transition,
\DateTimeImmutable $transitionAt,
?User $byUser = null,
): self {
$previousStep = $this->getCurrentStep();
$previousStep
->setTransitionAfter($transition)
->setTransitionAt($transitionAt)
->setTransitionBy($byUser);
$newStep = new EntityWorkflowStep();
$newStep->setCurrentStep($step);
foreach ($transitionContextDTO->futureCcUsers as $user) {
$newStep->addCcUser($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);
}
if (null !== $transitionContextDTO->futureUserSignature) {
new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature);
} else {
foreach ($transitionContextDTO->futurePersonSignatures as $personSignature) {
new EntityWorkflowStepSignature($newStep, $personSignature);
}
}
foreach ($transitionContextDTO->futureDestineeThirdParties as $thirdParty) {
new EntityWorkflowSend($newStep, $thirdParty, $transitionAt->add(new \DateInterval('P30D')));
}
foreach ($transitionContextDTO->futureDestineeEmails as $email) {
new EntityWorkflowSend($newStep, $email, $transitionAt->add(new \DateInterval('P30D')));
}
// copy the freeze
if ($this->isFreeze()) {
$newStep->setFreezeAfter(true);
@@ -476,4 +497,41 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return $this->steps->get($this->steps->count() - 2);
}
public function isOnHoldAtCurrentStep(): bool
{
return $this->getCurrentStep()->getHoldsOnStep()->count() > 0;
}
/**
* Determines if the workflow has become stale after a given date.
*
* This function checks the creation date and the transition states of the workflow steps.
* A workflow is considered stale if:
* - The creation date is before the given date and no transitions have occurred since the creation.
* - Or if there are no transitions after the given date.
*
* @param \DateTimeImmutable $at the date to compare against the workflow's status
*
* @return bool true if the workflow is stale after the given date, false otherwise
*/
public function isStaledAt(\DateTimeImmutable $at): bool
{
// if there is no transition since the creation, then the workflow is staled
if ('initial' === $this->getCurrentStep()->getCurrentStep()
&& null === $this->getCurrentStep()->getTransitionAt()
) {
if (null === $this->getCreatedAt()) {
return false;
}
if ($this->getCreatedAt() < $at) {
return true;
}
return false;
}
return $this->getCurrentStepChained()->getPrevious()->getTransitionAt() < $at;
}
}

View File

@@ -0,0 +1,227 @@
<?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\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Random\Randomizer;
/**
* An entity which stores then sending of a workflow's content to
* some external entity.
*/
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_workflow_entity_send')]
class EntityWorkflowSend implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: ThirdParty::class)]
#[ORM\JoinColumn(nullable: true)]
private ?ThirdParty $destineeThirdParty = null;
#[ORM\Column(type: Types::TEXT, nullable: false, options: ['default' => ''])]
private string $destineeEmail = '';
#[ORM\Column(type: 'uuid', unique: true, nullable: false)]
private UuidInterface $uuid;
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
private string $privateToken;
#[ORM\Column(type: Types::INTEGER, nullable: false, options: ['default' => 0])]
private int $numberOfErrorTrials = 0;
/**
* @var Collection<int, EntityWorkflowSendView>
*/
#[ORM\OneToMany(mappedBy: 'send', targetEntity: EntityWorkflowSendView::class, cascade: ['remove'])]
private Collection $views;
public function __construct(
#[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class, inversedBy: 'sends')]
#[ORM\JoinColumn(nullable: false)]
private EntityWorkflowStep $entityWorkflowStep,
string|ThirdParty $destinee,
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: false)]
private \DateTimeImmutable $expireAt,
) {
$this->uuid = Uuid::uuid4();
$random = new Randomizer();
$this->privateToken = bin2hex($random->getBytes(48));
$this->entityWorkflowStep->addSend($this);
if ($destinee instanceof ThirdParty) {
$this->destineeThirdParty = $destinee;
} else {
$this->destineeEmail = $destinee;
}
$this->views = new ArrayCollection();
}
/**
* @internal use the @see{EntityWorkflowSendView}'s constructor instead
*/
public function addView(EntityWorkflowSendView $view): self
{
if (!$this->views->contains($view)) {
$this->views->add($view);
}
return $this;
}
public function getDestineeEmail(): string
{
return $this->destineeEmail;
}
public function getDestineeThirdParty(): ?ThirdParty
{
return $this->destineeThirdParty;
}
public function getId(): ?int
{
return $this->id;
}
public function getNumberOfErrorTrials(): int
{
return $this->numberOfErrorTrials;
}
public function getPrivateToken(): string
{
return $this->privateToken;
}
public function getEntityWorkflowStep(): EntityWorkflowStep
{
return $this->entityWorkflowStep;
}
public function getEntityWorkflowStepChained(): ?EntityWorkflowStep
{
foreach ($this->getEntityWorkflowStep()->getEntityWorkflow()->getStepsChained() as $step) {
if ($this->getEntityWorkflowStep() === $step) {
return $step;
}
}
return null;
}
public function getUuid(): UuidInterface
{
return $this->uuid;
}
public function getExpireAt(): \DateTimeImmutable
{
return $this->expireAt;
}
public function getViews(): Collection
{
return $this->views;
}
public function increaseErrorTrials(): void
{
$this->numberOfErrorTrials = $this->numberOfErrorTrials + 1;
}
public function getDestinee(): string|ThirdParty
{
if (null !== $this->getDestineeThirdParty()) {
return $this->getDestineeThirdParty();
}
return $this->getDestineeEmail();
}
/**
* Determines the kind of destinee based on whether the destinee is a thirdParty or an emailAddress.
*
* @return 'thirdParty'|'email' 'thirdParty' if the destinee is a third party, 'email' otherwise
*/
public function getDestineeKind(): string
{
if (null !== $this->getDestineeThirdParty()) {
return 'thirdParty';
}
return 'email';
}
public function isViewed(): bool
{
return $this->views->count() > 0;
}
public function isExpired(?\DateTimeImmutable $now = null): bool
{
return ($now ?? new \DateTimeImmutable('now')) >= $this->expireAt;
}
/**
* Retrieves the most recent view.
*
* @return EntityWorkflowSendView|null returns the last view or null if there are no views
*/
public function getLastView(): ?EntityWorkflowSendView
{
$last = null;
foreach ($this->views as $view) {
if (null === $last) {
$last = $view;
} else {
if ($view->getViewAt() > $last->getViewAt()) {
$last = $view;
}
}
}
return $last;
}
/**
* Retrieves an array of views grouped by their remote IP address.
*
* @return array<string, list<EntityWorkflowSendView>> an associative array where the keys are IP addresses and the values are arrays of views associated with those IPs
*/
public function getViewsByIp(): array
{
$views = [];
foreach ($this->getViews() as $view) {
$views[$view->getRemoteIp()][] = $view;
}
return $views;
}
}

View File

@@ -0,0 +1,60 @@
<?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\Workflow;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Register the viewing action from an external destinee.
*/
#[ORM\Entity(readOnly: true)]
#[ORM\Table(name: 'chill_main_workflow_entity_send_views')]
class EntityWorkflowSendView
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: EntityWorkflowSend::class, inversedBy: 'views')]
#[ORM\JoinColumn(nullable: false)]
private EntityWorkflowSend $send,
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeInterface $viewAt,
#[ORM\Column(type: Types::TEXT)]
private string $remoteIp = '',
) {
$this->send->addView($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getRemoteIp(): string
{
return $this->remoteIp;
}
public function getSend(): EntityWorkflowSend
{
return $this->send;
}
public function getViewAt(): \DateTimeInterface
{
return $this->viewAt;
}
}

View File

@@ -0,0 +1,20 @@
<?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\Workflow;
enum EntityWorkflowSignatureStateEnum: string
{
case PENDING = 'pending';
case SIGNED = 'signed';
case REJECTED = 'rejected';
case CANCELED = 'canceled';
}

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>
*/
@@ -55,6 +63,12 @@ class EntityWorkflowStep
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user_by_accesskey')]
private Collection $destUserByAccessKey;
/**
* @var Collection <int, EntityWorkflowStepSignature>
*/
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepSignature::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $signatures;
#[ORM\ManyToOne(targetEntity: EntityWorkflow::class, inversedBy: 'steps')]
private ?EntityWorkflow $entityWorkflow = null;
@@ -92,11 +106,27 @@ class EntityWorkflowStep
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
private ?string $transitionByEmail = null;
/**
* @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold>
*/
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)]
private Collection $holdsOnStep;
/**
* @var Collection<int, EntityWorkflowSend>
*/
#[ORM\OneToMany(mappedBy: 'entityWorkflowStep', targetEntity: EntityWorkflowSend::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $sends;
public function __construct()
{
$this->ccUser = new ArrayCollection();
$this->destUser = new ArrayCollection();
$this->destUserGroups = new ArrayCollection();
$this->destUserByAccessKey = new ArrayCollection();
$this->signatures = new ArrayCollection();
$this->holdsOnStep = new ArrayCollection();
$this->sends = new ArrayCollection();
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(32));
}
@@ -109,6 +139,9 @@ class EntityWorkflowStep
return $this;
}
/**
* @deprecated
*/
public function addDestEmail(string $email): self
{
if (!\in_array($email, $this->destEmail, true)) {
@@ -127,6 +160,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)) {
@@ -136,6 +185,39 @@ class EntityWorkflowStep
return $this;
}
/**
* @internal use @see{EntityWorkflowStepSignature}'s constructor instead
*/
public function addSignature(EntityWorkflowStepSignature $signature): self
{
if (!$this->signatures->contains($signature)) {
$this->signatures[] = $signature;
}
return $this;
}
/**
* @internal use @see{EntityWorkflowSend}'s constructor instead
*/
public function addSend(EntityWorkflowSend $send): self
{
if (!$this->sends->contains($send)) {
$this->sends[] = $send;
}
return $this;
}
public function removeSignature(EntityWorkflowStepSignature $signature): self
{
if ($this->signatures->contains($signature)) {
$this->signatures->removeElement($signature);
}
return $this;
}
public function getAccessKey(): string
{
return $this->accessKey;
@@ -143,7 +225,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
*/
@@ -157,6 +241,14 @@ class EntityWorkflowStep
);
}
/**
* @return Collection<int, UserGroup>
*/
public function getDestUserGroups(): Collection
{
return $this->destUserGroups;
}
public function getCcUser(): Collection
{
return $this->ccUser;
@@ -172,6 +264,11 @@ class EntityWorkflowStep
return $this->currentStep;
}
/**
* @return array<string>
*
* @deprecated
*/
public function getDestEmail(): array
{
return $this->destEmail;
@@ -198,6 +295,22 @@ class EntityWorkflowStep
return $this->entityWorkflow;
}
/**
* @return Collection<int, EntityWorkflowStepSignature>
*/
public function getSignatures(): Collection
{
return $this->signatures;
}
/**
* @return Collection<int, EntityWorkflowSend>
*/
public function getSends(): Collection
{
return $this->sends;
}
public function getId(): ?int
{
return $this->id;
@@ -243,6 +356,17 @@ class EntityWorkflowStep
return $this->freezeAfter;
}
public function isOnHoldByUser(User $user): bool
{
foreach ($this->getHoldsOnStep() as $onHold) {
if ($onHold->getByUser() === $user) {
return true;
}
}
return false;
}
public function isWaitingForTransition(): bool
{
if (null !== $this->transitionAfter) {
@@ -377,6 +501,11 @@ class EntityWorkflowStep
return $this;
}
public function getHoldsOnStep(): Collection
{
return $this->holdsOnStep;
}
#[Assert\Callback]
public function validateOnCreation(ExecutionContextInterface $context, mixed $payload): void
{
@@ -396,4 +525,13 @@ class EntityWorkflowStep
}
}
}
public function addOnHold(EntityWorkflowStepHold $onHold): self
{
if (!$this->holdsOnStep->contains($onHold)) {
$this->holdsOnStep->add($onHold);
}
return $this;
}
}

View File

@@ -0,0 +1,54 @@
<?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\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table('chill_main_workflow_entity_step_hold')]
#[ORM\UniqueConstraint(name: 'chill_main_workflow_hold_unique_idx', columns: ['step_id', 'byUser_id'])]
class EntityWorkflowStepHold implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
public function __construct(#[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class)]
#[ORM\JoinColumn(nullable: false)]
private EntityWorkflowStep $step, #[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private User $byUser)
{
$step->addOnHold($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getStep(): EntityWorkflowStep
{
return $this->step;
}
public function getByUser(): User
{
return $this->byUser;
}
}

View File

@@ -0,0 +1,206 @@
<?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\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_workflow_entity_step_signature')]
class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, unique: true)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
private ?User $userSigner = null;
#[ORM\ManyToOne(targetEntity: Person::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Person $personSigner = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 50, nullable: false, enumType: EntityWorkflowSignatureStateEnum::class)]
private EntityWorkflowSignatureStateEnum $state = EntityWorkflowSignatureStateEnum::PENDING;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIMETZ_IMMUTABLE, nullable: true, options: ['default' => null])]
private ?\DateTimeImmutable $stateDate = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
private array $signatureMetadata = [];
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true, options: ['default' => null])]
private ?int $zoneSignatureIndex = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class, inversedBy: 'signatures')]
private EntityWorkflowStep $step,
User|Person $signer,
) {
$this->step->addSignature($this);
$this->setSigner($signer);
}
private function setSigner(User|Person $signer): void
{
if ($signer instanceof User) {
$this->userSigner = $signer;
} else {
$this->personSigner = $signer;
}
}
public function getId(): ?int
{
return $this->id;
}
public function getStep(): EntityWorkflowStep
{
return $this->step;
}
public function getSigner(): User|Person
{
if (null !== $this->userSigner) {
return $this->userSigner;
}
return $this->personSigner;
}
public function getSignatureMetadata(): array
{
return $this->signatureMetadata;
}
public function setSignatureMetadata(array $signatureMetadata): EntityWorkflowStepSignature
{
$this->signatureMetadata = $signatureMetadata;
return $this;
}
public function getState(): EntityWorkflowSignatureStateEnum
{
return $this->state;
}
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setState(EntityWorkflowSignatureStateEnum $state): EntityWorkflowStepSignature
{
$this->state = $state;
return $this;
}
public function getStateDate(): ?\DateTimeImmutable
{
return $this->stateDate;
}
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setStateDate(?\DateTimeImmutable $stateDate): EntityWorkflowStepSignature
{
$this->stateDate = $stateDate;
return $this;
}
public function getZoneSignatureIndex(): ?int
{
return $this->zoneSignatureIndex;
}
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setZoneSignatureIndex(?int $zoneSignatureIndex): EntityWorkflowStepSignature
{
$this->zoneSignatureIndex = $zoneSignatureIndex;
return $this;
}
public function isSigned(): bool
{
return EntityWorkflowSignatureStateEnum::SIGNED == $this->getState();
}
public function isPending(): bool
{
return EntityWorkflowSignatureStateEnum::PENDING == $this->getState();
}
public function isCanceled(): bool
{
return EntityWorkflowSignatureStateEnum::CANCELED === $this->getState();
}
public function isRejected(): bool
{
return EntityWorkflowSignatureStateEnum::REJECTED === $this->getState();
}
/**
* Checks whether all signatures associated with a given workflow step are not pending.
*
* Iterates over each signature in the provided workflow step, and returns false if any signature
* is found to be pending. If all signatures are not pending, returns true.
*
* @param EntityWorkflowStep $step the workflow step whose signatures are to be checked
*
* @return bool true if all signatures are not pending, false otherwise
*/
public static function isAllSignatureNotPendingForStep(EntityWorkflowStep $step): bool
{
foreach ($step->getSignatures() as $signature) {
if ($signature->isPending()) {
return false;
}
}
return true;
}
/**
* @return 'person'|'user'
*/
public function getSignerKind(): string
{
if ($this->personSigner instanceof Person) {
return 'person';
}
return 'user';
}
}

View File

@@ -36,6 +36,7 @@ class ChillCollectionType extends AbstractType
$view->vars['identifier'] = $options['identifier'];
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
$view->vars['js_caller'] = $options['js_caller'];
$view->vars['uniqid'] = uniqid();
}
public function configureOptions(OptionsResolver $resolver)

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;
@@ -26,10 +28,14 @@ class EntityToJsonTransformer implements DataTransformerInterface
public function reverseTransform($value)
{
if ('' === $value) {
if (false === $this->multiple && '' === $value) {
return null;
}
if ($this->multiple && [] === $value) {
return [];
}
$denormalized = json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
if ($this->multiple) {
@@ -74,15 +80,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

@@ -18,16 +18,30 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
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
* - `suggest_myself`: append the current user to the list of suggested
* - `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
{
public function __construct(private readonly DenormalizerInterface $denormalizer, private readonly SerializerInterface $serializer, private readonly NormalizerInterface $normalizer) {}
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly SerializerInterface $serializer,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
@@ -46,6 +60,12 @@ class PickUserDynamicType extends AbstractType
foreach ($options['suggested'] as $user) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
$user = $this->security->getUser();
if ($user instanceof User) {
if (true === $options['suggest_myself'] && !in_array($user, $options['suggested'], true)) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
}
}
public function configureOptions(OptionsResolver $resolver)
@@ -54,6 +74,8 @@ class PickUserDynamicType extends AbstractType
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggest_myself', false)
->setAllowedTypes('suggest_myself', ['bool'])
->setDefault('suggested', [])
// if set to true, only the id will be set inside the content. The denormalization will not work.
->setDefault('as_id', false)

View File

@@ -0,0 +1,83 @@
<?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\Entity\User;
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\Security\Core\Security;
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,
private readonly Security $security,
) {}
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']);
}
$user = $this->security->getUser();
if ($user instanceof User) {
if (true === $options['suggest_myself'] && !in_array($user, $options['suggested'], true)) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
}
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggested', [])
->setDefault('suggest_myself', false)
->setAllowedTypes('suggest_myself', ['bool'])
// 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,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\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\EmailType;
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('email', EmailType::class, [
'label' => 'user_group.Email',
'help' => 'user_group.EmailHelp',
'empty_data' => '',
'required' => false,
])
->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

@@ -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 Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class WorkflowSignatureMetadataType extends AbstractType
{
public function __construct(private readonly ParameterBagInterface $parameterBag, private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$documentTypeChoices = $this->parameterBag->get('chill_main')['workflow_signature']['base_signer']['document_kinds'];
$choices = [];
foreach ($documentTypeChoices as $documentType) {
$labels = [];
foreach ($documentType['labels'] as $label) {
$labels[$label['lang']] = $label['label'];
}
$localizedLabel = $this->translatableStringHelper->localize($labels);
if (null !== $localizedLabel) {
$choices[$localizedLabel] = $documentType['key'];
}
}
$builder
->add('documentType', ChoiceType::class, [
'label' => 'workflow.signature_zone.metadata.docType',
'expanded' => false,
'required' => true,
'choices' => $choices,
])
->add('documentNumber', TextType::class, [
'required' => true,
'label' => 'workflow.signature_zone.metadata.docNumber',
])
->add('expirationDate', ChillDateType::class, [
'required' => true,
'input' => 'datetime_immutable',
'label' => 'workflow.signature_zone.metadata.docExpiration',
]);
}
}

View File

@@ -12,232 +12,209 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
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\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
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;
use Symfony\Component\Workflow\Transition;
class WorkflowStepType extends AbstractType
{
public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly Registry $registry, private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function __construct(
private readonly Registry $registry,
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly EntityWorkflowManager $entityWorkflowManager,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $options['entity_workflow'];
$handler = $this->entityWorkflowManager->getHandler($entityWorkflow);
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$place = $workflow->getMarking($entityWorkflow);
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata(array_keys($place->getPlaces())[0]);
$suggestedUsers = $this->entityWorkflowManager->getSuggestedUsers($entityWorkflow);
$suggestedThirdParties = $this->entityWorkflowManager->getSuggestedThirdParties($entityWorkflow);
$suggestedPersons = $this->entityWorkflowManager->getSuggestedPersons($entityWorkflow);
if (true === $options['transition']) {
if (null === $options['entity_workflow']) {
throw new \LogicException('if transition is true, entity_workflow should be defined');
}
if (null === $options['entity_workflow']) {
throw new \LogicException('if transition is true, entity_workflow should be defined');
}
$transitions = $this->registry
->get($options['entity_workflow'], $entityWorkflow->getWorkflowName())
->getEnabledTransitions($entityWorkflow);
$transitions = $this->registry
->get($options['entity_workflow'], $entityWorkflow->getWorkflowName())
->getEnabledTransitions($entityWorkflow);
$choices = array_combine(
array_map(
static fn (Transition $transition) => $transition->getName(),
$transitions
),
$choices = array_combine(
array_map(
static fn (Transition $transition) => $transition->getName(),
$transitions
);
),
$transitions
);
if (\array_key_exists('validationFilterInputLabels', $placeMetadata)) {
$inputLabels = $placeMetadata['validationFilterInputLabels'];
if (\array_key_exists('validationFilterInputLabels', $placeMetadata)) {
$inputLabels = $placeMetadata['validationFilterInputLabels'];
$builder->add('transitionFilter', ChoiceType::class, [
'multiple' => false,
'label' => 'workflow.My decision',
'choices' => [
'forward' => 'forward',
'backward' => 'backward',
'neutral' => 'neutral',
],
'choice_label' => fn (string $key) => $this->translatableStringHelper->localize($inputLabels[$key]),
'choice_attr' => static fn (string $key) => [
$key => $key,
],
'mapped' => false,
'expanded' => true,
'data' => 'forward',
]);
}
$builder
->add('transition', ChoiceType::class, [
'label' => 'workflow.Next step',
'mapped' => false,
'multiple' => false,
'expanded' => true,
'choices' => $choices,
'constraints' => [new NotNull()],
'choice_label' => function (Transition $transition) use ($workflow) {
$meta = $workflow->getMetadataStore()->getTransitionMetadata($transition);
if (\array_key_exists('label', $meta)) {
return $this->translatableStringHelper->localize($meta['label']);
}
return $transition->getName();
},
'choice_attr' => static function (Transition $transition) use ($workflow) {
$toFinal = true;
$isForward = 'neutral';
$metadata = $workflow->getMetadataStore()->getTransitionMetadata($transition);
if (\array_key_exists('isForward', $metadata)) {
if ($metadata['isForward']) {
$isForward = 'forward';
} else {
$isForward = 'backward';
}
}
foreach ($transition->getTos() as $to) {
$meta = $workflow->getMetadataStore()->getPlaceMetadata($to);
if (
!\array_key_exists('isFinal', $meta) || false === $meta['isFinal']
) {
$toFinal = false;
}
}
return [
'data-is-transition' => 'data-is-transition',
'data-to-final' => $toFinal ? '1' : '0',
'data-is-forward' => $isForward,
];
},
])
->add('future_dest_users', PickUserDynamicType::class, [
'label' => 'workflow.dest for next steps',
'multiple' => true,
'mapped' => false,
'suggested' => $options['suggested_users'],
])
->add('future_cc_users', PickUserDynamicType::class, [
'label' => 'workflow.cc for next steps',
'multiple' => true,
'mapped' => false,
'required' => false,
'suggested' => $options['suggested_users'],
])
->add('future_dest_emails', ChillCollectionType::class, [
'label' => 'workflow.dest by email',
'help' => 'workflow.dest by email help',
'mapped' => false,
'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->add('transitionFilter', ChoiceType::class, [
'multiple' => false,
'label' => 'workflow.My decision',
'choices' => [
'forward' => 'forward',
'backward' => 'backward',
'neutral' => 'neutral',
],
'choice_label' => fn (string $key) => $this->translatableStringHelper->localize($inputLabels[$key]),
'choice_attr' => static fn (string $key) => [
$key => $key,
],
'mapped' => false,
'expanded' => true,
'data' => 'forward',
]);
}
if (
$handler->supportsFreeze($entityWorkflow)
&& !$entityWorkflow->isFreeze()
) {
$builder
->add('freezeAfter', CheckboxType::class, [
'required' => false,
'label' => 'workflow.Freeze',
'help' => 'workflow.The associated element will be freezed',
]);
}
$builder
->add('transition', ChoiceType::class, [
'label' => 'workflow.Next step',
'multiple' => false,
'expanded' => true,
'choices' => $choices,
'constraints' => [new NotNull()],
'choice_label' => function (Transition $transition) use ($workflow) {
$meta = $workflow->getMetadataStore()->getTransitionMetadata($transition);
if (\array_key_exists('label', $meta)) {
return $this->translatableStringHelper->localize($meta['label']);
}
return $transition->getName();
},
'choice_attr' => static function (Transition $transition) use ($workflow) {
$toFinal = true;
$isForward = 'neutral';
$isSignature = [];
$isSentExternal = false;
$metadata = $workflow->getMetadataStore()->getTransitionMetadata($transition);
if (\array_key_exists('isForward', $metadata)) {
if ($metadata['isForward']) {
$isForward = 'forward';
} else {
$isForward = 'backward';
}
}
foreach ($transition->getTos() as $to) {
$meta = $workflow->getMetadataStore()->getPlaceMetadata($to);
if (
!\array_key_exists('isFinal', $meta) || false === $meta['isFinal']
) {
$toFinal = false;
}
if (\array_key_exists('isSignature', $meta)) {
$isSignature = $meta['isSignature'];
}
$isSentExternal = $isSentExternal ? true : $meta['isSentExternal'] ?? false;
}
return [
'data-is-transition' => 'data-is-transition',
'data-to-final' => $toFinal ? '1' : '0',
'data-is-forward' => $isForward,
'data-is-signature' => json_encode($isSignature),
'data-is-sent-external' => $isSentExternal ? '1' : '0',
];
},
])
->add('isPersonOrUserSignature', ChoiceType::class, [
'mapped' => false,
'multiple' => false,
'expanded' => true,
'label' => 'workflow.signature_zone.type of signature',
'choices' => [
'workflow.signature_zone.persons' => 'person',
'workflow.signature_zone.user' => 'user',
],
])
->add('futurePersonSignatures', PickPersonDynamicType::class, [
'label' => 'workflow.signature_zone.person signatures',
'multiple' => true,
'empty_data' => '[]',
'suggested' => $suggestedPersons,
])
->add('futureUserSignature', PickUserDynamicType::class, [
'label' => 'workflow.signature_zone.user signature',
'multiple' => false,
'suggest_myself' => true,
'suggested' => $suggestedUsers,
])
->add('futureDestUsers', PickUserGroupOrUserDynamicType::class, [
'label' => 'workflow.dest for next steps',
'multiple' => true,
'empty_data' => '[]',
'suggested' => $suggestedUsers,
'suggest_myself' => true,
])
->add('futureCcUsers', PickUserDynamicType::class, [
'label' => 'workflow.cc for next steps',
'multiple' => true,
'required' => false,
'suggested' => $suggestedUsers,
'empty_data' => '[]',
'attr' => ['class' => 'future-cc-users'],
'suggest_myself' => true,
])
->add('futureDestineeEmails', ChillCollectionType::class, [
'entry_type' => EmailType::class,
'entry_options' => [
'empty_data' => '',
],
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => static fn (?string $email) => '' === $email || null === $email,
'button_add_label' => 'workflow.transition_destinee_add_emails',
'button_remove_label' => 'workflow.transition_destinee_remove_emails',
'help' => 'workflow.transition_destinee_emails_help',
'label' => 'workflow.transition_destinee_emails_label',
])
->add('futureDestineeThirdParties', PickThirdpartyDynamicType::class, [
'label' => 'workflow.transition_destinee_third_party',
'help' => 'workflow.transition_destinee_third_party_help',
'multiple' => true,
'empty_data' => [],
'suggested' => $suggestedThirdParties,
]);
$builder
->add('comment', ChillTextareaType::class, [
'required' => false,
'label' => 'Comment',
'empty_data' => '',
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefined('class')
->setRequired('transition')
->setAllowedTypes('transition', 'bool')
->setDefault('data_class', WorkflowTransitionContextDTO::class)
->setRequired('entity_workflow')
->setAllowedTypes('entity_workflow', EntityWorkflow::class)
->setDefault('suggested_users', [])
->setDefault('constraints', [
new Callback(
function ($step, ExecutionContextInterface $context, $payload) {
/** @var EntityWorkflowStep $step */
$form = $context->getObject();
$workflow = $this->registry->get($step->getEntityWorkflow(), $step->getEntityWorkflow()->getWorkflowName());
$transition = $form['transition']->getData();
$toFinal = true;
if (null === $transition) {
$context
->buildViolation('workflow.You must select a next step, pick another decision if no next steps are available');
} else {
foreach ($transition->getTos() as $to) {
$meta = $workflow->getMetadataStore()->getPlaceMetadata($to);
if (
!\array_key_exists('isFinal', $meta) || false === $meta['isFinal']
) {
$toFinal = false;
}
}
$destUsers = $form['future_dest_users']->getData();
$destEmails = $form['future_dest_emails']->getData();
if (!$toFinal && [] === $destUsers && [] === $destEmails) {
$context
->buildViolation('workflow.You must add at least one dest user or email')
->atPath('future_dest_users')
->addViolation();
}
}
}
),
new Callback(
function ($step, ExecutionContextInterface $context, $payload) {
$form = $context->getObject();
foreach ($form->get('future_dest_users')->getData() as $u) {
if (in_array($u, $form->get('future_cc_users')->getData(), true)) {
$context
->buildViolation('workflow.The user in cc cannot be a dest user in the same workflow step')
->atPath('ccUsers')
->addViolation();
}
}
}
),
]);
->setAllowedTypes('entity_workflow', EntityWorkflow::class);
}
}

View File

@@ -0,0 +1,54 @@
<?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\Workflow\EntityWorkflowSendView;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-implements ObjectRepository<EntityWorkflowSendView>
*/
class EntityWorkflowSendViewRepository implements ObjectRepository
{
private readonly ObjectRepository $repository;
public function __construct(ManagerRegistry $registry)
{
$this->repository = $registry->getRepository($this->getClassName());
}
public function find($id): ?EntityWorkflowSendView
{
return $this->repository->find($id);
}
public function findAll()
{
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): ?EntityWorkflowSendView
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return EntityWorkflowSendView::class;
}
}

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,7 +12,9 @@ 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;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -99,6 +101,24 @@ class EntityWorkflowRepository implements ObjectRepository
return $this->repository->findAll();
}
/**
* @return list<EntityWorkflow>
*/
public function findByRelatedEntity($entityClass, $relatedEntityId): array
{
$qb = $this->repository->createQueryBuilder('w');
$query = $qb->where(
$qb->expr()->andX(
$qb->expr()->eq('w.relatedEntityClass', ':entity_class'),
$qb->expr()->eq('w.relatedEntityId', ':entity_id'),
)
)->setParameter('entity_class', $entityClass)
->setParameter('entity_id', $relatedEntityId);
return $query->getQuery()->getResult();
}
/**
* @param mixed|null $limit
* @param mixed|null $offset
@@ -180,6 +200,36 @@ class EntityWorkflowRepository implements ObjectRepository
return $this->repository->findOneBy($criteria);
}
/**
* Finds workflows that are not finalized and are older than the specified date.
*
* @param \DateTimeImmutable $olderThanDate the date to compare against
*
* @return iterable<EntityWorkflow>
*/
public function findWorkflowsWithoutFinalStepAndOlderThan(\DateTimeImmutable $olderThanDate): iterable
{
$qb = $this->repository->createQueryBuilder('sw');
$qb->select('sw')
// only the workflow which are not finalized
->where(sprintf('NOT EXISTS (SELECT 1 FROM %s ews WHERE ews.isFinal = TRUE AND ews.entityWorkflow = sw.id)', EntityWorkflowStep::class))
->andWhere(
$qb->expr()->orX(
// only the workflow where all the transitions are older than transitionAt
sprintf(':olderThanDate > ALL (SELECT ews2.transitionAt FROM %s ews2 WHERE ews2.transitionAt IS NOT NULL AND ews2.entityWorkflow = sw)', EntityWorkflowStep::class),
// or the workflow which have only the initial step, with no transition
sprintf('1 = (SELECT COUNT(ews3.id) FROM %s ews3 WHERE ews3.currentStep = :initial AND ews3.transitionAt IS NULL AND ews3.entityWorkflow = sw)', EntityWorkflowStep::class),
)
)
->andWhere('sw.createdAt < :olderThanDate')
->setParameter('olderThanDate', $olderThanDate)
->setParameter('initial', 'initial')
;
return $qb->getQuery()->toIterable();
}
public function getClassName(): string
{
return EntityWorkflow::class;
@@ -212,7 +262,11 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->where(
$qb->expr()->andX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$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

@@ -0,0 +1,23 @@
<?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\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\PersonBundle\Entity\Person;
interface EntityWorkflowSignatureACLAwareRepositoryInterface
{
/**
* @return array<EntityWorkflowStepSignature>
*/
public function findByPersonAndPendingState(Person $person): array;
}

View File

@@ -0,0 +1,79 @@
<?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\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<EntityWorkflowStepHold>
*/
class EntityWorkflowStepHoldRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EntityWorkflowStepHold::class);
}
/**
* Find an EntityWorkflowStepHold by its ID.
*/
public function findById(int $id): ?EntityWorkflowStepHold
{
return $this->find($id);
}
/**
* Find all EntityWorkflowStepHold records.
*
* @return EntityWorkflowStepHold[]
*/
public function findAllHolds(): array
{
return $this->findAll();
}
/**
* Find EntityWorkflowStepHold by a specific step.
*
* @return EntityWorkflowStepHold[]
*/
public function findByStep(EntityWorkflowStep $step): array
{
return $this->findBy(['step' => $step]);
}
/**
* Find a single EntityWorkflowStepHold by step and user.
*
* @throws NonUniqueResultException
*/
public function findOneByStepAndUser(EntityWorkflowStep $step, User $user): ?EntityWorkflowStepHold
{
try {
return $this->createQueryBuilder('e')
->andWhere('e.step = :step')
->andWhere('e.byUser = :user')
->setParameter('step', $step)
->setParameter('user', $user)
->getQuery()
->getSingleResult();
} catch (NoResultException) {
return null;
}
}
}

View File

@@ -12,15 +12,16 @@ 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;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class EntityWorkflowStepRepository implements ObjectRepository
readonly class EntityWorkflowStepRepository implements ObjectRepository
{
private readonly EntityRepository $repository;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
@@ -65,7 +66,11 @@ class EntityWorkflowStepRepository implements ObjectRepository
$qb->where(
$qb->expr()->andX(
$qb->expr()->isMemberOf(':user', 'e.destUser'),
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'e.destUser'),
$qb->expr()->isMemberOf(':user', 'e.destUserByAccessKey'),
$qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF e.destUserGroups AND :user MEMBER OF ug.users', UserGroup::class))
),
$qb->expr()->isNull('e.transitionAt'),
$qb->expr()->eq('e.isFinal', ':bool'),
)

View File

@@ -0,0 +1,54 @@
<?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\Workflow;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Security\Core\Security;
class EntityWorkflowStepSignatureACLAwareRepository implements EntityWorkflowSignatureACLAwareRepositoryInterface
{
public function __construct(
private readonly EntityWorkflowStepSignatureRepository $repository,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly Security $security,
) {}
public function findByPersonAndPendingState(Person $person): array
{
$signatures = $this->repository->findByPerson($person);
$filteredSignatures = [];
foreach ($signatures as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState() || EntityWorkflowSignatureStateEnum::CANCELED === $signature->getState() || EntityWorkflowSignatureStateEnum::REJECTED === $signature->getState()) {
continue;
}
$workflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($workflow);
if (null === $storedObject) {
continue;
}
if ($this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
$filteredSignatures[] = $signature;
}
}
return $filteredSignatures;
}
}

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\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Chill\PersonBundle\Entity\Person;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<EntityWorkflowStepSignature>
*/
class EntityWorkflowStepSignatureRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EntityWorkflowStepSignature::class);
}
public function findByPerson(Person $person): array
{
$qb = $this->createQueryBuilder('ewss');
$qb->select('ewss');
$qb->where($qb->expr()->eq('ewss.personSigner', ':person'));
$qb->setParameter('person', $person);
return $qb->getQuery()->getResult();
}
}

View File

@@ -31,6 +31,8 @@
// Specific templates
@import './scss/notification';
@import './scss/hover.scss';
/*
* BASE LAYOUT POSITION
*/
@@ -496,6 +498,7 @@ div.workflow {
div.breadcrumb {
display: initial;
margin-bottom: 0;
margin-right: .5rem;
padding-right: 0.5em;
background-color: tint-color($chill-yellow, 90%);
border: 1px solid $chill-yellow;

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

@@ -0,0 +1,11 @@
.row.row-hover {
padding: 0.3rem;
&:hover {
background-color: $gray-100;
border-top: 1px solid $gray-400;
border-bottom: 1px solid $gray-400;
}
}

View File

@@ -17,6 +17,10 @@ ul.record_actions {
display: inline-block;
}
&.slim {
margin-bottom: 0;
}
&.column {
flex-direction: column;
}

View File

@@ -1,12 +0,0 @@
const buildLinkCreate = function(workflowName, relatedEntityClass, relatedEntityId) {
let params = new URLSearchParams();
params.set('entityClass', relatedEntityClass);
params.set('entityId', relatedEntityId);
params.set('workflow', workflowName);
return `/fr/main/workflow/create?`+params.toString();
};
export {
buildLinkCreate,
};

View File

@@ -0,0 +1,22 @@
/**
* Constructs a URL for creating a workflow link associated with a specific entity.
*
* @param {string} workflowName - The name of the workflow to associate.
* @param {string} relatedEntityClass - The class/type of the related entity.
* @param {number|undefined} relatedEntityId - The unique identifier of the related entity. Must be defined.
*
* @returns {string} The constructed URL containing query parameters based on the provided arguments.
*
* @throws {Error} If the related entity ID is undefined.
*/
export const buildLinkCreate = (workflowName: string, relatedEntityClass: string, relatedEntityId: number|undefined): string => {
if (typeof (relatedEntityId) === 'undefined') {
throw new Error("the related entity id is not set");
}
let params = new URLSearchParams();
params.set('entityClass', relatedEntityClass);
params.set('entityId', relatedEntityId.toString(10));
params.set('workflow', workflowName);
return `/fr/main/workflow/create?`+params.toString();
};

View File

@@ -30,6 +30,12 @@
*/
import './collection.scss';
declare global {
interface GlobalEventHandlersEventMap {
'show-hide-show': CustomEvent<{id: number, froms: HTMLElement[], container: HTMLElement}>,
}
}
export class CollectionEventPayload {
collection: HTMLUListElement;
entry: HTMLLIElement;
@@ -107,20 +113,34 @@ export const buildRemoveButton = (collection: HTMLUListElement, entry: HTMLLIEle
return button;
}
window.addEventListener('load', () => {
const collectionsInit = new Set<string>;
const buttonsInit = new Set<string>();
const initialize = function (target: Document|Element): void {
let
addButtons: NodeListOf<HTMLButtonElement> = document.querySelectorAll("button[data-collection-add-target]"),
collections: NodeListOf<HTMLUListElement> = document.querySelectorAll("ul[data-collection-regular]");
for (let i = 0; i < addButtons.length; i++) {
let addButton = addButtons[i];
const addButton = addButtons[i];
const uniqid = addButton.dataset.uniqid as string;
if (buttonsInit.has(uniqid)) {
continue;
}
buttonsInit.add(uniqid);
addButton.addEventListener('click', (e: Event) => {
e.preventDefault();
handleAdd(e.target);
});
}
for (let i = 0; i < collections.length; i++) {
let entries: NodeListOf<HTMLLIElement> = collections[i].querySelectorAll(':scope > li');
const collection = collections[i];
const uniqid = collection.dataset.uniqid as string;
if (collectionsInit.has(uniqid)) {
continue;
}
collectionsInit.add(uniqid);
let entries: NodeListOf<HTMLLIElement> = collection.querySelectorAll(':scope > li');
for (let j = 0; j < entries.length; j++) {
if (entries[j].dataset.collectionEmptyExplain === "1") {
continue;
@@ -128,4 +148,13 @@ window.addEventListener('load', () => {
initializeRemove(collections[i], entries[j]);
}
}
};
window.addEventListener('DOMContentLoaded', () => {
initialize(document);
});
window.addEventListener('show-hide-show', (event: CustomEvent<{id: number; container: HTMLElement; froms: HTMLElement[]}>) => {
const container = event.detail.container as HTMLElement;
initialize(container);
})

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,10 +99,11 @@ function loadDynamicPicker(element) {
}
}
}
if (this.submit_on_adding_new_entity) {
input.form.submit();
}
},
addNewEntityProcessEnded() {
if (this.submit_on_adding_new_entity) {
input.form.submit();
}
},
removeEntity({entity}) {
if (-1 === this.suggested.findIndex(e => e.type === entity.type && e.id === entity.id)) {

View File

@@ -1,41 +1,145 @@
import {ShowHide} from 'ChillMainAssets/lib/show_hide/show_hide.js';
window.addEventListener('DOMContentLoaded', function() {
let
const
divTransitions = document.querySelector('#transitions'),
futureDestUsersContainer = document.querySelector('#futureDests')
futureDestUsersContainer = document.querySelector('#futureDests'),
personSignatureField = document.querySelector('#person-signature-field'),
userSignatureField = document.querySelector('#user-signature-field'),
signatureTypeChoices = document.querySelector('#signature-type-choice'),
personChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_0'),
userChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_1'),
signatureZone = document.querySelector('#signature-zone'),
transitionFilterContainer = document.querySelector('#transitionFilter'),
transitionsContainer = document.querySelector('#transitions'),
sendExternalContainer = document.querySelector('#sendExternalContainer')
;
if (null !== divTransitions) {
new ShowHide({
load_event: null,
froms: [divTransitions],
container: [futureDestUsersContainer],
test: function(divs, arg2, arg3) {
for (let div of divs) {
for (let input of div.querySelectorAll('input')) {
if (input.checked) {
if (input.dataset.toFinal === "1") {
return false;
} else {
return true;
}
}
}
}
// ShowHide instance for future dest users
new ShowHide({
debug: false,
load_event: null,
froms: [divTransitions],
container: [futureDestUsersContainer],
test: function(froms, event) {
for (let transition of froms) {
for (let input of transition.querySelectorAll('input')) {
if (input.checked) {
if ('1' === input.dataset.toFinal) {
return false;
}
if ('1' === input.dataset.isSentExternal) {
return false;
}
const inputData = JSON.parse(input.getAttribute('data-is-signature'))
if (inputData.includes('person') || inputData.includes('user')) {
return false;
} else {
personChoice.checked = false
userChoice.checked = false
return true;
},
});
}
}
}
}
return false;
}
});
// ShowHide instance for send external
new ShowHide({
debug: false,
load_event: null,
froms: [divTransitions],
container: [sendExternalContainer],
test: function(froms, event) {
for (let transition of froms) {
for (let input of transition.querySelectorAll('input')) {
if (input.checked) {
if ('1' === input.dataset.isSentExternal) {
return true;
}
}
}
}
return false;
}
})
// ShowHide signature zone
new ShowHide({
debug: false,
load_event: null,
froms: [divTransitions],
container: [signatureZone],
test: function(froms, event) {
for (let transition of froms) {
for (let input of transition.querySelectorAll('input')) {
if (input.checked) {
const inputData = JSON.parse(input.getAttribute('data-is-signature'))
if (inputData.includes('person') || inputData.includes('user')) {
return true;
}
}
}
}
return false;
}
});
// ShowHides for personSignatureField or userSignatureField within signature zone
new ShowHide({
debug: false,
froms: [signatureTypeChoices],
container: [personSignatureField],
test: function(froms, event) {
for (let container of froms) {
return personChoice.checked;
}
return false;
},
});
new ShowHide({
debug: false,
froms: [signatureTypeChoices],
container: [userSignatureField],
test: function(froms, event) {
for (let container of froms) {
return userChoice.checked;
}
return false;
},
});
if (null !== divTransitions) {
new ShowHide({
load_event: null,
froms: [divTransitions],
container: [futureDestUsersContainer],
test: function(divs, arg2, arg3) {
for (let div of divs) {
for (let input of div.querySelectorAll('input')) {
if (input.checked) {
if (input.dataset.toFinal === "1") {
return false;
} else {
return true;
}
}
}
}
return true;
},
});
}
let
transitionFilterContainer = document.querySelector('#transitionFilter'),
transitions = document.querySelector('#transitions')
;
if (null !== transitionFilterContainer) {
transitions.querySelectorAll('.form-check').forEach(function(row) {
transitionsContainer.querySelectorAll('.form-check').forEach(function(row) {
const isForward = row.querySelector('input').dataset.isForward;
@@ -51,6 +155,7 @@ window.addEventListener('DOMContentLoaded', function() {
}
}
}
return true;
},
toggle_callback: function (c, dir) {
for (let div of c) {
@@ -66,5 +171,4 @@ window.addEventListener('DOMContentLoaded', function() {
});
});
}
});

View File

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

View File

@@ -9,13 +9,16 @@
</template>
<template v-slot:tbody>
<tr v-for="(w, i) in workflows.results" :key="`workflow-${i}`">
<td>{{ w.title }}</td>
<td>
{{ w.title }}
</td>
<td>
<div class="workflow">
<div class="breadcrumb">
<i class="fa fa-circle me-1 text-chill-yellow mx-2"></i>
<span class="mx-2">{{ getStep(w) }}</span>
</div>
<span v-if="w.isOnHoldAtCurrentStep" class="badge bg-success rounded-pill">{{ $t('on_hold') }}</span>
</div>
</td>
<td v-if="w.datas.persons !== null">

View File

@@ -41,6 +41,7 @@ const appMessages = {
Step: "Étape",
concerned_users: "Usagers concernés",
Object_workflow: "Objet du workflow",
on_hold: "En attente",
show_entity: "Voir {entity}",
the_activity: "l'échange",
the_course: "le parcours",

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

@@ -6,7 +6,7 @@
<div>
<div class="item-row col">
<h2>{{ w.title }}</h2>
<div class="flex-grow-1 ms-3 h3">
<div class="flex-grow-1 ms-3 h3">
<div class="visually-hidden">
{{ w.relatedEntityClass }}
{{ w.relatedEntityId }}
@@ -38,6 +38,7 @@
</span>
</template>
</div>
<span v-if="w.isOnHoldAtCurrentStep" class="badge bg-success rounded-pill">{{ $t('on_hold') }}</span>
</div>
<div class="item-row">
@@ -73,7 +74,8 @@ const i18n = {
you_subscribed_to_all_steps: "Vous recevrez une notification à chaque étape",
you_subscribed_to_final_step: "Vous recevrez une notification à l'étape finale",
by: "Par",
at: "Le"
at: "Le",
on_hold: "En attente"
}
}
}
@@ -100,11 +102,17 @@ export default {
},
getPopContent(step) {
if (step.transitionPrevious != null) {
return `<ul class="small_in_title">
<li><span class="item-key">${i18n.messages.fr.by} : </span><b>${step.transitionPreviousBy.text}</b></li>
<li><span class="item-key">${i18n.messages.fr.at} : </span><b>${this.formatDate(step.transitionPreviousAt.datetime)}</b></li>
</ul>`
;
if (step.transitionPreviousBy !== null) {
return `<ul class="small_in_title">
<li><span class="item-key">${i18n.messages.fr.by} : </span><b>${step.transitionPreviousBy.text}</b></li>
<li><span class="item-key">${i18n.messages.fr.at} : </span><b>${this.formatDate(step.transitionPreviousAt.datetime)}</b></li>
</ul>`
;
} else {
return `<ul class="small_in_title">
<li><span class="item-key">${i18n.messages.fr.at} : </span><b>${this.formatDate(step.transitionPreviousAt.datetime)}</b></li>
</ul>`
}
}
},
formatDate(datetime) {

View File

@@ -1,20 +1,13 @@
<template>
<button v-if="hasWorkflow"
class="btn btn-primary"
@click="openModal">
<b>{{ countWorkflows }}</b>
<template v-if="countWorkflows > 1">{{ $t('workflows') }}</template>
<template v-else>{{ $t('workflow') }}</template>
</button>
<pick-workflow v-else-if="allowCreate"
<pick-workflow
:relatedEntityClass="this.relatedEntityClass"
:relatedEntityId="this.relatedEntityId"
:workflowsAvailables="workflowsAvailables"
:preventDefaultMoveToGenerate="this.$props.preventDefaultMoveToGenerate"
:goToGenerateWorkflowPayload="this.goToGenerateWorkflowPayload"
:countExistingWorkflows="countWorkflows"
@go-to-generate-workflow="goToGenerateWorkflow"
@click-open-list="openModal"
></pick-workflow>
<teleport to="body">
@@ -39,6 +32,8 @@
:workflowsAvailables="workflowsAvailables"
:preventDefaultMoveToGenerate="this.$props.preventDefaultMoveToGenerate"
:goToGenerateWorkflowPayload="this.goToGenerateWorkflowPayload"
:countExistingWorkflows="countWorkflows"
:embedded-within-list-modal="true"
@go-to-generate-workflow="this.goToGenerateWorkflow"
></pick-workflow>
</template>

View File

@@ -1,63 +1,104 @@
<template>
<template v-if="workflowsAvailables.length >= 1">
<div class="dropdown d-grid gap-2">
<template v-if="props.workflowsAvailables.length >= 1">
<div v-if="countExistingWorkflows == 0 || embeddedWithinListModal" class="dropdown d-grid gap-2">
<button class="btn btn-primary dropdown-toggle" type="button" id="createWorkflowButton" data-bs-toggle="dropdown" aria-expanded="false">
Créer un workflow
</button>
<ul class="dropdown-menu" aria-labelledby="createWorkflowButton">
<li v-for="w in workflowsAvailables" :key="w.name">
<a class="dropdown-item" :href="makeLink(w.name)" @click.prevent="goToGenerateWorkflow($event, w.name)">{{ w.text }}</a>
<li v-for="w in props.workflowsAvailables" :key="w.name">
<button class="dropdown-item" type="button" @click.prevent="goToGenerateWorkflow($event, w.name)">{{ w.text }}</button>
</li>
</ul>
</div>
<div v-else>
<div class="btn-group">
<button @click="emit('clickOpenList')" class="btn btn-primary">
<template v-if="countExistingWorkflows === 1">
1 workflow associé
</template>
<template v-else>
{{ countExistingWorkflows }} workflows associés
</template>
</button>
<button class="btn btn-primary dropdown-toggle dropdown-toggle-split" type="button" id="createWorkflowButton" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Liste des workflows disponibles</span>
</button>
<ul class="dropdown-menu" aria-labelledby="createWorkflowButton">
<li v-for="w in props.workflowsAvailables" :key="w.name">
<button class="dropdown-item" type="button" @click.prevent="goToGenerateWorkflow($event, w.name)">{{ w.text }}</button>
</li>
</ul>
</div>
</div>
</template>
<template v-else>
<div v-if="countExistingWorkflows > 0" class="dropdown d-grid gap-2">
<button @click="emit('clickOpenList')" class="btn btn-primary" type="button" id="createWorkflowButton" data-bs-toggle="dropdown" aria-expanded="false">
<template v-if="countExistingWorkflows === 1">
1 workflow associé
</template>
<template v-else>
{{ countExistingWorkflows }} workflows associés
</template>
</button>
</div>
</template>
</template>
<script>
<script setup lang="ts">
import {buildLinkCreate} from 'ChillMainAssets/lib/entity-workflow/api.js';
import {buildLinkCreate} from '../../../lib/entity-workflow/api';
import {WorkflowAvailable} from "../../../types";
export default {
name: "PickWorkflow",
props: {
relatedEntityClass: {
type: String,
required: true,
},
relatedEntityId: {
type: Number,
required: false,
},
workflowsAvailables: {
type: Array,
required: true,
},
preventDefaultMoveToGenerate: {
type: Boolean,
required: false,
default: false,
},
goToGenerateWorkflowPayload: {
required: false,
default: {}
},
},
emits: ['goToGenerateWorkflow'],
methods: {
makeLink(workflowName) {
return buildLinkCreate(workflowName, this.relatedEntityClass, this.relatedEntityId);
},
goToGenerateWorkflow(event, workflowName) {
console.log('goToGenerateWorkflow', event, workflowName);
interface PickWorkflowConfig {
relatedEntityClass: string;
/**
* Represents the identifier of a related entity.
* This variable can store either a numerical value representing the entity's ID or an undefined value
* if the related entity is not specified or available, for instance when the entity is created within
* the interface and the id will be available later
*/
relatedEntityId: number|undefined;
workflowsAvailables: WorkflowAvailable[];
preventDefaultMoveToGenerate: boolean;
goToGenerateWorkflowPayload: object;
countExistingWorkflows: number;
/**
* if true, this button will not present a splitted button
*/
embeddedWithinListModal: boolean;
}
if (!this.$props.preventDefaultMoveToGenerate) {
console.log('to go generate');
window.location.assign(this.makeLink(workflowName));
}
const props = withDefaults(defineProps<PickWorkflowConfig>(), {preventDefaultMoveToGenerate: false, goToGenerateWorkflowPayload: {}, allowCreateWorkflow: false});
this.$emit('goToGenerateWorkflow', {event, workflowName, link: this.makeLink(workflowName), payload: this.goToGenerateWorkflowPayload});
}
const emit = defineEmits<{
(e: 'goToGenerateWorkflow', {event: MouseEvent, workflowName: string, isLinkValid: boolean, link: string, payload: object}): void;
(e: 'clickOpenList'): void;
}>();
const makeLink = (workflowName: string): string => buildLinkCreate(workflowName, props.relatedEntityClass, props.relatedEntityId);
const goToGenerateWorkflow = (event: MouseEvent, workflowName: string): void => {
console.log('goToGenerateWorkflow', event, workflowName);
let link = '';
let isLinkValid = false;
try {
link = makeLink(workflowName);
isLinkValid = true;
} catch (e) {
console.info("could not generate link to create workflow, maybe the relatedEntityId is not yet known", e);
}
if (!props.preventDefaultMoveToGenerate) {
window.location.assign(link);
}
emit('goToGenerateWorkflow', {event, workflowName, link, isLinkValid, payload: props.goToGenerateWorkflowPayload});
}
const goToDuplicateRelatedEntity = (event: MouseEvent, workflowName: string): void => {
}
</script>

View File

@@ -300,7 +300,96 @@
</ul>
</div>
<h3><code>slim</code></h3>
<p>Ajouter <code>slim</code> enlève la marge inférieure. Permet un meilleur alignement horizontal dans une <code>row</code></p>
<div class="container">
<div class="row row-hover">
<div class="col-8">
Some text, ul_record_actions sans slim
</div>
<div class="col-4">
<ul class="record_actions">
<li><button class="btn">Some action</button></li>
</ul>
</div>
</div>
<div class="row row-hover">
<div class="col-8">
Some text, ul_record_actions avec slim
</div>
<div class="col-4">
<ul class="record_actions slim">
<li><button class="btn">Some action</button></li>
</ul>
</div>
</div>
</div>
<xmp><a class="btn btn-submit">Text</a></xmp>
Toutes les classes btn-* de bootstrap sont fonctionnelles
<h2>Hover</h2>
<p>Ajouter <code>.row-hover</code> sur une class <code>.row</code> provoque un changement de background au survol</p>
<div class="container">
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="329" data-action="show" data-button-text="Fatoumata Binta DIALLO (33 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Fatoumata Binta DIALLO (33 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<span class="text-end">A signé le 04/09/2024 à 13:55</span>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="330" data-action="show" data-button-text="Abdoulaye DIALLO (9 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Abdoulaye DIALLO (9 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<ul class="record_actions">
<li>
<a class="btn btn-misc" href="/fr/main/workflow/signature/6/metadata?returnPath=/fr/main/workflow/15/show"><i class="fa fa-pencil-square-o"></i> Signer</a>
</li>
</ul>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="332" data-action="show" data-button-text="Mohamed DIALLO (44 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Mohamed DIALLO (44 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<span class="text-end">A signé le 04/09/2024 à 13:57</span>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="333" data-action="show" data-button-text="Fatou DIALLO (37 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Fatou DIALLO (37 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<ul class="record_actions">
<li>
<a class="btn btn-misc" href="/fr/main/workflow/signature/8/metadata?returnPath=/fr/main/workflow/15/show"><i class="fa fa-pencil-square-o"></i> Signer</a>
</li>
</ul>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="334" data-action="show" data-button-text="Fanta DIALLO (7 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Fanta DIALLO (7 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<ul class="record_actions">
<li>
<a class="btn btn-misc" href="/fr/main/workflow/signature/9/metadata?returnPath=/fr/main/workflow/15/show"><i class="fa fa-pencil-square-o"></i> Signer</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '/_dev/specs.yaml',
dom_id: '#swagger-ui',
});
};
</script>
</body>
</html>

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

@@ -163,6 +163,7 @@
<div class="chill-collection">
<ul class="list-entry"
{{ form.vars.js_caller }}="{{ form.vars.js_caller }}"
data-uniqid="{{ form.vars.uniqid|escape('html_attr') }}"
data-collection-name="{{ form.vars.name|escape('html_attr') }}"
data-collection-identifier="{{ form.vars.identifier|escape('html_attr') }}"
data-collection-button-remove-label="{{ form.vars.button_remove_label|trans|e }}"
@@ -184,6 +185,8 @@
{% if form.vars.allow_add == 1 %}
<button class="add-entry btn btn-misc"
type="button"
data-uniqid="{{ form.vars.uniqid|escape('html_attr') }}"
data-collection-add-target="{{ form.vars.name|escape('html_attr') }}"
data-form-prototype="{{ ('<div>' ~ form_widget(form.vars.prototype) ~ '</div>')|escape('html_attr') }}" >
<i class="fa fa-plus fa-fw"></i>

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

@@ -6,7 +6,7 @@
{{ form_errors(transition_form) }}
{% set step = entity_workflow.currentStepChained %}
{% set labels = workflow_metadata(entity_workflow, 'label', step.currentStep) %}
{% set labels = workflow_metadata(entity_workflow, 'label', step.currentStep, entity_workflow.workflowName) %}
{% set label = labels is null ? step.currentStep : labels|localize_translatable_string %}
<div class="card">
@@ -58,24 +58,57 @@
{{ form_row(transition_form.transition) }}
</div>
{% if transition_form.freezeAfter is defined %}
{{ form_row(transition_form.freezeAfter) }}
{% endif %}
<div id="signature-zone">
<div id="signature-type-choice">
{{ form_row(transition_form.isPersonOrUserSignature) }}
{{ form_errors(transition_form.isPersonOrUserSignature) }}
</div>
<div id="user-signature-field">
{{ form_row(transition_form.futureUserSignature) }}
{{ form_errors(transition_form.futureUserSignature) }}
</div>
<div id="person-signature-field">
{{ form_row(transition_form.futurePersonSignatures) }}
{{ form_errors(transition_form.futurePersonSignatures) }}
</div>
</div>
<div id="futureDests">
{{ form_row(transition_form.future_dest_users) }}
<div id="future-dest-users">
{{ form_row(transition_form.futureDestUsers) }}
{{ form_errors(transition_form.futureDestUsers) }}
</div>
<div id="future-cc-users">
{{ form_row(transition_form.futureCcUsers) }}
{{ form_errors(transition_form.futureCcUsers) }}
</div>
</div>
{{ form_row(transition_form.future_cc_users) }}
{{ form_row(transition_form.future_dest_emails) }}
{{ form_errors(transition_form.future_dest_users) }}
<div id="sendExternalContainer">
{{ form_row(transition_form.futureDestineeThirdParties) }}
{{ form_errors(transition_form.futureDestineeThirdParties) }}
{{ form_row(transition_form.futureDestineeEmails) }}
{{ form_errors(transition_form.futureDestineeEmails) }}
</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>
@@ -98,15 +131,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

@@ -1,8 +1,5 @@
{% set acl = "0" %}
{% if is_granted('CHILL_MAIN_WORKFLOW_CREATE', blank_workflow) %}
{% set acl = "1" %}
{% endif %}
{% set acl = "1" %}
{# note: the list of available workflows already check that it is possible to create a given workflow #}
{# vue component #}
<div data-list-workflows="1"
data-workflows="{{ entity_workflows_json|json_encode|e('html_attr') }}"

View File

@@ -2,7 +2,7 @@
<div class="flex-table">
{% for step in entity_workflow.stepsChained %}
{% set place_labels = workflow_metadata(entity_workflow, 'label', step.currentStep) %}
{% set place_labels = workflow_metadata(entity_workflow, 'label', step.currentStep, entity_workflow.workflowName) %}
{% set place_label = place_labels is null ? step.currentStep : place_labels|localize_translatable_string %}
<div class="item-bloc {{ 'bloc' ~ step.id }} {% if loop.first %}initial{% endif %}">
@@ -13,38 +13,40 @@
{{ '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 %}
</div>
{% if step.next is not null %}
{% set transition = chill_workflow_transition_by_string(step.entityWorkflow, step.transitionAfter) %}
{% set transition_labels = workflow_metadata(step.entityWorkflow, 'label', transition) %}
{% set transition_labels = workflow_metadata(step.entityWorkflow, 'label', transition, step.entityWorkflow.workflowName) %}
{% set transition_label = transition_labels is null ? step.transitionAfter : transition_labels|localize_translatable_string %}
{% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition) %}
{% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition, step.entityWorkflow.workflowName) %}
<div class="item-row separator">
<div class="item-col" style="width: inherit;">
{% if step.transitionBy is not null %}
<div>
{{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }}
{%- if step.transitionBy is not null -%}
<span class="badge-user">{{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }}</span>
{% else %}
<span class="chill-no-data-statement">{{ 'workflow.Automated transition'|trans }}</span>
{%- endif -%}
</div>
{% endif %}
<div>
<span>{{ step.transitionAt|format_datetime('long', 'medium') }}</span>
</div>
@@ -69,13 +71,31 @@
</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.signatures_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>
<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>
@@ -85,7 +105,7 @@
<p><b>{{ 'workflow.Users put in Cc'|trans }}&nbsp;: </b></p>
<ul>
{% for u in step.ccUser %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</li>
<li><span class="badge-user">{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</span></li>
{% endfor %}
</ul>
{% endif %}
@@ -103,12 +123,23 @@
<p><b>{{ 'workflow.Those users are also granted to apply a transition by using an access key'|trans }}&nbsp;:</b></p>
<ul>
{% for u in step.destUserByAccessKey %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</li>
<li><span class="badge-user">{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</span></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% if step.signatures|length > 0 %}
<p><b>{{ 'workflow.signatures_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 %}
{% if step.sends|length > 0 %}
<div>
<p><b>{{ 'workflow.sent_through_secured_link'|trans }}</b></p>
{{ include('@ChillMain/Workflow/_send_views_list.html.twig', {'sends': step.sends}) }}
</div>
{% endif %}
</div>

View File

@@ -1,11 +1,15 @@
<h2>
{{ 'workflow_'|trans }}
</h2>
{% if handler is not null %}
<h2>
{{ 'workflow_'|trans }}
</h2>
<div class="item-row col" style="display: block;">
{% include handler.template(entity_workflow) with handler.templateData(entity_workflow)|merge({
'description': true,
'breadcrumb': true,
'add_classes': ''
}) %}
</div>
<div class="item-row col" style="display: block;">
{% include handler.template(entity_workflow) with handler.templateData(entity_workflow)|merge({
'description': true,
'breadcrumb': true,
'add_classes': ''
}) %}
</div>
{% else %}
<h2>{{ 'workflow.deleted_title'|trans }}</h2>
{% endif %}

View File

@@ -0,0 +1,35 @@
<div class="container">
{% for send in sends %}
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-5">
{% if send.destineeKind == 'thirdParty' %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
action: 'show', displayBadge: true,
targetEntity: { name: 'thirdParty', id: send.destineeThirdParty.id },
buttonText: send.destineeThirdParty|chill_entity_render_string,
} %}
{% else %}
<a href="mailto:{{ send.destineeEmail }}">{{ send.destineeEmail }}</a>
{% endif %}
{% if not send.expired %}
<p><small>{{ 'workflow.send_external_message.document_available_until'|trans({'expiration': send.expireAt}) }}</small></p>
{% endif %}
</div>
<div class="col-sm-12 col-md-7 text-end">
<p><small>{{ 'workflow.external_views.number_of_views'|trans({'numberOfViews': send.views|length}) }}</small></p>
{% if send.views|length > 0 %}
<p><small>{{ 'workflow.external_views.last_view_at'|trans({'at': send.lastView.viewAt }) }}</small></p>
<p>
<small>
<b>{{ 'workflow.public_views_by_ip'|trans }}&nbsp;:</b>
{%- for ip, vs in send.viewsByIp -%}
<span title="{% for v in vs %}{{ v.viewAt|format_datetime('short', 'short')|escape('html_attr') }}{% if not loop.last %}{{ ', '|escape('html_attr') }}{% endif %}{% endfor %}">{{ ip }} ({{ vs|length }}x)</span>
{%- endfor -%}
</small>
</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,3 @@
<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

@@ -1,12 +1,11 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title %}
{% block title -%}
{{ 'Workflow'|trans }}
{% endblock %}
{%- endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{{ encore_entry_script_tags('mod_entity_workflow_subscribe') }}
{{ encore_entry_script_tags('page_workflow_show') }}
@@ -39,6 +38,9 @@
<h2>{{ handler.entityTitle(entity_workflow) }}</h2>
{{ macro.breadcrumb({'entity_workflow': entity_workflow}) }}
{% if entity_workflow.isOnHoldAtCurrentStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %}
</div>
{% include handler_template with handler_template_data|merge({'display_action': true }) %}
@@ -57,18 +59,19 @@
</section>
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
<section class="step my-4">{% include '@ChillMain/Workflow/_decision.html.twig' %}</section>{#
<section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
{% if signatures|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% elseif entity_workflow.currentStep.sends|length > 0 %}
<section class="step my-4">
<h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2>
{% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %}
</section>
{% else %}
<section class="step my-4">{% include '@ChillMain/Workflow/_decision.html.twig' %}</section>
{% endif %}
{# <section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
<section class="step my-4">{% include '@ChillMain/Workflow/_history.html.twig' %}</section>
{# useful ?
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a class="btn btn-cancel" href="{{ path('chill_main_workflow_list_dest') }}">
{{ 'Back to the list'|trans }}
</a>
</li>
</ul>
#}
</div>
{% endblock %}

View File

@@ -69,6 +69,9 @@
</button>
<div>
{{ macro.breadcrumb(l) }}
{% if l.entity_workflow.isOnHoldAtCurrentStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %}
</div>
</div>

View File

@@ -3,24 +3,30 @@
{% if step.previous is not null %}
<li>
<span class="item-key">{{ 'By'|trans ~ ' : ' }}</span>
<b>{{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }}</b>
<b>{% if step.previous.transitionBy is not null %}{{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }}{% else %}<span class="chill-no-data-statement">{{ 'workflow.Automated transition'|trans }}</span>{% endif %}</b>
</li>
<li>
<span class="item-key">{{ 'Le'|trans ~ ' : ' }}</span>
<b>{{ step.previous.transitionAt|format_datetime('short', 'short') }}</b>
</li>
<li>
<span class="item-key">{{ 'workflow.For'|trans ~ ' : ' }}</span>
<b>
{% for d in step.destUser %}{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b>
</li>
<li>
<span class="item-key">{{ 'workflow.Cc'|trans ~ ' : ' }}</span>
<b>
{% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b>
</li>
{% if step.destUser|length > 0 or step.destUserGroups|length > 0 %}
<li>
<span class="item-key">{{ 'workflow.For'|trans ~ ' : ' }}</span>
<b>
{% for d in step.destUser %}<span class="badge-user">{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}</span>{% if not loop.last %}, {% endif -%}{% endfor -%}
{%- if step.destUser|length > 0 and step.destUserGroups|length > 0 %}, {% endif -%}
{%- for d in step.destUserGroups %}{{ d|chill_entity_render_box }}{% if not loop.last %}, {% endif %}{% endfor -%}
</b>
</li>
{% endif %}
{% if step.ccUser|length > 0 %}
<li>
<span class="item-key">{{ 'workflow.Cc'|trans ~ ' : ' }}</span>
<b>
{% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b>
</li>
{% endif %}
{% else %}
<li>
<span class="item-key">{{ 'workflow.Created by'|trans ~ ' : ' }}</span>
@@ -40,7 +46,7 @@
{% endif %}
{% if step.previous is not null %}
{% set transition = chill_workflow_transition_by_string(step.entityWorkflow, step.previous.transitionAfter) %}
{% set labels = workflow_metadata(step.entityWorkflow, 'label', transition) %}
{% set labels = workflow_metadata(step.entityWorkflow, 'label', transition, step.entityWorkflow.workflowName) %}
{% set label = labels is null ? step.previous.transitionAfter : labels|localize_translatable_string %}
{{ label }}
{% endif %}
@@ -49,7 +55,7 @@
{% macro breadcrumb(_ctx) %}
<div class="breadcrumb">
{% for step in _ctx.entity_workflow.stepsChained %}
{% set labels = workflow_metadata(_ctx.entity_workflow, 'label', step.currentStep) %}
{% set labels = workflow_metadata(_ctx.entity_workflow, 'label', step.currentStep, _ctx.entity_workflow.workflowName) %}
{% set label = labels is null ? step.currentStep : labels|localize_translatable_string %}
{% set popTitle = _self.popoverTitle(step) %}
{% set popContent = _self.popoverContent(step) %}

View File

@@ -0,0 +1,22 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title 'workflow.signature_zone.metadata.sign_by'|trans({ '%name%' : person|chill_entity_render_string}) %}
{% block content %}
<div class="col-10 workflow">
<h1 class="mb-5">{{ block('title') }}</h1>
{% if metadata_form is not null %}
{{ form_start(metadata_form) }}
{{ form_row(metadata_form.documentType) }}
{{ form_row(metadata_form.documentNumber) }}
{{ form_row(metadata_form.expirationDate) }}
<ul class="record_actions">
<li>
{{ form_widget(metadata_form.submit, { 'attr' : { 'class' : 'btn btn-submit' }} ) }}
</li>
</ul>
{{ form_end(metadata_form) }}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon">
<title>Signature</title>
{{ encore_entry_link_tags('mod_bootstrap') }}
{{ encore_entry_link_tags('mod_forkawesome') }}
{{ encore_entry_link_tags('chill') }}
{{ encore_entry_link_tags('vue_document_signature') }}
</head>
<body>
{% block js %}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
<script type="text/javascript">
window.signature = {{ signature|json_encode|raw }};
</script>
{{ encore_entry_script_tags('vue_document_signature') }}
{% endblock %}
<div class="content" id="content">
<div class="row">
<div class="col-12 m-auto">
<div class="row" id="document-signature"></div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -8,6 +8,6 @@ Vous êtes invités à valider cette étape au plus tôt.
Vous pouvez visualiser le workflow sur cette page:
{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id})) }}
{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': 'fr'})) }}
Cordialement,

View File

@@ -0,0 +1,9 @@
Chers membres du groupe {{ user_group.label|localize_translatable_string }},
Un suivi "{{ workflow.text }}" a atteint une nouvelle étape: {{ place.text }}
Vous pouvez visualiser le workflow sur cette page:
{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, '_locale': 'fr', 'accessKey': entity_workflow.currentStep.accessKey})) }}
Cordialement,

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})) }}
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

@@ -0,0 +1,88 @@
<!doctype html>
<html>
<body>
{%- set previous = send.entityWorkflowStepChained.previous -%}
{%- if previous.transitionBy is not null -%}
{%- set sender = previous.transitionBy|chill_entity_render_string -%}
{%- else -%}
{%- set sender = 'workflow.send_external_message.sender_system_user'|trans -%}
{%- endif -%}
<script type="application/ld+json">
{%- set data = {
"@context": "http://schema.org",
"@type": "EmailMessage",
"potentialAction": {
"@type": "ViewAction",
"url": absolute_url(path('chill_main_workflow_send_view_public', {'uuid': send.uuid, 'verificationKey': send.privateToken})),
"name": 'workflow.send_external_message.see_docs_action_name'|trans
},
"description": 'workflow.send_external_message.see_doc_action_description'|trans({'sender': sender}),
} -%}
{{- data|json_encode|raw -}}
</script>
<div
style='background-color:#F5F5F5;color:#262626;font-family:"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif;font-size:16px;font-weight:400;letter-spacing:0.15008px;line-height:1.5;margin:0;padding:32px 0;min-height:100%;width:100%'
>
<table
align="center"
width="100%"
style="margin:0 auto;max-width:600px;background-color:#FFFFFF"
role="presentation"
cellspacing="0"
cellpadding="0"
border="0"
>
<tbody>
<tr style="width:100%">
<td>
<div style="font-weight:normal;padding:16px 24px 16px 24px">
{{ 'workflow.send_external_message.greeting'|trans }},
</div>
<div style="font-weight:normal;padding:16px 24px 16px 24px">
{{ 'workflow.send_external_message.explanation'|trans({'sender': sender}) }}
</div>
<div style="font-weight:normal;padding:16px 24px 16px 24px">
{{ 'workflow.send_external_message.confidentiality'|trans }}
</div>
<div style="text-align:center;padding:16px 24px 16px 24px">
<a
href="{{ absolute_url(path('chill_main_workflow_send_view_public', {'uuid': send.uuid, 'verificationKey': send.privateToken})) }}"
style="color:#FFFFFF;font-size:16px;font-weight:bold;background-color:#334d5c;border-radius:4px;display:inline-block;padding:12px 20px;text-decoration:none"
target="_blank"
><span
><!--[if mso
]><i
style="letter-spacing: 20px;mso-font-width:-100%;mso-text-raise:30"
hidden
>&nbsp;</i
><!
[endif]--></span
><span>
{{ 'workflow.send_external_message.button_content'|trans({'sender': sender}) }}
</span><span
><!--[if mso
]><i
style="letter-spacing: 20px;mso-font-width:-100%"
hidden
>&nbsp;</i
><!
[endif]--></span
></a
>
</div>
<div style="font-weight:normal;padding:16px 24px 16px 24px">
{{ 'workflow.send_external_message.document_available_until'|trans({ 'expiration': send.expireAt}, null, lang) }}
</div>
<div style="font-weight:normal;padding:16px 24px 16px 24px">
{{ 'workflow.send_external_message.or_see_link'|trans }}&nbsp;:
</div>
<div style="font-size:16px;padding:16px 24px 16px 24px">
<code>{{ absolute_url(path('chill_main_workflow_send_view_public', {'uuid': send.uuid, 'verificationKey': send.privateToken})) }}</code>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ 'workflow.public_link.expired_link_title'|trans }}</title>
</head>
<body>
<h1>{{ 'workflow.public_link.expired_link_title'|trans }}</h1>
<p>{{ 'workflow.public_link.expired_link_explanation'|trans }}</p>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" >
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0" >
<title>{% block title %}{% endblock %}</title>
{% block head_custom %}{% endblock %}
<link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon">
{{ encore_entry_link_tags('mod_bootstrap') }}
{{ encore_entry_link_tags('mod_forkawesome') }}
{{ encore_entry_link_tags('chill') }}
{% block css %}{% endblock %}
</head>
<body>
<header>
<nav class="navbar navbar-dark bg-primary navbar-expand-md">
<div class="container-xxl">
<div class="col-4">
<a class="navbar-brand" href="{{ path('chill_main_homepage') }}">
{{ include('@ChillMain/Layout/_header-logo.html.twig') }}
</a>
</div>
<div class="col-8"></div>
</div>
</nav>
</header>
<div class="content" id="content">
<div class="container-xxl">
<div class="row justify-content-center my-5">
<div>
{% block public_content %}{% endblock %}
</div>
<div style="margin-top: 1rem;">
<p>
{{ 'workflow.public_link.shared_explanation_until_remaining'|trans({'expireAt': send.expireAt, 'viewsCount': metadata.viewsCount, 'viewsRemaining': metadata.viewsRemaining}) }}
</p>
</div>
</div>
</div>
</div>
{{ include('@ChillMain/Layout/_footer.html.twig', {'public_page': true}) }}
{{ encore_entry_script_tags('mod_bootstrap') }}
{{ encore_entry_script_tags('mod_forkawesome') }}
{{ encore_entry_script_tags('chill') }}
{% block js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title %}{{ 'workflow.signature.cancel_signature_of'|trans({ '%signer%': signature.signer|chill_entity_render_string }) }}{% endblock %}
{% block content %}
<h1>{{ block('title') }}</h1>
<p>{{ 'workflow.signature.cancel_are_you_sure'|trans({'%signer%': signature.signer|chill_entity_render_string}) }}</p>
{{ form_start(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_main_workflow_show', {'id': signature.step.entityWorkflow.id}) }}">{{ 'Cancel'|trans }}</a>
</li>
<li>
{{ form_widget(form.confirm, {'attr': {'class': 'btn btn-misc'}}) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title %}{{ 'workflow.signature.reject_signature_of'|trans({ '%signer%': signature.signer|chill_entity_render_string }) }}{% endblock %}
{% block content %}
<h1>{{ block('title') }}</h1>
<p>{{ 'workflow.signature.reject_are_you_sure'|trans({'%signer%': signature.signer|chill_entity_render_string}) }}</p>
{{ form_start(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_main_workflow_show', {'id': signature.step.entityWorkflow.id}) }}">{{ 'Cancel'|trans }}</a>
</li>
<li>
{{ form_widget(form.confirm, {'attr': {'class': 'btn btn-misc'}}) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

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

@@ -0,0 +1,54 @@
<?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\Routing;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final readonly class ChillUrlGenerator implements ChillUrlGeneratorInterface
{
public function __construct(private UrlGeneratorInterface $urlGenerator, private RequestStack $requestStack) {}
public function generate(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return $this->urlGenerator->generate($name, $parameters, $referenceType);
}
public function generateWithReturnPath(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
$uri = $this->requestStack->getCurrentRequest()->getRequestUri();
return $this->urlGenerator->generate($name, [...$parameters, 'returnPath' => $uri], $referenceType);
}
public function returnPathOr(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
$request = $this->requestStack->getCurrentRequest();
if ($request->query->has('returnPath')) {
return $request->query->get('returnPath');
}
return $this->urlGenerator->generate($name, $parameters, $referenceType);
}
public function forwardReturnPath(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
$request = $this->requestStack->getCurrentRequest();
if ($request->query->has('returnPath')) {
return $this->urlGenerator->generate($name, [...$parameters, 'returnPath' => $request->query->get('returnPath')], $referenceType);
}
return $this->urlGenerator->generate($name, $parameters, $referenceType);
}
}

View File

@@ -0,0 +1,40 @@
<?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\Routing;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Interface for generating URLs with returnPath information.
*/
interface ChillUrlGeneratorInterface
{
/**
* Generate an URL without any return path. This is the same as using @see{UrlGeneratorInterface::generate}.
*/
public function generate(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string;
/**
* Generate an url, append a return path in it.
*/
public function generateWithReturnPath(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string;
/**
* Get the return path or, if any, generate an url.
*/
public function returnPathOr(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string;
/**
* Return a new URL, with the same return path as the existing one. If any, no return path is forwarded.
*/
public function forwardReturnPath(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\Workflow\EntityWorkflowStepSignature;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class EntityWorkflowStepSignatureVoter extends Voter
{
public const SIGN = 'CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN';
public const CANCEL = 'CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL';
public const REJECT = 'CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_REJECT';
protected function supports(string $attribute, $subject)
{
return $subject instanceof EntityWorkflowStepSignature
&& in_array($attribute, [self::SIGN, self::CANCEL, self::REJECT], true);
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token)
{
/** @var EntityWorkflowStepSignature $subject */
if ($subject->getSigner() instanceof Person) {
return true;
}
if ($subject->getSigner() === $token->getUser()) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter class that determines if a user has permission to apply all transitions
* in a workflow based on their roles and the centers they have access to.
*/
final class EntityWorkflowTransitionVoter extends Voter implements ProvideRoleHierarchyInterface
{
final public const APPLY_ALL_TRANSITIONS = 'CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION';
public function __construct(
private readonly EntityWorkflowManager $workflowManager,
private readonly AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private readonly CenterResolverManagerInterface $centerResolverManager,
private readonly AccessDecisionManagerInterface $accessDecisionManager,
) {}
public function getRoles(): array
{
return [self::APPLY_ALL_TRANSITIONS];
}
public function getRolesWithoutScope(): array
{
return [self::APPLY_ALL_TRANSITIONS];
}
public function getRolesWithHierarchy(): array
{
return [
'workflow.Permissions' => [
self::APPLY_ALL_TRANSITIONS,
],
];
}
protected function supports(string $attribute, $subject): bool
{
return self::APPLY_ALL_TRANSITIONS === $attribute && ($subject instanceof EntityWorkflowStep || $subject instanceof EntityWorkflow);
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var EntityWorkflowStep|EntityWorkflow $subject */
$entityWorkflow = $subject instanceof EntityWorkflowStep ? $subject->getEntityWorkflow() : $subject;
if (!$this->accessDecisionManager->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)) {
return false;
}
$handler = $this->workflowManager->getHandler($entityWorkflow);
$entity = $handler->getRelatedEntity($entityWorkflow);
if (null === $entity) {
return false;
}
$centers = $this->centerResolverManager->resolveCenters($entity);
$reachableCenters = $this->authorizationHelper->getReachableCenters(self::APPLY_ALL_TRANSITIONS);
foreach ($centers as $center) {
if (in_array($center, $reachableCenters, true)) {
return true;
}
}
return false;
}
}

View File

@@ -13,9 +13,11 @@ namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Helper\DuplicateEntityWorkflowFinder;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Registry;
class EntityWorkflowVoter extends Voter
{
@@ -27,7 +29,12 @@ class EntityWorkflowVoter extends Voter
final public const SHOW_ENTITY_LINK = 'CHILL_MAIN_WORKFLOW_LINK_SHOW';
public function __construct(private readonly EntityWorkflowManager $manager, private readonly Security $security) {}
public function __construct(
private readonly EntityWorkflowManager $manager,
private readonly Security $security,
private readonly DuplicateEntityWorkflowFinder $duplicateEntityWorkflowFinder,
private readonly Registry $registry,
) {}
protected function supports($attribute, $subject)
{
@@ -41,6 +48,15 @@ class EntityWorkflowVoter extends Voter
{
switch ($attribute) {
case self::CREATE:
if (false === $this->voteOnAttribute(self::SEE, $subject, $token)) {
return false;
}
if ($this->duplicateEntityWorkflowFinder->hasDuplicateOpenedOrFinalPositiveEntityWorkflow($subject)) {
return false;
}
return true;
case self::SEE:
$handler = $this->manager->getHandler($subject);
@@ -69,7 +85,25 @@ class EntityWorkflowVoter extends Voter
return false;
case self::DELETE:
return 'initial' === $subject->getStep();
if ('initial' === $subject->getStep()) {
return true;
}
if (!$subject->isFinal()) {
return false;
}
// the entity workflow is finalized. We check if the final is not positive. If yes, we can
// delete the entity
$workflow = $this->registry->get($subject, $subject->getWorkflowName());
foreach ($workflow->getMarking($subject)->getPlaces() as $place => $key) {
$metadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (false === ($metadata['isFinalPositive'] ?? true)) {
return true;
}
}
return false;
case self::SHOW_ENTITY_LINK:
if ('initial' === $subject->getStep()) {

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

@@ -12,13 +12,14 @@ declare(strict_types=1);
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class WorkflowEntityDeletionVoter extends Voter
{
/**
* @param \Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface[] $handlers
* @param EntityWorkflowHandlerInterface[] $handlers
*/
public function __construct(private $handlers, private readonly EntityWorkflowRepository $entityWorkflowRepository) {}
@@ -30,7 +31,7 @@ class WorkflowEntityDeletionVoter extends Voter
foreach ($this->handlers as $handler) {
if ($handler->isObjectSupported($subject)
&& \in_array($attribute, $handler->getDeletionRoles($subject), true)) {
&& \in_array($attribute, $handler->getDeletionRoles(), true)) {
return true;
}
}

View File

@@ -18,7 +18,14 @@ final readonly class CenterResolverManager implements CenterResolverManagerInter
/**
* @param \Chill\MainBundle\Security\Resolver\CenterResolverInterface[] $resolvers
*/
public function __construct(private iterable $resolvers = []) {}
public function __construct(private iterable $resolvers = [])
{
foreach ($resolvers as $resolver) {
if ($resolver instanceof ManagerAwareCenterResolverInterface) {
$resolver->setResolverManager($this);
}
}
}
public function resolveCenters($entity, ?array $options = []): array
{

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Security\Resolver;
/**
* Center resolver which need the CenterResolverManager.
*
* Appended to @see{CenterResolverInterface} which needs the @{CenterResolverManager} to resolve the center
*/
interface ManagerAwareCenterResolverInterface
{
public function setResolverManager(CenterResolverManagerInterface $manager): void;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Security\Resolver;
trait ManagerAwareCenterResolverTrait
{
protected CenterResolverManagerInterface $centerResolverManager;
public function setResolverManager(CenterResolverManagerInterface $manager): void
{
$this->centerResolverManager = $manager;
}
}

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

@@ -45,6 +45,7 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn
'steps' => $this->normalizer->normalize($object->getStepsChained(), $format, $context),
'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context),
'title' => $handler->getEntityTitle($object),
'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(),
];
}

Some files were not shown because too many files have changed in this diff Show More