Merge branch 'master' into 385-invitation-list

This commit is contained in:
2025-11-06 12:14:21 +01:00
128 changed files with 2989 additions and 546 deletions

View File

@@ -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([

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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]
)
);
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\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])
);
}
}

View File

@@ -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,
],
],
],
],
],
]);
}

View File

@@ -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()

View File

@@ -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;
/**

View File

@@ -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),
];
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
};

View File

@@ -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";

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -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>();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 %}

View File

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

View File

@@ -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 %}

View File

@@ -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} %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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]);

View File

@@ -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;
}
}

View File

@@ -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),
],
];
}

View File

@@ -64,7 +64,7 @@ class AddressReferenceBEFromBestAddress
$uncompressedStream = gzopen($tmpname, 'r');
$csv = Reader::createFromStream($uncompressedStream);
$csv = Reader::from($uncompressedStream);
$csv->setDelimiter(',');
$csv->setHeaderOffset(0);

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ class AddressReferenceLU
fseek($file, 0);
$csv = Reader::createFromStream($file);
$csv = Reader::from($file);
$csv->setDelimiter(';');
$csv->setHeaderOffset(0);

View File

@@ -43,7 +43,7 @@ class PostalCodeBEFromBestAddress
$uncompressedStream = gzopen($tmpname, 'r');
$csv = Reader::createFromStream($uncompressedStream);
$csv = Reader::from($uncompressedStream);
$csv->setDelimiter(',');
$csv->setHeaderOffset(0);

View File

@@ -47,7 +47,7 @@ class PostalCodeFRFromOpenData
fseek($tmpfile, 0);
$csv = Reader::createFromStream($tmpfile);
$csv = Reader::from($tmpfile);
$csv->setDelimiter(',');
$csv->setHeaderOffset(0);

View File

@@ -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()]);
}
}
}

View File

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

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\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']);
}
}

View File

@@ -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'
);
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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'));

View File

@@ -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;
}
/**

View File

@@ -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();

View File

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

View File

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

View File

@@ -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"
);
};

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\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)');
}
}

View File

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

View File

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

View File

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