mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2026-01-15 05:41:25 +00:00
Merge branch 'master' into 385-invitation-list
This commit is contained in:
@@ -334,7 +334,7 @@ class ChillImportUsersCommand extends Command
|
||||
|
||||
protected function loadUsers()
|
||||
{
|
||||
$reader = Reader::createFromPath($this->tempInput->getArgument('csvfile'));
|
||||
$reader = Reader::from($this->tempInput->getArgument('csvfile'));
|
||||
$reader->setHeaderOffset(0);
|
||||
|
||||
foreach ($reader->getRecords() as $line => $r) {
|
||||
@@ -362,7 +362,7 @@ class ChillImportUsersCommand extends Command
|
||||
|
||||
protected function prepareGroupingCenters()
|
||||
{
|
||||
$reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers'));
|
||||
$reader = Reader::from($this->tempInput->getOption('grouping-centers'));
|
||||
$reader->setHeaderOffset(0);
|
||||
|
||||
foreach ($reader->getRecords() as $r) {
|
||||
@@ -378,7 +378,7 @@ class ChillImportUsersCommand extends Command
|
||||
|
||||
protected function prepareWriter()
|
||||
{
|
||||
$this->output = $output = Writer::createFromPath($this->tempInput
|
||||
$this->output = $output = Writer::from($this->tempInput
|
||||
->getOption('csv-dump'), 'a+');
|
||||
|
||||
$output->insertOne([
|
||||
|
||||
@@ -119,7 +119,7 @@ class ChillUserSendRenewPasswordCodeCommand extends Command
|
||||
protected function getReader()
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromPath($this->input->getArgument('csvfile'));
|
||||
$reader = Reader::from($this->input->getArgument('csvfile'));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('The csv file could not be read', [
|
||||
'path' => $this->input->getArgument('csvfile'),
|
||||
|
||||
@@ -43,7 +43,7 @@ final readonly class UserExportController
|
||||
|
||||
$users = $this->userRepository->findAllAsArray($request->getLocale());
|
||||
|
||||
$csv = Writer::createFromPath('php://temp', 'r+');
|
||||
$csv = Writer::from('php://temp', 'r+');
|
||||
$csv->insertOne(
|
||||
array_map(
|
||||
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
||||
@@ -104,7 +104,7 @@ final readonly class UserExportController
|
||||
|
||||
$userPermissions = $this->userRepository->findAllUserACLAsArray();
|
||||
|
||||
$csv = Writer::createFromPath('php://temp', 'r+');
|
||||
$csv = Writer::from('php://temp', 'r+');
|
||||
$csv->insertOne(
|
||||
array_map(
|
||||
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
||||
|
||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\CRUD\Controller\ApiController;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||
@@ -27,7 +28,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class WorkflowApiController
|
||||
class WorkflowApiController extends ApiController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {}
|
||||
|
||||
|
||||
@@ -264,11 +264,12 @@ class WorkflowController extends AbstractController
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser());
|
||||
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false);
|
||||
$paginator = $this->paginatorFactory->create($total);
|
||||
|
||||
$workflows = $this->entityWorkflowRepository->findBySubscriber(
|
||||
$this->security->getUser(),
|
||||
false,
|
||||
['createdAt' => 'DESC'],
|
||||
$paginator->getItemsPerPage(),
|
||||
$paginator->getCurrentPageFirstItemNumber()
|
||||
|
||||
@@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController
|
||||
$signature,
|
||||
$request,
|
||||
EntityWorkflowStepSignatureVoter::CANCEL,
|
||||
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); },
|
||||
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature),
|
||||
'@ChillMain/WorkflowSignature/cancel.html.twig',
|
||||
);
|
||||
}
|
||||
@@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController
|
||||
$signature,
|
||||
$request,
|
||||
EntityWorkflowStepSignatureVoter::REJECT,
|
||||
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); },
|
||||
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature),
|
||||
'@ChillMain/WorkflowSignature/reject.html.twig',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(EntityWorkflowStepSignature): string $markSignature
|
||||
*
|
||||
* @throws \Twig\Error\LoaderError
|
||||
* @throws \Twig\Error\RuntimeError
|
||||
* @throws \Twig\Error\SyntaxError
|
||||
*/
|
||||
private function markSignatureAction(
|
||||
EntityWorkflowStepSignature $signature,
|
||||
Request $request,
|
||||
@@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) {
|
||||
$markSignature($signature);
|
||||
});
|
||||
$expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature));
|
||||
|
||||
return new RedirectResponse(
|
||||
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()])
|
||||
$this->chillUrlGenerator->forwardReturnPath(
|
||||
'chill_main_workflow_wait',
|
||||
['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
final readonly class WorkflowWaitStepChangeController
|
||||
{
|
||||
public function __construct(
|
||||
private ChillUrlGeneratorInterface $chillUrlGenerator,
|
||||
private Environment $twig,
|
||||
) {}
|
||||
|
||||
#[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])]
|
||||
public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response
|
||||
{
|
||||
if ($entityWorkflow->getStep() === $expectedStep) {
|
||||
return new RedirectResponse(
|
||||
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
$this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController;
|
||||
use Chill\MainBundle\Controller\UserGroupApiController;
|
||||
use Chill\MainBundle\Controller\UserJobApiController;
|
||||
use Chill\MainBundle\Controller\UserJobController;
|
||||
use Chill\MainBundle\Controller\WorkflowApiController;
|
||||
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
|
||||
use Chill\MainBundle\Doctrine\DQL\Age;
|
||||
use Chill\MainBundle\Doctrine\DQL\Extract;
|
||||
@@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\UserGroup;
|
||||
use Chill\MainBundle\Entity\UserJob;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Form\CenterType;
|
||||
use Chill\MainBundle\Form\CivilityType;
|
||||
use Chill\MainBundle\Form\CountryType;
|
||||
@@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType;
|
||||
use Chill\MainBundle\Form\UserJobType;
|
||||
use Chill\MainBundle\Form\UserType;
|
||||
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
|
||||
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
|
||||
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
|
||||
use Ramsey\Uuid\Doctrine\UuidType;
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
@@ -202,6 +205,11 @@ class ChillMainExtension extends Extension implements
|
||||
[]
|
||||
);
|
||||
|
||||
$container->setParameter(
|
||||
'chill_main.top_banner',
|
||||
$config['top_banner'] ?? []
|
||||
);
|
||||
|
||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
||||
$loader->load('services.yaml');
|
||||
$loader->load('services/doctrine.yaml');
|
||||
@@ -247,6 +255,7 @@ class ChillMainExtension extends Extension implements
|
||||
'name' => $config['installation_name'], ],
|
||||
'available_languages' => $config['available_languages'],
|
||||
'add_address' => $config['add_address'],
|
||||
'chill_main_config' => $config,
|
||||
],
|
||||
'form_themes' => ['@ChillMain/Form/fields.html.twig'],
|
||||
];
|
||||
@@ -940,6 +949,21 @@ class ChillMainExtension extends Extension implements
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'class' => EntityWorkflow::class,
|
||||
'name' => 'workflow',
|
||||
'base_path' => '/api/1.0/main/workflow',
|
||||
'base_role' => EntityWorkflowVoter::SEE,
|
||||
'controller' => WorkflowApiController::class,
|
||||
'actions' => [
|
||||
'_entity' => [
|
||||
'methods' => [
|
||||
Request::METHOD_GET => true,
|
||||
Request::METHOD_HEAD => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->arrayNode('top_banner')
|
||||
->canBeUnset()
|
||||
->children()
|
||||
->booleanNode('visible')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->arrayNode('text')
|
||||
->useAttributeAsKey('lang')
|
||||
->scalarPrototype()->end()
|
||||
->end() // end of text
|
||||
->scalarNode('color')->defaultNull()->end()
|
||||
->scalarNode('background_color')->defaultNull()->end()
|
||||
->end() // end of top_banner children
|
||||
->end() // end of top_banner
|
||||
->arrayNode('widgets')
|
||||
->canBeEnabled()
|
||||
->canBeUnset()
|
||||
|
||||
@@ -123,7 +123,7 @@ class EntityWorkflowStep
|
||||
/**
|
||||
* @var Collection<int, EntityWorkflowStepHold>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)]
|
||||
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class, cascade: ['remove'])]
|
||||
private Collection $holdsOnStep;
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
|
||||
if (isset($lastExecutionData['last_execution'])) {
|
||||
$lastExecution = \DateTimeImmutable::createFromFormat(
|
||||
\DateTimeImmutable::ATOM,
|
||||
$lastExecutionData['last_execution']
|
||||
);
|
||||
|
||||
if (false === $lastExecution) {
|
||||
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||
}
|
||||
} else {
|
||||
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||
}
|
||||
@@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
||||
]);
|
||||
|
||||
return [
|
||||
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
|
||||
'last_execution' => $now->format(\DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function countBySubscriber(User $user): int
|
||||
/**
|
||||
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||
*
|
||||
* @throws \Doctrine\ORM\NoResultException
|
||||
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||
*/
|
||||
public function countBySubscriber(User $user, ?bool $isFinal = null): int
|
||||
{
|
||||
$qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
|
||||
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
@@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
|
||||
/**
|
||||
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||
* @param mixed|null $limit
|
||||
* @param mixed|null $offset
|
||||
*/
|
||||
public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array
|
||||
{
|
||||
$qb = $this->buildQueryBySubscriber($user)->select('ew');
|
||||
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew');
|
||||
|
||||
foreach ($orderBy as $key => $sort) {
|
||||
$qb->addOrderBy('ew.'.$key, $sort);
|
||||
@@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function buildQueryBySubscriber(User $user): QueryBuilder
|
||||
private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('ew');
|
||||
|
||||
@@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
|
||||
$qb->setParameter('user', $user);
|
||||
|
||||
if (null !== $isFinal) {
|
||||
if ($isFinal) {
|
||||
$qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||
} else {
|
||||
$qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||
}
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Extracts the "returnPath" parameter from the current URL's query string and returns it.
|
||||
* If the parameter is not present, returns the provided fallback path.
|
||||
*
|
||||
* @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string.
|
||||
* @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present.
|
||||
*/
|
||||
export function returnPathOr(fallbackPath: string): string {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const returnPath = urlParams.get("returnPath");
|
||||
|
||||
return returnPath ?? fallbackPath;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { EntityWorkflow } from "ChillMainAssets/types";
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
export const fetchWorkflow = async (
|
||||
workflowId: number,
|
||||
): Promise<EntityWorkflow> => {
|
||||
try {
|
||||
return await makeFetch<null, EntityWorkflow>(
|
||||
"GET",
|
||||
`/api/1.0/main/workflow/${workflowId}.json`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch workflow ${workflowId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import {
|
||||
GenericDoc,
|
||||
isGenericDocWithStoredObject,
|
||||
} from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
|
||||
|
||||
export interface DateTime {
|
||||
datetime: string;
|
||||
@@ -202,6 +206,77 @@ export interface WorkflowAttachment {
|
||||
genericDoc: null | GenericDoc;
|
||||
}
|
||||
|
||||
export type AttachmentWithDocAndStored = WorkflowAttachment & {
|
||||
genericDoc: GenericDoc & { storedObject: StoredObject };
|
||||
};
|
||||
|
||||
export function isAttachmentWithDocAndStored(
|
||||
a: WorkflowAttachment,
|
||||
): a is AttachmentWithDocAndStored {
|
||||
return (
|
||||
isWorkflowAttachmentWithGenericDoc(a) &&
|
||||
isGenericDocWithStoredObject(a.genericDoc)
|
||||
);
|
||||
}
|
||||
|
||||
export function isWorkflowAttachmentWithGenericDoc(
|
||||
attachment: WorkflowAttachment,
|
||||
): attachment is WorkflowAttachment & { genericDoc: GenericDoc } {
|
||||
return attachment.genericDoc !== null;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface EntityWorkflowStep {
|
||||
type: "entity_workflow_step";
|
||||
id: number;
|
||||
comment: string;
|
||||
currentStep: StepDefinition;
|
||||
isFinal: boolean;
|
||||
isFreezed: boolean;
|
||||
isFinalized: boolean;
|
||||
transitionPrevious: Transition | null;
|
||||
transitionAfter: Transition | null;
|
||||
previousId: number | null;
|
||||
nextId: number | null;
|
||||
transitionPreviousBy: User | null;
|
||||
transitionPreviousAt: DateTime | null;
|
||||
}
|
||||
|
||||
export interface Transition {
|
||||
name: string;
|
||||
text: string;
|
||||
isForward: boolean;
|
||||
}
|
||||
|
||||
export interface StepDefinition {
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface EntityWorkflow {
|
||||
type: "entity_workflow";
|
||||
id: number;
|
||||
relatedEntityClass: string;
|
||||
relatedEntityId: number;
|
||||
workflow: Workflow;
|
||||
currentStep: EntityWorkflowStep;
|
||||
steps: EntityWorkflowStep[];
|
||||
datas: WorkflowData;
|
||||
title: string;
|
||||
isOnHoldAtCurrentStep: boolean;
|
||||
_permissions: {
|
||||
CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowData {
|
||||
persons: Person[];
|
||||
}
|
||||
|
||||
export interface ExportGeneration {
|
||||
id: string;
|
||||
type: "export_generation";
|
||||
@@ -215,3 +290,8 @@ export interface ExportGeneration {
|
||||
export interface PrivateCommentEmbeddable {
|
||||
comments: Record<number, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Possible states for the WaitingScreen Component.
|
||||
*/
|
||||
export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready";
|
||||
|
||||
@@ -10,7 +10,8 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
import { ExportGeneration } from "ChillMainAssets/types";
|
||||
import WaitingScreen from "../_components/WaitingScreen.vue";
|
||||
import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types";
|
||||
|
||||
interface AppProps {
|
||||
exportGenerationId: string;
|
||||
@@ -34,13 +35,16 @@ const storedObject = computed<null | StoredObject>(() => {
|
||||
});
|
||||
|
||||
const isPending = computed<boolean>(() => status.value === "pending");
|
||||
const isFetching = computed<boolean>(
|
||||
() => tryiesForReady.value < maxTryiesForReady,
|
||||
);
|
||||
const isReady = computed<boolean>(() => status.value === "ready");
|
||||
const isFailure = computed<boolean>(() => status.value === "failure");
|
||||
const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
|
||||
|
||||
const state = computed<WaitingScreenState>((): WaitingScreenState => {
|
||||
if (status.value === "empty") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
return status.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* counter for the number of times that we check for a new status
|
||||
*/
|
||||
@@ -85,57 +89,36 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="waiting-screen">
|
||||
<div
|
||||
v-if="isPending && isFetching"
|
||||
class="alert alert-danger text-center"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
|
||||
</p>
|
||||
</div>
|
||||
<WaitingScreen :state="state">
|
||||
<template v-slot:pending>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPending && !isFetching" class="alert alert-info">
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isFailure" class="alert alert-danger text-center">
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isReady" class="alert alert-success text-center">
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
|
||||
</p>
|
||||
<template v-slot:stopped>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="storedObject !== null">
|
||||
<document-action-buttons-group
|
||||
:stored-object="storedObject"
|
||||
:filename="filename"
|
||||
></document-action-buttons-group>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-slot:failure>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-slot:ready>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
|
||||
</p>
|
||||
|
||||
<p v-if="storedObject !== null">
|
||||
<document-action-buttons-group
|
||||
:stored-object="storedObject"
|
||||
:filename="filename"
|
||||
></document-action-buttons-group>
|
||||
</p>
|
||||
</template>
|
||||
</WaitingScreen>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#waiting-screen {
|
||||
> .alert {
|
||||
min-height: 350px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from "@vueuse/core";
|
||||
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||
import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper";
|
||||
import { ref } from "vue";
|
||||
import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue";
|
||||
import { WaitingScreenState } from "ChillMainAssets/types";
|
||||
import {
|
||||
trans,
|
||||
WORKFLOW_WAIT_TITLE,
|
||||
WORKFLOW_WAIT_ERROR_WHILE_WAITING,
|
||||
WORKFLOW_WAIT_SUCCESS,
|
||||
} from "translator";
|
||||
|
||||
interface WaitPostProcessWorkflowComponentProps {
|
||||
workflowId: number;
|
||||
expectedStep: string;
|
||||
}
|
||||
|
||||
const props = defineProps<WaitPostProcessWorkflowComponentProps>();
|
||||
const counter = ref<number>(0);
|
||||
const MAX_TRYIES = 50;
|
||||
|
||||
const state = ref<WaitingScreenState>("pending");
|
||||
|
||||
const { pause, resume } = useIntervalFn(
|
||||
async () => {
|
||||
try {
|
||||
const workflow = await fetchWorkflow(props.workflowId);
|
||||
counter.value++;
|
||||
if (workflow.currentStep.currentStep.name === props.expectedStep) {
|
||||
window.location.assign(
|
||||
returnPathOr("/fr/main/workflow" + workflow.id + "/show"),
|
||||
);
|
||||
resume();
|
||||
state.value = "ready";
|
||||
}
|
||||
|
||||
if (counter.value > MAX_TRYIES) {
|
||||
pause();
|
||||
state.value = "failure";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pause();
|
||||
}
|
||||
},
|
||||
2000,
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<WaitingScreen :state="state">
|
||||
<template v-slot:pending>
|
||||
<p>
|
||||
{{ trans(WORKFLOW_WAIT_TITLE) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-slot:failure>
|
||||
<p>
|
||||
{{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-slot:ready>
|
||||
<p>
|
||||
{{ trans(WORKFLOW_WAIT_SUCCESS) }}
|
||||
</p>
|
||||
</template>
|
||||
</WaitingScreen>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
function mountApp(): void {
|
||||
const el = document.querySelector<HTMLDivElement>(".screen-wait");
|
||||
if (!el) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: mount element .screen-wait not found",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowIdAttr = el.getAttribute("data-workflow-id");
|
||||
const expectedStep = el.getAttribute("data-expected-step") || "";
|
||||
|
||||
if (!workflowIdAttr) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expectedStep) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: data-expected-step attribute missing on mount element",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowId = Number(workflowIdAttr);
|
||||
if (Number.isNaN(workflowId)) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: data-workflow-id is not a valid number:",
|
||||
workflowIdAttr,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const app = createApp(App, {
|
||||
workflowId,
|
||||
expectedStep,
|
||||
});
|
||||
|
||||
app.mount(el);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountApp);
|
||||
} else {
|
||||
mountApp();
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from "vue";
|
||||
import type { WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import { computed, onMounted, ref, useTemplateRef } from "vue";
|
||||
import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
|
||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types";
|
||||
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||
import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator";
|
||||
|
||||
interface AppConfig {
|
||||
workflowId: number;
|
||||
@@ -34,6 +36,13 @@ const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
|
||||
) as GenericDocForAccompanyingPeriod[],
|
||||
);
|
||||
|
||||
const workflow = ref<EntityWorkflow | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
workflow.value = await fetchWorkflow(Number(props.workflowId));
|
||||
console.log("workflow", workflow.value);
|
||||
});
|
||||
|
||||
const openModal = function () {
|
||||
pickDocModal.value?.openModal();
|
||||
};
|
||||
@@ -49,23 +58,33 @@ const onPickGenericDoc = ({
|
||||
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
|
||||
emit("removeAttachment", payload);
|
||||
};
|
||||
|
||||
const canEditAttachement = computed<boolean>(() => {
|
||||
if (null === workflow.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pick-generic-doc-modal
|
||||
:workflow="workflow"
|
||||
:accompanying-period-id="props.accompanyingPeriodId"
|
||||
:to-remove="attachedGenericDoc"
|
||||
ref="pickDocModal"
|
||||
@pickGenericDoc="onPickGenericDoc"
|
||||
></pick-generic-doc-modal>
|
||||
<attachment-list
|
||||
:workflow="workflow"
|
||||
:attachments="props.attachments"
|
||||
@removeAttachment="onRemoveAttachment"
|
||||
></attachment-list>
|
||||
<ul class="record_actions">
|
||||
<ul v-if="canEditAttachement" class="record_actions">
|
||||
<li>
|
||||
<button type="button" class="btn btn-create" @click="openModal">
|
||||
Ajouter une pièce jointe
|
||||
{{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import {
|
||||
AttachmentWithDocAndStored,
|
||||
EntityWorkflow,
|
||||
isAttachmentWithDocAndStored,
|
||||
WorkflowAttachment,
|
||||
} from "ChillMainAssets/types";
|
||||
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
import { computed } from "vue";
|
||||
import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator";
|
||||
|
||||
interface AttachmentListProps {
|
||||
attachments: WorkflowAttachment[];
|
||||
workflow: EntityWorkflow | null;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -13,30 +21,43 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const props = defineProps<AttachmentListProps>();
|
||||
|
||||
const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() =>
|
||||
props.attachments.filter(
|
||||
(a: WorkflowAttachment): a is AttachmentWithDocAndStored =>
|
||||
isAttachmentWithDocAndStored(a),
|
||||
),
|
||||
);
|
||||
|
||||
const canRemove = computed<boolean>((): boolean => {
|
||||
if (null === props.workflow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
v-if="props.attachments.length === 0"
|
||||
v-if="notNullAttachments.length === 0"
|
||||
class="chill-no-data-statement text-center"
|
||||
>
|
||||
Aucune pièce jointe
|
||||
{{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }}
|
||||
</p>
|
||||
<!-- TODO translate -->
|
||||
<div else class="flex-table">
|
||||
<div v-for="a in props.attachments" :key="a.id" class="item-bloc">
|
||||
<div v-else class="flex-table">
|
||||
<div v-for="a in notNullAttachments" :key="a.id" class="item-bloc">
|
||||
<generic-doc-item-box
|
||||
v-if="a.genericDoc !== null"
|
||||
:generic-doc="a.genericDoc"
|
||||
></generic-doc-item-box>
|
||||
<div class="item-row separator">
|
||||
<ul class="record_actions">
|
||||
<li v-if="a.genericDoc?.storedObject !== null">
|
||||
<li>
|
||||
<document-action-buttons-group
|
||||
:stored-object="a.genericDoc.storedObject"
|
||||
></document-action-buttons-group>
|
||||
</li>
|
||||
<li>
|
||||
<li v-if="canRemove">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-delete"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
||||
|
||||
interface GenericDocItemBoxProps {
|
||||
genericDoc: GenericDocForAccompanyingPeriod;
|
||||
genericDoc: GenericDoc;
|
||||
}
|
||||
|
||||
const props = defineProps<GenericDocItemBoxProps>();
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue";
|
||||
import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { EntityWorkflow } from "ChillMainAssets/types";
|
||||
|
||||
interface PickGenericDocProps {
|
||||
workflow: EntityWorkflow | null;
|
||||
accompanyingPeriodId: number;
|
||||
pickedList: GenericDocForAccompanyingPeriod[];
|
||||
toRemove: GenericDocForAccompanyingPeriod[];
|
||||
@@ -36,9 +38,21 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean =>
|
||||
) !== -1;
|
||||
|
||||
onMounted(async () => {
|
||||
genericDocs.value = await fetch_generic_docs_by_accompanying_period(
|
||||
const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period(
|
||||
props.accompanyingPeriodId,
|
||||
);
|
||||
const documentClasses = [
|
||||
"Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument",
|
||||
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
|
||||
"Chill\\DocStoreBundle\\Entity\\PersonDocument",
|
||||
];
|
||||
|
||||
genericDocs.value = fetchedGenericDocs.filter(
|
||||
(doc) =>
|
||||
!documentClasses.includes(
|
||||
props.workflow?.relatedEntityClass || "",
|
||||
) || props.workflow?.relatedEntityId !== doc.identifiers.id,
|
||||
);
|
||||
loaded.value = true;
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import { computed, ref, useTemplateRef } from "vue";
|
||||
import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue";
|
||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { EntityWorkflow } from "ChillMainAssets/types";
|
||||
|
||||
interface PickGenericDocModalProps {
|
||||
workflow: EntityWorkflow | null;
|
||||
accompanyingPeriodId: number;
|
||||
toRemove: GenericDocForAccompanyingPeriod[];
|
||||
}
|
||||
@@ -80,6 +82,7 @@ defineExpose({ openModal, closeModal });
|
||||
</template>
|
||||
<template v-slot:body>
|
||||
<pick-generic-doc
|
||||
:workflow="props.workflow"
|
||||
:accompanying-period-id="props.accompanyingPeriodId"
|
||||
:to-remove="props.toRemove"
|
||||
:picked-list="pickeds"
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
<template>
|
||||
<div class="d-grid gap-2 my-3">
|
||||
<button
|
||||
class="btn btn-misc"
|
||||
class="btn btn-outline-primary text-start d-flex align-items-center"
|
||||
:class="{ active: subscriberFinal }"
|
||||
type="button"
|
||||
v-if="!subscriberFinal"
|
||||
@click="subscribeTo('subscribe', 'final')"
|
||||
@click="
|
||||
subscribeTo(
|
||||
subscriberFinal ? 'unsubscribe' : 'subscribe',
|
||||
'final',
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="fa fa-check fa-fw"></i>
|
||||
{{ trans(WORKFLOW_SUBSCRIBE_FINAL) }}
|
||||
<i
|
||||
class="fa fa-fw me-2"
|
||||
:class="subscriberFinal ? 'fa-check-square-o' : 'fa-square-o'"
|
||||
></i>
|
||||
<span>{{ trans(WORKFLOW_SUBSCRIBE_FINAL) }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-misc"
|
||||
class="btn btn-outline-primary text-start d-flex align-items-center"
|
||||
:class="{ active: subscriberStep }"
|
||||
type="button"
|
||||
v-if="subscriberFinal"
|
||||
@click="subscribeTo('unsubscribe', 'final')"
|
||||
@click="
|
||||
subscribeTo(
|
||||
subscriberStep ? 'unsubscribe' : 'subscribe',
|
||||
'step',
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="fa fa-times fa-fw"></i>
|
||||
{{ trans(WORKFLOW_UNSUBSCRIBE_FINAL) }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-misc"
|
||||
type="button"
|
||||
v-if="!subscriberStep"
|
||||
@click="subscribeTo('subscribe', 'step')"
|
||||
>
|
||||
<i class="fa fa-check fa-fw"></i>
|
||||
{{ trans(WORKFLOW_SUBSCRIBE_ALL_STEPS) }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-misc"
|
||||
type="button"
|
||||
v-if="subscriberStep"
|
||||
@click="subscribeTo('unsubscribe', 'step')"
|
||||
>
|
||||
<i class="fa fa-times fa-fw"></i>
|
||||
{{ trans(WORKFLOW_UNSUBSCRIBE_ALL_STEPS) }}
|
||||
<i
|
||||
class="fa fa-fw me-2"
|
||||
:class="subscriberStep ? 'fa-check-square-o' : 'fa-square-o'"
|
||||
></i>
|
||||
<span>{{ trans(WORKFLOW_SUBSCRIBE_ALL_STEPS) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,9 +43,7 @@ import { defineProps, defineEmits } from "vue";
|
||||
import {
|
||||
trans,
|
||||
WORKFLOW_SUBSCRIBE_FINAL,
|
||||
WORKFLOW_UNSUBSCRIBE_FINAL,
|
||||
WORKFLOW_SUBSCRIBE_ALL_STEPS,
|
||||
WORKFLOW_UNSUBSCRIBE_ALL_STEPS,
|
||||
} from "translator";
|
||||
|
||||
// props
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { WaitingScreenState } from "ChillMainAssets/types";
|
||||
|
||||
interface Props {
|
||||
state: WaitingScreenState;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="waiting-screen">
|
||||
<div
|
||||
v-if="props.state === 'pending' && !!$slots.pending"
|
||||
class="alert alert-danger text-center"
|
||||
>
|
||||
<div>
|
||||
<slot name="pending"></slot>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.state === 'stopped' && !!$slots.stopped"
|
||||
class="alert alert-info"
|
||||
>
|
||||
<div>
|
||||
<slot name="stopped"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.state === 'failure' && !!$slots.failure"
|
||||
class="alert alert-danger text-center"
|
||||
>
|
||||
<div>
|
||||
<slot name="failure"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.state === 'ready' && !!$slots.ready"
|
||||
class="alert alert-success text-center"
|
||||
>
|
||||
<div>
|
||||
<slot name="ready"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#waiting-screen {
|
||||
> .alert {
|
||||
min-height: 350px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %}
|
||||
{% set banner_text = '' %}
|
||||
{% set current_locale = app.request.locale %}
|
||||
|
||||
{% if chill_main_config.top_banner.text[current_locale] is defined %}
|
||||
{% set banner_text = chill_main_config.top_banner.text[current_locale] %}
|
||||
{% else %}
|
||||
{% set banner_text = chill_main_config.top_banner.text|first %}
|
||||
{% endif %}
|
||||
|
||||
{% if banner_text %}
|
||||
<div class="top-banner w-100 text-center py-2"
|
||||
style="{% if chill_main_config.top_banner.color is defined %}color: {{ chill_main_config.top_banner.color }};{% endif %}{% if chill_main_config.top_banner.background_color is defined %}background-color: {{ chill_main_config.top_banner.background_color }};{% endif %}">
|
||||
{{ banner_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -21,8 +21,6 @@
|
||||
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
|
||||
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
|
||||
|
||||
{{ form_row(form.addressesEmails) }}
|
||||
|
||||
{% include handler.template(notification) with handler.templateData(notification) %}
|
||||
|
||||
<div class="mb-3 row">
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
<span class="dt">cercle/centre:</span>
|
||||
<span class="dt">{{ 'Scope'|trans }}/{{ 'center'|trans }}:</span>
|
||||
{% if entity.mainScope %}
|
||||
{{ entity.mainScope.name|localize_translatable_string }}
|
||||
{% endif %}
|
||||
|
||||
@@ -58,12 +58,14 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if signatures|length > 0 %}
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section>
|
||||
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
|
||||
{% if signatures|length > 0 %}
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
|
||||
{% elseif entity_workflow.currentStep.sends|length > 0 %}
|
||||
{% if 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} %}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends '@ChillMain/layout.html.twig' %}
|
||||
|
||||
{% block title %}{{ 'workflow.signature.waiting_for'|trans }}{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{{ encore_entry_link_tags('page_workflow_waiting_post_process') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ encore_entry_script_tags('page_workflow_waiting_post_process') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ block('title') }}</h1>
|
||||
|
||||
<div class="screen-wait" data-workflow-id="{{ workflow.id|e('html_attr') }}" data-expected-step="{{ expectedStep|e('html_attr') }}"></div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -26,6 +26,10 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %}
|
||||
{{ include('@ChillMain/Layout/_top_banner.html.twig') }}
|
||||
{% endif %}
|
||||
|
||||
{% if responsive_debug is defined and responsive_debug == 1 %}
|
||||
{{ include('@ChillMain/Layout/_debug.html.twig') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -49,7 +49,7 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface
|
||||
'route' => 'chill_crud_center_index',
|
||||
])->setExtras(['order' => 1010]);
|
||||
|
||||
$menu->addChild('Regroupements des centres', [
|
||||
$menu->addChild('Regroupements des territoires', [
|
||||
'route' => 'chill_crud_regroupment_index',
|
||||
])->setExtras(['order' => 1015]);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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 Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
|
||||
final class EntityWorkflowAttachmentVoter extends Voter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Registry $registry,
|
||||
) {}
|
||||
public const EDIT = 'CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT';
|
||||
|
||||
protected function supports(string $attribute, $subject): bool
|
||||
{
|
||||
return $subject instanceof EntityWorkflow && self::EDIT === $attribute;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
if (!$subject instanceof EntityWorkflow) {
|
||||
throw new \UnexpectedValueException('Subject must be an instance of EntityWorkflow');
|
||||
}
|
||||
|
||||
if ($subject->isFinal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workflow = $this->registry->get($subject, $subject->getWorkflowName());
|
||||
|
||||
$marking = $workflow->getMarking($subject);
|
||||
foreach ($marking->getPlaces() as $place => $int) {
|
||||
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
|
||||
if ($placeMetadata['isSentExternal'] ?? false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,25 @@ declare(strict_types=1);
|
||||
namespace Chill\MainBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
|
||||
class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {}
|
||||
public function __construct(
|
||||
private readonly EntityWorkflowManager $entityWorkflowManager,
|
||||
private readonly MetadataExtractor $metadataExtractor,
|
||||
private readonly Registry $registry,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param EntityWorkflow $object
|
||||
@@ -46,6 +53,9 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn
|
||||
'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context),
|
||||
'title' => $handler->getEntityTitle($object),
|
||||
'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(),
|
||||
'_permissions' => [
|
||||
EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class AddressReferenceBEFromBestAddress
|
||||
|
||||
$uncompressedStream = gzopen($tmpname, 'r');
|
||||
|
||||
$csv = Reader::createFromStream($uncompressedStream);
|
||||
$csv = Reader::from($uncompressedStream);
|
||||
$csv->setDelimiter(',');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ final class AddressReferenceBaseImporter
|
||||
|
||||
$filename = sprintf('%s-%s.csv', (new \DateTimeImmutable())->format('Ymd-His'), uniqid());
|
||||
$path = Path::normalize(sprintf('%s%s%s', sys_get_temp_dir(), DIRECTORY_SEPARATOR, $filename));
|
||||
$writer = Writer::createFromPath($path, 'w+');
|
||||
$writer = Writer::from($path, 'w+');
|
||||
// insert headers
|
||||
$writer->insertOne([
|
||||
'postalcode',
|
||||
|
||||
@@ -53,7 +53,7 @@ class AddressReferenceFromBAN
|
||||
// re-open it to read it
|
||||
$csvDecompressed = gzopen($path, 'r');
|
||||
|
||||
$csv = Reader::createFromStream($csvDecompressed);
|
||||
$csv = Reader::from($csvDecompressed);
|
||||
$csv->setDelimiter(';')->setHeaderOffset(0);
|
||||
$stmt = new Statement();
|
||||
$stmt = $stmt->process($csv, [
|
||||
|
||||
@@ -41,7 +41,7 @@ class AddressReferenceFromBano
|
||||
|
||||
fseek($file, 0);
|
||||
|
||||
$csv = Reader::createFromStream($file);
|
||||
$csv = Reader::from($file);
|
||||
$csv->setDelimiter(',');
|
||||
$stmt = new Statement();
|
||||
$stmt = $stmt->process($csv, [
|
||||
|
||||
@@ -39,7 +39,7 @@ class AddressReferenceLU
|
||||
|
||||
fseek($file, 0);
|
||||
|
||||
$csv = Reader::createFromStream($file);
|
||||
$csv = Reader::from($file);
|
||||
$csv->setDelimiter(';');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class PostalCodeBEFromBestAddress
|
||||
|
||||
$uncompressedStream = gzopen($tmpname, 'r');
|
||||
|
||||
$csv = Reader::createFromStream($uncompressedStream);
|
||||
$csv = Reader::from($uncompressedStream);
|
||||
$csv->setDelimiter(',');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class PostalCodeFRFromOpenData
|
||||
|
||||
fseek($tmpfile, 0);
|
||||
|
||||
$csv = Reader::createFromStream($tmpfile);
|
||||
$csv = Reader::from($tmpfile);
|
||||
$csv->setDelimiter(',');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Notifier\Event\SentMessageEvent;
|
||||
final readonly class SentMessageEventSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private LoggerInterface $notifierLogger, // will be send to "notifierLogger" if it exists
|
||||
) {}
|
||||
|
||||
public static function getSubscribedEvents()
|
||||
@@ -33,9 +33,9 @@ final readonly class SentMessageEventSubscriber implements EventSubscriberInterf
|
||||
$message = $event->getMessage();
|
||||
|
||||
if (null === $message->getMessageId()) {
|
||||
$this->logger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]);
|
||||
$this->notifierLogger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]);
|
||||
} else {
|
||||
$this->logger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]);
|
||||
$this->notifierLogger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ final class ScopeControllerTest extends WebTestCase
|
||||
$client->getResponse()->getStatusCode(),
|
||||
'Unexpected HTTP status code for GET /fr/admin/scope/'
|
||||
);
|
||||
$crawler = $client->click($crawler->selectLink('Créer un nouveau cercle')->link());
|
||||
$crawler = $client->click($crawler->selectLink('Créer un nouveau service')->link());
|
||||
// Fill in the form and submit it
|
||||
$form = $crawler->selectButton('Créer')->form([
|
||||
'chill_mainbundle_scope[name][fr]' => 'Test en fr',
|
||||
|
||||
@@ -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\Tests\DependencyInjection;
|
||||
|
||||
use Chill\MainBundle\DependencyInjection\Configuration;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Config\Definition\Processor;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ConfigurationTest extends TestCase
|
||||
{
|
||||
public function testTopBannerConfiguration(): void
|
||||
{
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
$configuration = new Configuration([], $containerBuilder);
|
||||
$processor = new Processor();
|
||||
|
||||
// Test with top_banner configuration
|
||||
$config = [
|
||||
'chill_main' => [
|
||||
'top_banner' => [
|
||||
'text' => [
|
||||
'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.',
|
||||
'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.',
|
||||
],
|
||||
'color' => 'white',
|
||||
'background-color' => 'red',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||
|
||||
self::assertArrayHasKey('top_banner', $processedConfig);
|
||||
self::assertArrayHasKey('text', $processedConfig['top_banner']);
|
||||
self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']);
|
||||
self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']);
|
||||
self::assertSame('white', $processedConfig['top_banner']['color']);
|
||||
self::assertSame('red', $processedConfig['top_banner']['background_color']);
|
||||
}
|
||||
|
||||
public function testTopBannerConfigurationOptional(): void
|
||||
{
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
$configuration = new Configuration([], $containerBuilder);
|
||||
$processor = new Processor();
|
||||
|
||||
// Test without top_banner configuration
|
||||
$config = [
|
||||
'chill_main' => [],
|
||||
];
|
||||
|
||||
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||
|
||||
// top_banner should not be present when not configured
|
||||
self::assertArrayNotHasKey('top_banner', $processedConfig);
|
||||
}
|
||||
|
||||
public function testTopBannerWithMinimalConfiguration(): void
|
||||
{
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
$configuration = new Configuration([], $containerBuilder);
|
||||
$processor = new Processor();
|
||||
|
||||
// Test with minimal top_banner configuration (only text)
|
||||
$config = [
|
||||
'chill_main' => [
|
||||
'top_banner' => [
|
||||
'text' => [
|
||||
'fr' => 'Test message',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||
|
||||
self::assertArrayHasKey('top_banner', $processedConfig);
|
||||
self::assertArrayHasKey('text', $processedConfig['top_banner']);
|
||||
self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']);
|
||||
self::assertNull($processedConfig['top_banner']['color']);
|
||||
self::assertNull($processedConfig['top_banner']['background_color']);
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,5 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase
|
||||
$actual = $this->dailyNotificationDigestCronjob->run([]);
|
||||
|
||||
self::assertArrayHasKey('last_execution', $actual);
|
||||
self::assertInstanceOf(
|
||||
\DateTimeImmutable::class,
|
||||
\DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']),
|
||||
'test that the string can be converted to a date'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,21 @@ declare(strict_types=1);
|
||||
namespace Chill\MainBundle\Tests\Notification\Email;
|
||||
|
||||
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
|
||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Result;
|
||||
use Doctrine\DBAL\Statement;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
* @covers \DailyNotificationDigestCronjob
|
||||
*/
|
||||
class DailyNotificationDigestCronJobTest extends TestCase
|
||||
{
|
||||
@@ -30,6 +35,7 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
||||
private MessageBusInterface $messageBus;
|
||||
private LoggerInterface $logger;
|
||||
private DailyNotificationDigestCronjob $cronjob;
|
||||
private \DateTimeImmutable $firstNow;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -38,6 +44,8 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
||||
$this->messageBus = $this->createMock(MessageBusInterface::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00');
|
||||
|
||||
$this->cronjob = new DailyNotificationDigestCronjob(
|
||||
$this->clock,
|
||||
$this->connection,
|
||||
@@ -78,4 +86,129 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
||||
'hour 23 - should not run' => [23, false],
|
||||
];
|
||||
}
|
||||
|
||||
public function testRunFirstExecutionReturnsStateAndDispatches(): array
|
||||
{
|
||||
// Use MockClock for deterministic time
|
||||
$firstNow = $this->firstNow;
|
||||
$clock = new MockClock($firstNow);
|
||||
|
||||
// Mock DBAL statement/result
|
||||
$statement = $this->createMock(Statement::class);
|
||||
$result = $this->createMock(Result::class);
|
||||
|
||||
$this->connection->method('prepare')->willReturn($statement);
|
||||
$statement->method('bindValue')->willReturnSelf();
|
||||
$statement->method('executeQuery')->willReturn($result);
|
||||
|
||||
$rows = [
|
||||
['user_id' => 10],
|
||||
['user_id' => 42],
|
||||
];
|
||||
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
$dispatched = [];
|
||||
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||
$state = $cron->run([]);
|
||||
|
||||
// Assert dispatch count and message contents
|
||||
self::assertCount(2, $dispatched);
|
||||
$expectedLast = $firstNow->sub(new \DateInterval('P1D'));
|
||||
foreach ($dispatched as $i => $msg) {
|
||||
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||
self::assertTrue(in_array($msg->getUserId(), [10, 42], true));
|
||||
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||
self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||
}
|
||||
|
||||
// Assert returned state
|
||||
self::assertIsArray($state);
|
||||
self::assertArrayHasKey('last_execution', $state);
|
||||
self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @depends testRunFirstExecutionReturnsStateAndDispatches
|
||||
*/
|
||||
public function testRunSecondExecutionUsesPreviousState(array $previousState): void
|
||||
{
|
||||
$firstNow = $this->firstNow;
|
||||
$secondNow = $firstNow->add(new \DateInterval('P1D'));
|
||||
$clock = new MockClock($secondNow);
|
||||
|
||||
// Mock DBAL for a single user this time
|
||||
$statement = $this->createMock(Statement::class);
|
||||
$result = $this->createMock(Result::class);
|
||||
|
||||
$this->connection->method('prepare')->willReturn($statement);
|
||||
$statement->method('bindValue')->willReturnSelf();
|
||||
$statement->method('executeQuery')->willReturn($result);
|
||||
|
||||
$rows = [
|
||||
['user_id' => 7],
|
||||
];
|
||||
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
$captured = [];
|
||||
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
|
||||
$captured[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||
$cron->run($previousState);
|
||||
|
||||
self::assertCount(1, $captured);
|
||||
$msg = $captured[0];
|
||||
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||
self::assertEquals(7, $msg->getUserId());
|
||||
self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||
self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||
}
|
||||
|
||||
public function testRunWithInvalidExecutionState(): void
|
||||
{
|
||||
$firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels');
|
||||
$previousExpected = $firstNow->sub(new \DateInterval('P1D'));
|
||||
$clock = new MockClock($firstNow);
|
||||
|
||||
// Mock DBAL for a single user this time
|
||||
$statement = $this->createMock(Statement::class);
|
||||
$result = $this->createMock(Result::class);
|
||||
|
||||
$this->connection->method('prepare')->willReturn($statement);
|
||||
$statement->method('bindValue')->willReturnSelf();
|
||||
$statement->method('executeQuery')->willReturn($result);
|
||||
|
||||
$rows = [
|
||||
['user_id' => 7],
|
||||
];
|
||||
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
$captured = [];
|
||||
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
|
||||
$captured[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||
$cron->run(['last_execution' => 'invalid data']);
|
||||
|
||||
self::assertCount(1, $captured);
|
||||
$msg = $captured[0];
|
||||
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||
self::assertEquals(7, $msg->getUserId());
|
||||
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||
self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Tests\Security\Authorization;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
|
||||
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Workflow\DefinitionBuilder;
|
||||
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
|
||||
use Symfony\Component\Workflow\Transition;
|
||||
use Symfony\Component\Workflow\Workflow;
|
||||
use Symfony\Component\Workflow\WorkflowInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class EntityWorkflowAttachmentVoterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @dataProvider dataVoteOnAttribute
|
||||
*/
|
||||
public function testVoteOnAttribute(EntityWorkflow $entityWorkflow, int $expected): void
|
||||
{
|
||||
$voter = new EntityWorkflowAttachmentVoter($this->buildRegistry());
|
||||
$actual = $voter->vote(
|
||||
new UsernamePasswordToken(new User(), 'default'),
|
||||
$entityWorkflow,
|
||||
['CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'],
|
||||
);
|
||||
$this->assertEquals($expected, $actual);
|
||||
}
|
||||
|
||||
public static function dataVoteOnAttribute(): iterable
|
||||
{
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
$workflow = static::buildRegistry()->get($entity, 'dummy');
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entity);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$workflow->apply(
|
||||
$entity,
|
||||
'to_final_positive',
|
||||
['context' => $dto,
|
||||
'byUser' => new User(),
|
||||
'transition' => 'to_final_positive',
|
||||
'transitionAt' => new \DateTimeImmutable()],
|
||||
);
|
||||
// we need to mark manually as final, as the listener is not registered
|
||||
$entity->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield 'on final positive' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
$workflow = static::buildRegistry()->get($entity, 'dummy');
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entity);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$workflow->apply(
|
||||
$entity,
|
||||
'to_final_negative',
|
||||
['context' => $dto,
|
||||
'byUser' => new User(),
|
||||
'transition' => 'to_final_negative',
|
||||
'transitionAt' => new \DateTimeImmutable()],
|
||||
);
|
||||
// we need to mark manually as final, as the listener is not registered
|
||||
$entity->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield 'on final negative' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
$workflow = static::buildRegistry()->get($entity, 'dummy');
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entity);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$workflow->apply(
|
||||
$entity,
|
||||
'to_sent_external',
|
||||
['context' => $dto,
|
||||
'byUser' => new User(),
|
||||
'transition' => 'to_sent_external',
|
||||
'transitionAt' => new \DateTimeImmutable()],
|
||||
);
|
||||
|
||||
yield 'on sent_external' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
yield 'on initial' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildRegistry(): Registry
|
||||
{
|
||||
$builder = new DefinitionBuilder();
|
||||
$builder
|
||||
->setInitialPlaces(['initial'])
|
||||
->addPlaces(['initial', 'sent_external', 'final_positive', 'final_negative'])
|
||||
->addTransitions([
|
||||
new Transition('to_final_positive', ['initial'], 'final_positive'),
|
||||
new Transition('to_sent_external', ['initial'], 'sent_external'),
|
||||
new Transition('to_final_negative', ['initial'], 'final_negative'),
|
||||
|
||||
])
|
||||
->setMetadataStore(
|
||||
new InMemoryMetadataStore(
|
||||
placesMetadata: [
|
||||
'sent_external' => [
|
||||
'isSentExternal' => true,
|
||||
],
|
||||
'final_positive' => [
|
||||
'isFinal' => true,
|
||||
'isFinalPositive' => true,
|
||||
],
|
||||
'final_negative' => [
|
||||
'isFinal' => true,
|
||||
'isFinalPositive' => false,
|
||||
],
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
|
||||
$registry = new Registry();
|
||||
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
|
||||
public function supports(WorkflowInterface $workflow, object $subject): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return $registry;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Tests\Workflow\Helper;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
|
||||
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
@@ -148,8 +151,11 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user is not present as a dest user'];
|
||||
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because there is no workflow'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force deny because the user is not present as a dest user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
@@ -171,6 +177,9 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user was a previous user'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force denied because the user was not a previous user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
@@ -232,6 +241,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature on a canceled workflow'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), 'force denied: the workflow is sent to an external user'];
|
||||
}
|
||||
|
||||
public function testNoWorkflow(): void
|
||||
@@ -253,7 +269,217 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
|
||||
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
|
||||
|
||||
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
|
||||
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
|
||||
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]);
|
||||
|
||||
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment
|
||||
*
|
||||
* @param list<EntityWorkflow> $entityWorkflows
|
||||
*/
|
||||
public function testAllowedByWorkflowReadByAttachment(
|
||||
array $entityWorkflows,
|
||||
User $user,
|
||||
string $expected,
|
||||
?\DateTimeImmutable $atDate,
|
||||
string $message,
|
||||
): void {
|
||||
// all entities must have this workflow name, so we are ok to set it here
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
$entityWorkflow->setWorkflowName('dummy');
|
||||
}
|
||||
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
|
||||
|
||||
self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message);
|
||||
}
|
||||
|
||||
public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable
|
||||
{
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user is not present as a dest user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user is a current user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user was a previous user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'Abstain: there is a signature for person, but the attachment is not concerned'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment
|
||||
*
|
||||
* @param list<EntityWorkflow> $entityWorkflows
|
||||
*/
|
||||
public function testAllowedByWorkflowWriteByAttachment(
|
||||
array $entityWorkflows,
|
||||
User $user,
|
||||
string $expected,
|
||||
?\DateTimeImmutable $atDate,
|
||||
string $message,
|
||||
): void {
|
||||
// all entities must have this workflow name, so we are ok to set it here
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
$entityWorkflow->setWorkflowName('dummy');
|
||||
}
|
||||
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
|
||||
|
||||
self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message);
|
||||
}
|
||||
|
||||
public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable
|
||||
{
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because there is no workflow'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user is not present as a dest user (and attachment)'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user is a current user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user was a previous user'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user was not a previous user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User());
|
||||
$entityWorkflow->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force denied: user was a previous user, but it is finalized positive'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable());
|
||||
$entityWorkflow->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: user was a previous user, it is finalized, but finalized negative'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
|
||||
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature, but not on the attachment'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature, but the signature is not on the attachment'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
|
||||
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User());
|
||||
$entityWorkflow->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature on a canceled workflow'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force denied: the workflow is sent to an external user'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<EntityWorkflow> $entityWorkflows
|
||||
*/
|
||||
private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->getUser()->willReturn($user);
|
||||
|
||||
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
|
||||
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled();
|
||||
|
||||
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
|
||||
$attachments = [];
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
$attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject());
|
||||
}
|
||||
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
|
||||
|
||||
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
|
||||
}
|
||||
|
||||
private static function buildRegistry(): Registry
|
||||
@@ -261,10 +487,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
$builder = new DefinitionBuilder();
|
||||
$builder
|
||||
->setInitialPlaces(['initial'])
|
||||
->addPlaces(['initial', 'test', 'final_positive', 'final_negative'])
|
||||
->addPlaces(['initial', 'test', 'sent_external', 'final_positive', 'final_negative'])
|
||||
->setMetadataStore(
|
||||
new InMemoryMetadataStore(
|
||||
placesMetadata: [
|
||||
'sent_external' => [
|
||||
'isSentExternal' => true,
|
||||
],
|
||||
'final_positive' => [
|
||||
'isFinal' => true,
|
||||
'isFinalPositive' => true,
|
||||
|
||||
@@ -11,8 +11,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Tests\Workflow\Messenger;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
@@ -23,6 +26,7 @@ use Chill\ThirdPartyBundle\Entity\ThirdParty;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
@@ -39,24 +43,54 @@ class PostSendExternalMessageHandlerTest extends TestCase
|
||||
public function testSendMessageHappyScenario(): void
|
||||
{
|
||||
$entityWorkflow = $this->buildEntityWorkflow();
|
||||
|
||||
// Prepare attachments (2 attachments)
|
||||
$attachmentStoredObject1 = new StoredObject();
|
||||
$attachmentStoredObject2 = new StoredObject();
|
||||
new EntityWorkflowAttachment('generic_doc', ['id' => 1], $entityWorkflow, $attachmentStoredObject1);
|
||||
new EntityWorkflowAttachment('generic_doc', ['id' => 2], $entityWorkflow, $attachmentStoredObject2);
|
||||
|
||||
// Prepare transition DTO and sends
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestineeEmails = ['external@example.com'];
|
||||
$dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')];
|
||||
$entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User());
|
||||
|
||||
// Repository returns our workflow
|
||||
$repository = $this->prophesize(EntityWorkflowRepository::class);
|
||||
$repository->find(1)->willReturn($entityWorkflow);
|
||||
|
||||
// Mailer must send to both recipients
|
||||
$mailer = $this->prophesize(MailerInterface::class);
|
||||
$mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce();
|
||||
$mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce();
|
||||
|
||||
// Workflow manager and handler
|
||||
$workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
|
||||
$workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title');
|
||||
$workflowManager = $this->prophesize(EntityWorkflowManager::class);
|
||||
$workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal());
|
||||
|
||||
$handler = new PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $workflowManager->reveal());
|
||||
// Associated stored object for the workflow
|
||||
$associatedStoredObject = new StoredObject();
|
||||
$workflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($associatedStoredObject);
|
||||
|
||||
// Converter should be called for each attachment and the associated stored object
|
||||
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
|
||||
$converter->addConvertedVersion($attachmentStoredObject1, 'fr')->shouldBeCalledOnce();
|
||||
$converter->addConvertedVersion($attachmentStoredObject2, 'fr')->shouldBeCalledOnce();
|
||||
$converter->addConvertedVersion($associatedStoredObject, 'fr')->shouldBeCalledOnce();
|
||||
|
||||
// Logger (not used in happy path, but required by handler)
|
||||
$logger = $this->prophesize(LoggerInterface::class);
|
||||
|
||||
$handler = new PostSendExternalMessageHandler(
|
||||
$repository->reveal(),
|
||||
$mailer->reveal(),
|
||||
$workflowManager->reveal(),
|
||||
$converter->reveal(),
|
||||
$logger->reveal(),
|
||||
);
|
||||
|
||||
$handler(new PostSendExternalMessage(1, 'fr'));
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Workflow\Helper;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
@@ -58,21 +61,39 @@ class WorkflowRelatedEntityPermissionHelper
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EntityWorkflowManager $entityWorkflowManager,
|
||||
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
|
||||
private readonly Registry $registry,
|
||||
private readonly ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param object $entity The entity may be an
|
||||
*
|
||||
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
|
||||
*/
|
||||
public function isAllowedByWorkflowForReadOperation(object $entity): string
|
||||
{
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
if ($entity instanceof StoredObject) {
|
||||
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
|
||||
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
|
||||
$isAttached = true;
|
||||
} else {
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
$isAttached = false;
|
||||
}
|
||||
|
||||
if ([] === $entityWorkflows) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) {
|
||||
return self::FORCE_GRANT;
|
||||
}
|
||||
|
||||
if ($isAttached) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
// give a view permission if there is a Person signature pending, or in the 12 hours following
|
||||
// the signature last state
|
||||
foreach ($entityWorkflows as $workflow) {
|
||||
@@ -100,33 +121,51 @@ class WorkflowRelatedEntityPermissionHelper
|
||||
*/
|
||||
public function isAllowedByWorkflowForWriteOperation(object $entity): string
|
||||
{
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
$runningWorkflows = [];
|
||||
if ($entity instanceof StoredObject) {
|
||||
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
|
||||
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
|
||||
$isAttached = true;
|
||||
} else {
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
$isAttached = false;
|
||||
}
|
||||
|
||||
// if a workflow is finalized positive, we are not allowed to edit to document any more
|
||||
if ([] === $entityWorkflows) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
// if a workflow is finalized positive, anyone is allowed to edit the document anymore
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
if ($entityWorkflow->isFinal()) {
|
||||
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
|
||||
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
|
||||
foreach ($marking->getPlaces() as $place => $int) {
|
||||
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
|
||||
if (true === ($placeMetadata['isFinalPositive'] ?? false)) {
|
||||
// the workflow is final, and final positive, so we stop here.
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
|
||||
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
|
||||
foreach ($marking->getPlaces() as $place => $int) {
|
||||
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
|
||||
if (
|
||||
($entityWorkflow->isFinal() && ($placeMetadata['isFinalPositive'] ?? false))
|
||||
|| ($placeMetadata['isSentExternal'] ?? false)
|
||||
) {
|
||||
// the workflow is final, and final positive, or is sentExternal, so we stop here.
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
if (
|
||||
// if not finalized positive
|
||||
$entityWorkflow->isFinal() && !($placeMetadata['isFinalPositive'] ?? false)
|
||||
) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
} else {
|
||||
$runningWorkflows[] = $entityWorkflow;
|
||||
}
|
||||
}
|
||||
|
||||
// if there is a signature on a **running workflow**, no one can edit the workflow any more
|
||||
foreach ($runningWorkflows as $entityWorkflow) {
|
||||
foreach ($entityWorkflow->getSteps() as $step) {
|
||||
foreach ($step->getSignatures() as $signature) {
|
||||
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
|
||||
return self::FORCE_DENIED;
|
||||
$runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal());
|
||||
|
||||
// if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore
|
||||
if (!$isAttached) {
|
||||
foreach ($runningWorkflows as $entityWorkflow) {
|
||||
foreach ($entityWorkflow->getSteps() as $step) {
|
||||
foreach ($step->getSignatures() as $signature) {
|
||||
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +176,11 @@ class WorkflowRelatedEntityPermissionHelper
|
||||
return self::FORCE_GRANT;
|
||||
}
|
||||
|
||||
return self::ABSTAIN;
|
||||
if ($isAttached) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,9 +11,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Workflow\Messenger;
|
||||
|
||||
use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
|
||||
@@ -25,6 +30,8 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt
|
||||
private EntityWorkflowRepository $entityWorkflowRepository,
|
||||
private MailerInterface $mailer,
|
||||
private EntityWorkflowManager $workflowManager,
|
||||
private StoredObjectToPdfConverter $storedObjectToPdfConverter,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(PostSendExternalMessage $message): void
|
||||
@@ -35,11 +42,38 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt
|
||||
throw new UnrecoverableMessageHandlingException(sprintf('Entity workflow with id %d not found', $message->entityWorkflowId));
|
||||
}
|
||||
|
||||
$this->convertToPdf($entityWorkflow, $message->lang);
|
||||
|
||||
foreach ($entityWorkflow->getCurrentStep()->getSends() as $send) {
|
||||
$this->sendEmailToDestinee($send, $message);
|
||||
}
|
||||
}
|
||||
|
||||
private function convertToPdf(EntityWorkflow $entityWorkflow, string $locale): void
|
||||
{
|
||||
foreach ($entityWorkflow->getAttachments() as $attachment) {
|
||||
try {
|
||||
$this->storedObjectToPdfConverter->addConvertedVersion($attachment->getProxyStoredObject(), $locale);
|
||||
} catch (StoredObjectManagerException $e) {
|
||||
$this->logger->error('Error converting attachment to PDF', ['backtrace' => $e->getTraceAsString(), 'attachment_id' => $attachment->getId()]);
|
||||
} catch (ConversionWithSameMimeTypeException $e) {
|
||||
$this->logger->error('Error converting attachment to PDF: already at the same MIME type', ['backtrace' => $e->getTraceAsString(), 'attachment_id' => $attachment->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
$storedObject = $this->workflowManager->getAssociatedStoredObject($entityWorkflow);
|
||||
|
||||
if (null !== $storedObject) {
|
||||
try {
|
||||
$this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $locale);
|
||||
} catch (StoredObjectManagerException $e) {
|
||||
$this->logger->error('Error converting stored object to PDF', ['backtrace' => $e->getTraceAsString(), 'stored_object_id' => $storedObject->getId(), 'workflow_id' => $entityWorkflow->getId()]);
|
||||
} catch (ConversionWithSameMimeTypeException $e) {
|
||||
$this->logger->error('Error converting stored object to PDF: already at the same MIME type', ['backtrace' => $e->getTraceAsString(), 'stored_object_id' => $storedObject->getId(), 'workflow_id' => $entityWorkflow->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendEmailToDestinee(EntityWorkflowSend $send, PostSendExternalMessage $message): void
|
||||
{
|
||||
$entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow();
|
||||
|
||||
@@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
use Symfony\Component\Workflow\Transition;
|
||||
|
||||
/**
|
||||
* Handles state changes for signature steps within a workflow.
|
||||
@@ -50,8 +51,10 @@ class SignatureStepStateChanger
|
||||
*
|
||||
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as signed
|
||||
* @param int|null $atIndex optional index position for the signature within the zone
|
||||
*
|
||||
* @return string The expected new workflow's step, after transition is applyied
|
||||
*/
|
||||
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): void
|
||||
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): string
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
@@ -60,7 +63,14 @@ class SignatureStepStateChanger
|
||||
->setZoneSignatureIndex($atIndex)
|
||||
->setStateDate($this->clock->now());
|
||||
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]);
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
|
||||
if (null === $transition) {
|
||||
return $signature->getStep()->getEntityWorkflow()->getStep();
|
||||
}
|
||||
|
||||
return $transition->getTos()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,8 +81,10 @@ class SignatureStepStateChanger
|
||||
*
|
||||
* This method updates the signature state to 'canceled' and logs the action.
|
||||
* It also dispatches a message to notify about the state change.
|
||||
*
|
||||
* @return string The expected new workflow's step, after transition is applyied
|
||||
*/
|
||||
public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): void
|
||||
public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): string
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
@@ -80,7 +92,15 @@ class SignatureStepStateChanger
|
||||
->setState(EntityWorkflowSignatureStateEnum::CANCELED)
|
||||
->setStateDate($this->clock->now());
|
||||
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as canceled', ['signatureId' => $signature->getId()]);
|
||||
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
|
||||
if (null === $transition) {
|
||||
return $signature->getStep()->getEntityWorkflow()->getStep();
|
||||
}
|
||||
|
||||
return $transition->getTos()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,8 +113,10 @@ class SignatureStepStateChanger
|
||||
* a state change has occurred.
|
||||
*
|
||||
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as rejected
|
||||
*
|
||||
* @return string The expected new workflow's step, after transition is applyied
|
||||
*/
|
||||
public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): void
|
||||
public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): string
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
@@ -102,7 +124,16 @@ class SignatureStepStateChanger
|
||||
->setState(EntityWorkflowSignatureStateEnum::REJECTED)
|
||||
->setStateDate($this->clock->now());
|
||||
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as rejected', ['signatureId' => $signature->getId()]);
|
||||
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
|
||||
|
||||
if (null === $transition) {
|
||||
return $signature->getStep()->getEntityWorkflow()->getStep();
|
||||
}
|
||||
|
||||
return $transition->getTos()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,10 +148,35 @@ class SignatureStepStateChanger
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_READ);
|
||||
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
if (null === $transition) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
|
||||
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
|
||||
$transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$transitionDto->futureDestUsers[] = $futureUser;
|
||||
|
||||
$workflow->apply($entityWorkflow, $transition->getName(), [
|
||||
'context' => $transitionDto,
|
||||
'transitionAt' => $this->clock->now(),
|
||||
'transition' => $transition->getName(),
|
||||
]);
|
||||
|
||||
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{transition: Transition|null, futureUser: User|null}
|
||||
*/
|
||||
private function decideTransition(EntityWorkflowStepSignature $signature): array
|
||||
{
|
||||
if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) {
|
||||
$this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]);
|
||||
|
||||
return;
|
||||
return ['transition' => null, 'futureUser' => null];
|
||||
}
|
||||
|
||||
$this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]);
|
||||
@@ -144,7 +200,7 @@ class SignatureStepStateChanger
|
||||
if (null === $transition) {
|
||||
$this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]);
|
||||
|
||||
return;
|
||||
return ['transition' => null, 'futureUser' => null];
|
||||
}
|
||||
|
||||
if ('person' === $signature->getSignerKind()) {
|
||||
@@ -156,19 +212,16 @@ class SignatureStepStateChanger
|
||||
if (null === $futureUser) {
|
||||
$this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]);
|
||||
|
||||
return;
|
||||
return ['transition' => null, 'futureUser' => null];
|
||||
}
|
||||
|
||||
$transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$transitionDto->futureDestUsers[] = $futureUser;
|
||||
foreach ($workflow->getDefinition()->getTransitions() as $transitionObj) {
|
||||
if ($transitionObj->getName() === $transition) {
|
||||
return ['transition' => $transitionObj, 'futureUser' => $futureUser];
|
||||
}
|
||||
}
|
||||
|
||||
$workflow->apply($entityWorkflow, $transition, [
|
||||
'context' => $transitionDto,
|
||||
'transitionAt' => $this->clock->now(),
|
||||
'transition' => $transition,
|
||||
]);
|
||||
|
||||
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
|
||||
throw new \RuntimeException('Transition not found');
|
||||
}
|
||||
|
||||
private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User
|
||||
|
||||
@@ -965,6 +965,31 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserJob"
|
||||
/1.0/main/workflow/{id}.json:
|
||||
get:
|
||||
tags:
|
||||
- workflow
|
||||
summary: Return a workflow
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: The workflow id
|
||||
schema:
|
||||
type: integer
|
||||
format: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
200:
|
||||
description: "ok"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Workflow"
|
||||
404:
|
||||
description: "not found"
|
||||
401:
|
||||
description: "Unauthorized"
|
||||
/1.0/main/workflow/my:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -120,5 +120,8 @@ module.exports = function (encore, entries) {
|
||||
"vue_onthefly",
|
||||
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
|
||||
);
|
||||
|
||||
encore.addEntry(
|
||||
"page_workflow_waiting_post_process",
|
||||
__dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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\Migrations\Main;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20251104124123 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Delete on cascade EntityWorkflowStepHold';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold
|
||||
DROP CONSTRAINT fk_1be2e7c73b21e9c');
|
||||
|
||||
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold
|
||||
ADD CONSTRAINT fk_1be2e7c73b21e9c
|
||||
FOREIGN KEY (step_id)
|
||||
REFERENCES chill_main_workflow_entity_step (id)
|
||||
ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold
|
||||
DROP CONSTRAINT fk_1be2e7c73b21e9c');
|
||||
|
||||
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold
|
||||
ADD CONSTRAINT fk_1be2e7c73b21e9c
|
||||
FOREIGN KEY (step_id)
|
||||
REFERENCES chill_main_workflow_entity_step (id)');
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,20 @@ duration:
|
||||
few {# minutes}
|
||||
other {# minutes}
|
||||
}
|
||||
hour: >-
|
||||
{h, plural,
|
||||
=0 {Aucune durée}
|
||||
one {# heure}
|
||||
few {# heures}
|
||||
other {# heures}
|
||||
}
|
||||
day: >-
|
||||
{d, plural,
|
||||
=0 {Aucune durée}
|
||||
one {# jour}
|
||||
few {# jours}
|
||||
other {# jours}
|
||||
}
|
||||
|
||||
filter_order:
|
||||
by_date:
|
||||
|
||||
@@ -54,7 +54,7 @@ user:
|
||||
title: Mon profil
|
||||
Profile successfully updated!: Votre profil a été mis à jour!
|
||||
no job: Pas de métier assigné
|
||||
no scope: Pas de cercle assigné
|
||||
no scope: Pas de service assigné
|
||||
notification_preferences: Préférences pour mes notifications
|
||||
|
||||
user_group:
|
||||
@@ -102,9 +102,9 @@ createdAt: Créé le
|
||||
createdBy: Créé par
|
||||
|
||||
#elements used in software
|
||||
centers: centres
|
||||
Centers: Centres
|
||||
center: centre
|
||||
centers: territoires
|
||||
Centers: Territoires
|
||||
center: territoire
|
||||
comment: commentaire
|
||||
Comment: Commentaire
|
||||
Comments: Commentaires
|
||||
@@ -227,12 +227,12 @@ Location Menu: Localisations et types de localisation
|
||||
Management of location: Gestion des localisations et types de localisation
|
||||
|
||||
#admin section for center's administration
|
||||
Create a new center: Créer un nouveau centre
|
||||
Center list: Liste des centres
|
||||
Center edit: Édition d'un centre
|
||||
Center creation: Création d'un centre
|
||||
New center: Nouveau centre
|
||||
Center: Centre
|
||||
Create a new center: Créer une nouveau territoire
|
||||
Center list: Liste des territoires
|
||||
Center edit: Édition d'un territoire
|
||||
Center creation: Création d'un territoire
|
||||
New center: Nouveau territoire
|
||||
Center: Territoire
|
||||
|
||||
#admin section for permissions group
|
||||
Permissions group list: Groupes de permissions
|
||||
@@ -246,15 +246,15 @@ New permission group: Nouveau groupe de permissions
|
||||
PermissionsGroup "%name%" edit: Modification du groupe de permission '%name%'
|
||||
Role: Rôle
|
||||
Choose amongst roles: Choisir un rôle
|
||||
Choose amongst scopes: Choisir un cercle
|
||||
Choose amongst scopes: Choisir un service
|
||||
Add permission: Ajouter les permissions
|
||||
This group does not provide any permission: Ce groupe n'attribue aucune permission
|
||||
The role '%role%' has been removed: Le rôle "%role%" a été enlevé de ce groupe de permission
|
||||
The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le cercle "%scope%" a été enlevé de ce groupe de permission
|
||||
The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le service "%scope%" a été enlevé de ce groupe de permission
|
||||
Unclassified: Non classifié
|
||||
Help to pick role and scope: Certains rôles ne nécessitent pas de cercle.
|
||||
The role need scope: Ce rôle nécessite un cercle.
|
||||
The role does not need scope: Ce rôle ne nécessite pas de cercle !
|
||||
Help to pick role and scope: Certains rôles ne nécessitent pas de service.
|
||||
The role need scope: Ce rôle nécessite un service.
|
||||
The role does not need scope: Ce rôle ne nécessite pas de service !
|
||||
|
||||
#admin section for users
|
||||
User configuration: Gestion des utilisateurs
|
||||
@@ -270,7 +270,7 @@ Grant new permissions: Ajout de permissions
|
||||
Add a new groupCenter: Ajout de permissions
|
||||
The permissions have been successfully added to the user: Les permissions ont été accordées à l'utilisateur
|
||||
The permissions where removed.: Les permissions ont été enlevées.
|
||||
Center & groups: Centre et groupes
|
||||
Center & groups: Territoire et groupes
|
||||
User %username%: Utilisateur %username%
|
||||
Add a new user: Ajouter un nouvel utilisateur
|
||||
The permissions have been added: Les permissions ont été ajoutées
|
||||
@@ -280,13 +280,13 @@ Back to the user edition: Retour au formulaire d'édition
|
||||
Password successfully updated!: Mot de passe mis à jour
|
||||
Flags: Drapeaux
|
||||
Main location: Localisation principale
|
||||
Main scope: Cercle
|
||||
Main center: Centre
|
||||
Main scope: Service
|
||||
Main center: Territoire
|
||||
user job: Métier de l'utilisateur
|
||||
Job: Métier
|
||||
Jobs: Métiers
|
||||
Choose a main center: Choisir un centre
|
||||
Choose a main scope: Choisir un cercle
|
||||
Choose a main center: Choisir un territoire
|
||||
Choose a main scope: Choisir un service
|
||||
choose a job: Choisir un métier
|
||||
choose a location: Choisir une localisation
|
||||
|
||||
@@ -302,12 +302,12 @@ Current location successfully updated: Localisation actuelle mise à jour
|
||||
Pick a location: Choisir un lieu
|
||||
|
||||
#admin section for circles (old: scopes)
|
||||
List circles: Cercles
|
||||
New circle: Nouveau cercle
|
||||
Circle: Cercle
|
||||
Circle edit: Modification du cercle
|
||||
Circle creation: Création d'un cercle
|
||||
Create a new circle: Créer un nouveau cercle
|
||||
List circles: Services
|
||||
New circle: Nouveau service
|
||||
Circle: Service
|
||||
Circle edit: Modification du service
|
||||
Circle creation: Création d'un service
|
||||
Create a new circle: Créer un nouveau service
|
||||
|
||||
#admin section for location
|
||||
Location: Localisation
|
||||
@@ -347,9 +347,9 @@ Country list: Liste des pays
|
||||
Country code: Code du pays
|
||||
|
||||
# circles / scopes
|
||||
Choose the circle: Choisir le cercle
|
||||
Scope: Cercle
|
||||
Scopes: Cercles
|
||||
Choose the circle: Choisir le service
|
||||
Scope: Service
|
||||
Scopes: Services
|
||||
|
||||
#export
|
||||
|
||||
@@ -357,14 +357,14 @@ Scopes: Cercles
|
||||
Exports list: Liste des exports
|
||||
Create an export: Créer un export
|
||||
#export creation step 'center' : pick a center
|
||||
Pick centers: Choisir les centres
|
||||
Pick a center: Choisir un centre
|
||||
The export will contains only data from the picked centers.: L'export ne contiendra que les données des centres choisis.
|
||||
This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les centres choisis.
|
||||
Pick centers: Choisir les territoires
|
||||
Pick a center: Choisir un territoire
|
||||
The export will contains only data from the picked centers.: L'export ne contiendra que les données des territoires choisis.
|
||||
This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les territoires choisis.
|
||||
Go to export options: Vers la préparation de l'export
|
||||
Pick aggregated centers: Regroupement de centres
|
||||
uncheck all centers: Désélectionner tous les centres
|
||||
check all centers: Sélectionner tous les centres
|
||||
Pick aggregated centers: Regroupement de territoires
|
||||
uncheck all centers: Désélectionner tous les territoires
|
||||
check all centers: Sélectionner tous les territoires
|
||||
# export creation step 'export' : choose aggregators, filtering and formatter
|
||||
Formatter: Mise en forme
|
||||
Choose the formatter: Choisissez le format d'export voulu.
|
||||
@@ -510,10 +510,10 @@ crud:
|
||||
title_edit: Modifier un regroupement
|
||||
center:
|
||||
index:
|
||||
title: Liste des centres
|
||||
add_new: Ajouter un centre
|
||||
title_new: Nouveau centre
|
||||
title_edit: Modifier un centre
|
||||
title: Liste des territoires
|
||||
add_new: Ajouter un territoire
|
||||
title_new: Nouveau territoire
|
||||
title_edit: Modifier un territoire
|
||||
news_item:
|
||||
index:
|
||||
title: Liste des actualités
|
||||
@@ -666,9 +666,18 @@ workflow:
|
||||
cancel_are_you_sure: Êtes-vous sûr de vouloir annuler la signature de %signer%
|
||||
reject_signature_of: Rejet de la signature de %signer%
|
||||
reject_are_you_sure: Êtes-vous sûr de vouloir rejeter la signature de %signer%
|
||||
waiting_for: En attente de modification de l'état de la signature
|
||||
|
||||
attachments:
|
||||
title: Pièces jointes
|
||||
no_attachment: Aucune pièce jointe
|
||||
Add_an_attachment: Ajouter une pièce jointe
|
||||
|
||||
wait:
|
||||
title: En attente de traitement
|
||||
error_while_waiting: Le traitement a échoué
|
||||
success: Traitement terminé. Redirection en cours...
|
||||
|
||||
|
||||
Subscribe final: Recevoir une notification à l'étape finale
|
||||
Subscribe all steps: Recevoir une notification à chaque étape
|
||||
@@ -853,7 +862,7 @@ absence:
|
||||
admin:
|
||||
users:
|
||||
export_list_csv: Liste des utilisateurs (format CSV)
|
||||
export_permissions_csv: Association utilisateurs - groupes de permissions - centre (format CSV)
|
||||
export_permissions_csv: Association utilisateurs - groupes de permissions - territoire (format CSV)
|
||||
export:
|
||||
id: Identifiant
|
||||
username: Nom d'utilisateur
|
||||
@@ -863,8 +872,8 @@ admin:
|
||||
civility_abbreviation: Abbréviation civilité
|
||||
civility_name: Civilité
|
||||
label: Label
|
||||
mainCenter_id: Identifiant centre principal
|
||||
mainCenter_name: Centre principal
|
||||
mainCenter_id: Identifiant territoire principal
|
||||
mainCenter_name: Territoire principal
|
||||
mainScope_id: Identifiant service principal
|
||||
mainScope_name: Service principal
|
||||
userJob_id: Identifiant métier
|
||||
@@ -874,8 +883,8 @@ admin:
|
||||
mainLocation_id: Identifiant localisation principale
|
||||
mainLocation_name: Localisation principale
|
||||
absenceStart: Absent à partir du
|
||||
center_id: Identifiant du centre
|
||||
center_name: Centre
|
||||
center_id: Identifiant du territoire
|
||||
center_name: Territoire
|
||||
permissionsGroup_id: Identifiant du groupe de permissions
|
||||
permissionsGroup_name: Groupe de permissions
|
||||
job_scope_histories:
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# role_scope constraint
|
||||
# scope presence
|
||||
The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un cercle.
|
||||
The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un cercle.
|
||||
The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un service.
|
||||
The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un service.
|
||||
"The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Le mot de passe doit contenir une majuscule, une minuscule, et au moins un caractère spécial parmi *[@#$%!,;:+\"'-/{}~=µ()£]). Les autres caractères sont autorisés."
|
||||
The password fields must match: Les mots de passe doivent correspondre
|
||||
The password must be greater than {{ limit }} characters: "[1,Inf] Le mot de passe doit contenir au moins {{ limit }} caractères"
|
||||
|
||||
A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et cercle.
|
||||
A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et service.
|
||||
|
||||
#UserCircleConsistency
|
||||
"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce cercle."
|
||||
"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce service."
|
||||
|
||||
The user in cc cannot be a dest user in the same workflow step: Un utilisateur en Cc ne peut pas être un utilisateur qui valide.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user