Compare commits

..

6 Commits

Author SHA1 Message Date
68688dd528 Revert "Merge branch 'revert-671bb6d5' into 'master'"
This reverts merge request !732
2024-09-19 13:40:09 +00:00
bfd7dc2270 Merge branch 'revert-671bb6d5' into 'master'
Revert "Merge branch 'signature-app/wp-576-restorestored-object-version' into 'master'"

See merge request Chill-Projet/chill-bundles!732
2024-09-19 13:32:41 +00:00
e4d0705e84 Revert "Merge branch 'signature-app/wp-576-restorestored-object-version' into 'master'"
This reverts merge request !586
2024-09-19 13:26:12 +00:00
671bb6d593 Merge branch 'signature-app/wp-576-restorestored-object-version' into 'master'
See the list of stored object and restore some versions

Closes #307

See merge request Chill-Projet/chill-bundles!730
2024-09-19 11:51:41 +00:00
79e26ffeae Merge branch '309-fix-referrer-scope-aggregator' into 'master'
Fix referrer scope date comparison in aggregator

Closes #309

See merge request Chill-Projet/chill-bundles!728
2024-09-16 13:58:58 +00:00
be8901a5c4 Fix referrer scope date comparison in aggregator
Correct the date comparison logic to use openingDate instead of closingDate when evaluating user history end dates. This ensures accurate grouping by referrer in the accompanying course aggregators. Added a changelog entry for Issue #309.
2024-09-16 15:52:48 +02:00
224 changed files with 794 additions and 9328 deletions

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: |
Correctly compute the grouping by referrer aggregator
time: 2024-09-16T15:51:50.268336979+02:00
custom:
Issue: "309"

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
final readonly class DocumentAccompanyingCourseDuplicateController
{
public function __construct(
private Security $security,
private AccompanyingCourseDocumentDuplicator $documentWorkflowDuplicator,
private EntityManagerInterface $entityManager,
private UrlGeneratorInterface $urlGenerator,
) {}
#[Route('/{_locale}/doc-store/accompanying-course-document/{id}/duplicate', name: 'chill_doc_store_accompanying_course_document_duplicate')]
public function __invoke(AccompanyingCourseDocument $document, Request $request, Session $session): Response
{
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $document)) {
throw new AccessDeniedHttpException('not allowed to see this document');
}
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::CREATE, $document->getCourse())) {
throw new AccessDeniedHttpException('not allowed to create this document');
}
$duplicated = $this->documentWorkflowDuplicator->duplicate($document);
$this->entityManager->persist($duplicated);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate('accompanying_course_document_edit', ['id' => $duplicated->getId(), 'course' => $duplicated->getCourse()->getId()])
);
}
}

View File

@@ -18,8 +18,6 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
@@ -259,33 +257,11 @@ class StoredObject implements Document, TrackCreationInterface
return $this->template;
}
/**
* @return Selectable<int, StoredObjectVersion>&Collection<int, StoredObjectVersion>
*/
public function getVersions(): Collection&Selectable
{
return $this->versions;
}
/**
* Retrieves versions sorted by a given order.
*
* @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending
*
* @return readableCollection&Selectable The ordered collection of versions
*/
public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable
{
$versions = $this->getVersions()->toArray();
match ($order) {
'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()),
'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()),
};
return new ArrayCollection($versions);
}
public function hasCurrentVersion(): bool
{
return null !== $this->getCurrentVersion();
@@ -296,47 +272,6 @@ class StoredObject implements Document, TrackCreationInterface
return null !== $this->template;
}
/**
* Checks if there is a version kept before conversion.
*
* @return bool true if a version is kept before conversion, false otherwise
*/
public function hasKeptBeforeConversionVersion(): bool
{
foreach ($this->getVersions() as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return true;
}
}
}
return false;
}
/**
* Retrieves the last version of the stored object that was kept before conversion.
*
* This method iterates through the ordered versions and their respective points
* in time to find the most recent version that has a point in time with the reason
* 'KEEP_BEFORE_CONVERSION'.
*
* @return StoredObjectVersion|null the version that was kept before conversion,
* or null if not found
*/
public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion
{
foreach ($this->getVersionsOrdered('DESC') as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return $version;
}
}
}
return null;
}
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
{
$this->template = $template;

View File

@@ -23,7 +23,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository, Associat
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $em)
public function __construct(private readonly EntityManagerInterface $em)
{
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
}

View File

@@ -1,27 +0,0 @@
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import {createApp} from "vue";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {defineComponent} from "vue";
import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll<HTMLDivElement>('div[data-download-button-single]').forEach((el) => {
const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject;
const title = el.dataset.title as string;
const app = createApp({
components: {DownloadButton},
data() {
return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}};
},
template: '<download-button :stored-object="storedObject" :at-version="storedObject.currentVersion" :classes="classes" :filename="title" :direct-download="true"></download-button>',
});
app.use(i18n).use(ToastPlugin).mount(el);
});
});

View File

@@ -2,7 +2,6 @@ import {
DateTime,
User,
} from "../../../ChillMainBundle/Resources/public/types";
import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers";
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
@@ -31,7 +30,6 @@ export interface StoredObject {
href: string;
expiration: number;
};
downloadLink?: SignedUrlGet;
};
}

View File

@@ -1,5 +1,5 @@
<template>
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open()" title="T&#233;l&#233;charger">
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)" title="T&#233;l&#233;charger">
<i class="fa fa-download"></i>
<template v-if="displayActionStringInButton">Télécharger</template>
</a>
@@ -20,15 +20,7 @@ interface DownloadButtonConfig {
atVersion: StoredObjectVersion,
classes: { [k: string]: boolean },
filename?: string,
/**
* if true, display the action string into the button. If false, displays only
* the icon
*/
displayActionStringInButton?: boolean,
/**
* if true, will download directly the file on load
*/
directDownload?: boolean,
displayActionStringInButton: boolean,
}
interface DownloadButtonState {
@@ -37,17 +29,13 @@ interface DownloadButtonState {
href_url: string,
}
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true, directDownload: false});
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true});
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
const open_button = ref<HTMLAnchorElement | null>(null);
function buildDocumentName(): string {
let document_name = props.filename ?? props.storedObject.title;
if ('' === document_name) {
document_name = 'document';
}
const document_name = props.filename ?? props.storedObject.title ?? 'document';
const ext = mime.getExtension(props.atVersion.type);
@@ -58,7 +46,9 @@ function buildDocumentName(): string {
return document_name;
}
async function download_and_open(): Promise<void> {
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
if (state.is_running) {
console.log('state is running, aborting');
return;
@@ -85,13 +75,11 @@ async function download_and_open(): Promise<void> {
state.is_running = false;
state.is_ready = true;
if (!props.directDownload) {
await nextTick();
open_button.value?.click();
await nextTick();
open_button.value?.click();
console.log('open button should have been clicked');
console.log('open button should have been clicked');
setTimeout(reset_state, 45000);
}
const timer = setTimeout(reset_state, 45000);
}
function reset_state(): void {
@@ -99,19 +87,10 @@ function reset_state(): void {
state.is_ready = false;
state.is_running = false;
}
onMounted(() => {
if (props.directDownload) {
download_and_open();
}
});
</script>
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
i.fa {
margin-right: 0.5rem;
}
</style>

View File

@@ -50,6 +50,7 @@ const onRestored = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTim
:version="v"
:can-edit="canEdit"
:is-current="higher_version === v.version"
:is-restored="v.version === state.restored"
:stored-object="storedObject"
@restore-version="onRestored"
></history-button-list-item>

View File

@@ -12,6 +12,7 @@ interface HistoryButtonListItemConfig {
storedObject: StoredObject;
canEdit: boolean;
isCurrent: boolean;
isRestored: boolean;
}
const emit = defineEmits<{
@@ -30,9 +31,7 @@ const isKeptBeforeConversion = computed<boolean>(() => props.version["point-in-t
),
);
const isRestored = computed<boolean>(() => props.version.version > 0 && null !== props.version["from-restored"]);
const isDuplicated = computed<boolean>(() => props.version.version === 0 && null !== props.version["from-restored"]);
const isRestored = computed<boolean>(() => null !== props.version["from-restored"]);
const classes = computed<{row: true, 'row-hover': true, 'blinking-1': boolean, 'blinking-2': boolean}>(() => ({row: true, 'row-hover': true, 'blinking-1': props.isRestored && 0 === props.version.version % 2, 'blinking-2': props.isRestored && 1 === props.version.version % 2}));
@@ -40,14 +39,13 @@ const classes = computed<{row: true, 'row-hover': true, 'blinking-1': boolean, '
<template>
<div :class="classes">
<div class="col-12 tags" v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated">
<div class="col-12 tags" v-if="isCurrent || isKeptBeforeConversion || isRestored">
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion">Conservée avant conversion dans un autre format</span>
<span class="badge bg-info" v-if="isRestored">Restaurée depuis la version {{ version["from-restored"]?.version + 1 }}</span>
<span class="badge bg-info" v-if="isDuplicated">Dupliqué depuis un autre document</span>
<span class="badge bg-info" v-if="isRestored">Restaurée depuis la version {{ version["from-restored"]?.version }}</span>
</div>
<div class="col-12">
<file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <template v-if="version.createdBy !== null && version.createdAt !== null"><strong v-if="version.version == 0">Créé par</strong><strong v-else>modifié par</strong> <span class="badge-user"><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge></span> <strong>à</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template><template v-if="version.createdBy === null && version.createdAt !== null"><strong v-if="version.version == 0">Créé le</strong><strong v-else>modifié le</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template>
<file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <strong v-if="version.version == 0">Créé par</strong><strong v-else>modifié par</strong> <span class="badge-user"><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge></span> <strong>à</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}
</div>
<div class="col-12">
<ul class="record_actions small slim on-version-actions">

View File

@@ -22,6 +22,7 @@ const props = defineProps<HistoryButtonListConfig>();
const state = reactive<HistoryButtonModalState>({opened: false});
const open = () => {
console.log('open');
state.opened = true;
}

View File

@@ -161,14 +161,7 @@ async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: n
throw new Error("no version associated to stored object");
}
// sometimes, the downloadInfo may be embedded into the storedObject
console.log('storedObject', storedObject);
let downloadInfo;
if (typeof storedObject._links !== 'undefined' && typeof storedObject._links.downloadLink !== 'undefined') {
downloadInfo = storedObject._links.downloadLink;
} else {
downloadInfo = await download_info_link(storedObject, atVersionToDownload);
}
const downloadInfo= await download_info_link(storedObject, atVersionToDownload);
const rawResponse = await window.fetch(downloadInfo.url);

View File

@@ -1 +0,0 @@
<div data-download-button-single="data-download-button-single" data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-title="{{ title|escape('html_attr') }}"></div>

View File

@@ -73,15 +73,8 @@
<li>
{{ document.object|chill_document_button_group(document.title) }}
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', document.course) %}
<li>
<a href="{{ chill_path_add_return_path('chill_doc_store_accompanying_course_document_duplicate', {'id': document.id}) }}" class="btn btn-duplicate" title="{{ 'Duplicate'|trans|e('html_attr') }}"></a>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
@@ -89,9 +82,9 @@
<a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
<li class="delete">
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% else %}

View File

@@ -1,43 +0,0 @@
{% extends '@ChillMain/Workflow/workflow_view_send_public_layout.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_document_download_button') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_document_download_button') }}
{% endblock %}
{% block title %}{{ 'workflow.public_link.title'|trans }} - {{ title }}{% endblock %}
{% block public_content %}
<h1>{{ 'workflow.public_link.shared_doc'|trans }}</h1>
{% set previous = send.entityWorkflowStepChained.previous %}
{% if previous is not null %}
{% if previous.transitionBy is not null %}
<p>{{ 'workflow.public_link.doc_shared_by_at_explanation'|trans({'byUser': previous.transitionBy|chill_entity_render_string( { 'at_date': previous.transitionAt } ), 'at': previous.transitionAt }) }}</p>
{% else %}
<p>{{ 'workflow.public_link.doc_shared_automatically_at_explanation'|trans({'at': previous.transitionAt}) }}</p>
{% endif %}
{% endif %}
<div class="row">
<div class="col-xs-12 col-sm-6 col-md-4">
<div class="card"">
<div class="card-body">
<h2 class="card-title">{{ title }}</h2>
<h3>{{ 'workflow.public_link.main_document'|trans }}</h3>
<ul class="record_actions slim small">
<li>
{{ storedObject|chill_document_download_only_button(storedObject.title(), false) }}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
@@ -31,17 +30,10 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
{
use NormalizerAwareTrait;
/**
* when added to the groups, a download link is included in the normalization,
* and no webdav links are generated.
*/
public const DOWNLOAD_LINK_ONLY = 'read:download-link-only';
public function __construct(
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
) {}
public function normalize($object, ?string $format = null, array $context = [])
@@ -63,24 +55,6 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
// deprecated property
$datas['creationDate'] = $datas['createdAt'];
if (array_key_exists(AbstractNormalizer::GROUPS, $context)) {
$groupsNormalized = is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]];
} else {
$groupsNormalized = [];
}
if (in_array(self::DOWNLOAD_LINK_ONLY, $groupsNormalized, true)) {
$datas['_permissions'] = [
'canSee' => true,
'canEdit' => false,
];
$datas['_links'] = [
'downloadLink' => $this->normalizer->normalize($this->tempUrlGenerator->generate('GET', $object->getCurrentVersion()->getFilename(), 180), $format, [AbstractNormalizer::GROUPS => ['read']]),
];
return $datas;
}
$canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);

View File

@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
/**
* Class which duplicate a stored object into a new one, recreating a stored object.
*/
class StoredObjectDuplicate
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
public function duplicate(StoredObject|StoredObjectVersion $from, bool $onlyLastKeptBeforeConversionVersion = true): StoredObject
{
$storedObject = $from instanceof StoredObjectVersion ? $from->getStoredObject() : $from;
$fromVersion = match ($storedObject->hasKeptBeforeConversionVersion() && $onlyLastKeptBeforeConversionVersion) {
true => $from->getLastKeptBeforeConversionVersion(),
false => $storedObject->getCurrentVersion(),
};
if (null === $fromVersion) {
throw new \UnexpectedValueException('could not find a version to restore');
}
$oldContent = $this->storedObjectManager->read($fromVersion);
$storedObject = new StoredObject();
$newVersion = $this->storedObjectManager->write($storedObject, $oldContent, $fromVersion->getType());
$newVersion->setCreatedFrom($fromVersion);
$this->logger->info('[StoredObjectDuplicate] Duplicated stored object from a version of a previous stored object', [
'from_stored_object_uuid' => $fromVersion->getStoredObject()->getUuid(),
'to_stored_object_uuid' => $storedObject->getUuid(),
'old_version_id' => $fromVersion->getId(),
'old_version_version' => $fromVersion->getVersion(),
'new_version_id' => $newVersion->getVersion(),
]);
return $storedObject;
}
}

View File

@@ -14,9 +14,6 @@ namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
/**
* Class responsible for restoring stored object versions into the same stored object.
*/
final readonly class StoredObjectRestore implements StoredObjectRestoreInterface
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}

View File

@@ -11,49 +11,25 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Registry;
class WorkflowStoredObjectPermissionHelper
{
public function __construct(
private readonly Security $security,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly Registry $registry,
) {}
public function __construct(private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager) {}
public function notBlockedByWorkflow(object $entity): bool
{
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$workflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$currentUser = $this->security->getUser();
foreach ($entityWorkflows as $entityWorkflow) {
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $active) {
$metadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if ($metadata['isFinalPositive'] ?? true) {
return false;
}
}
} else {
if (!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) {
return false;
}
foreach ($workflows as $workflow) {
if ($workflow->isFinal()) {
return false;
}
// as soon as there is one signatured applyied, we are not able to
// edit the document any more
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return false;
}
}
if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) {
return false;
}
}

View File

@@ -28,10 +28,6 @@ class WopiEditTwigExtension extends AbstractExtension
'needs_environment' => true,
'is_safe' => ['html'],
]),
new TwigFilter('chill_document_download_only_button', [WopiEditTwigExtensionRuntime::class, 'renderDownloadButton'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}

View File

@@ -15,7 +15,6 @@ use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
@@ -178,17 +177,6 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
]);
}
public function renderDownloadButton(Environment $environment, StoredObject $storedObject, string $title = ''): string
{
return $environment->render(
'@ChillDocStore/Button/button_download.html.twig',
[
'document_json' => $this->normalizer->normalize($storedObject, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]]),
'title' => $title,
]
);
}
public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string
{
return $environment->render(self::TEMPLATE, [

View File

@@ -12,8 +12,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
@@ -56,27 +54,4 @@ class StoredObjectTest extends KernelTestCase
self::assertNotSame($firstVersion, $version);
}
public function testHasKeptBeforeConversionVersion(): void
{
$storedObject = new StoredObject();
$version1 = $storedObject->registerVersion();
self::assertFalse($storedObject->hasKeptBeforeConversionVersion());
// add a point in time without the correct version
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BY_USER);
self::assertFalse($storedObject->hasKeptBeforeConversionVersion());
self::assertNull($storedObject->getLastKeptBeforeConversionVersion());
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
self::assertTrue($storedObject->hasKeptBeforeConversionVersion());
// add a second version
$version2 = $storedObject->registerVersion();
new StoredObjectPointInTime($version2, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
self::assertSame($version2, $storedObject->getLastKeptBeforeConversionVersion());
}
}

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Form;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
@@ -133,8 +132,7 @@ class StoredObjectTypeTest extends TypeTestCase
new StoredObjectNormalizer(
$jwtTokenProvider->reveal(),
$urlGenerator->reveal(),
$security->reveal(),
$this->createMock(TempUrlGeneratorInterface::class)
$security->reveal()
),
new StoredObjectDenormalizer($storedObjectRepository->reveal()),
new StoredObjectVersionNormalizer(),

View File

@@ -11,8 +11,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
@@ -72,9 +70,7 @@ class StoredObjectNormalizerTest extends TestCase
return ['sub' => 'sub'];
});
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security);
$normalizer->setNormalizer($globalNormalizer);
$actual = $normalizer->normalize($storedObject, 'json');
@@ -99,48 +95,4 @@ class StoredObjectNormalizerTest extends TestCase
self::assertArrayHasKey('dav_link', $actual['_links']);
self::assertEqualsCanonicalizing(['href' => $davLink, 'expiration' => $d->getTimestamp()], $actual['_links']['dav_link']);
}
public function testWithDownloadLinkOnly(): void
{
$storedObject = new StoredObject();
$storedObject->registerVersion();
$storedObject->setTitle('test');
$reflection = new \ReflectionClass(StoredObject::class);
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($storedObject, 1);
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
$jwtProvider->expects($this->never())->method('createToken')->withAnyParameters();
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(Security::class);
$security->expects($this->never())->method('isGranted');
$globalNormalizer = $this->createMock(NormalizerInterface::class);
$globalNormalizer->expects($this->exactly(4))->method('normalize')
->withAnyParameters()
->willReturnCallback(function (?object $object, string $format, array $context) {
if (null === $object) {
return null;
}
return ['sub' => 'sub'];
});
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$tempUrlGenerator->expects($this->once())->method('generate')->with('GET', $storedObject->getCurrentVersion()->getFilename(), $this->isType('int'))
->willReturn(new SignedUrl('GET', 'https://some-link/test', new \DateTimeImmutable('300 seconds'), $storedObject->getCurrentVersion()->getFilename()));
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer->setNormalizer($globalNormalizer);
$actual = $normalizer->normalize($storedObject, 'json', ['groups' => ['read', 'read:download-link-only']]);
self::assertIsArray($actual);
self::assertArrayHasKey('_links', $actual);
self::assertArrayHasKey('downloadLink', $actual['_links']);
self::assertEquals(['sub' => 'sub'], $actual['_links']['downloadLink']);
}
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectDuplicateTest extends TestCase
{
use ProphecyTrait;
public function testDuplicateHappyScenario(): void
{
$storedObject = new StoredObject();
// we create multiple version, we want the last to be duplicated
$storedObject->registerVersion(type: 'application/test');
$version = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->createMock(StoredObjectManagerInterface::class);
$manager->method('read')->with($version)->willReturn('1234');
$manager
->expects($this->once())
->method('write')
->with($this->isInstanceOf(StoredObject::class), '1234', 'application/test')
->willReturnCallback(fn (StoredObject $so, $content, $type) => $so->registerVersion(type: $type));
$storedObjectDuplicate = new StoredObjectDuplicate($manager, new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version, $actual->getCurrentVersion()->getCreatedFrom());
}
public function testDuplicateWithKeptVersion(): void
{
$storedObject = new StoredObject();
// we create two versions for stored object
// the first one is "kept before conversion", and that one should
// be duplicated, not the second one
$version1 = $storedObject->registerVersion(type: $type = 'application/test');
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version2 = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->prophesize(StoredObjectManagerInterface::class);
// we create both possibilities for the method "read"
$manager->read($version1)->willReturn('1234');
$manager->read($version2)->willReturn('4567');
// we create the write method, and check that it is called with the content from version1, not version2
$manager->write(Argument::type(StoredObject::class), '1234', 'application/test')
->shouldBeCalled()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0]; // args are ordered by key, so the first one is the stored object...
$type = $args[2]; // and the last one is the string $type
return $storedObject->registerVersion(type: $type);
});
// we create the service which will duplicate things
$storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version1, $actual->getCurrentVersion()->getCreatedFrom());
}
public function testDuplicateWithKeptVersionButWeWantToDuplicateTheLastOne(): void
{
$storedObject = new StoredObject();
// we create two versions for stored object
// the first one is "kept before conversion", and that one should
// be duplicated, not the second one
$version1 = $storedObject->registerVersion(type: $type = 'application/test');
new StoredObjectPointInTime($version1, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version2 = $storedObject->registerVersion(type: $type = 'application/test');
$manager = $this->prophesize(StoredObjectManagerInterface::class);
// we create both possibilities for the method "read"
$manager->read($version1)->willReturn('1234');
$manager->read($version2)->willReturn('4567');
// we create the write method, and check that it is called with the content from version1, not version2
$manager->write(Argument::type(StoredObject::class), '4567', 'application/test')
->shouldBeCalled()
->will(function ($args) {
/** @var StoredObject $storedObject */
$storedObject = $args[0]; // args are ordered by key, so the first one is the stored object...
$type = $args[2]; // and the last one is the string $type
return $storedObject->registerVersion(type: $type);
});
// we create the service which will duplicate things
$storedObjectDuplicate = new StoredObjectDuplicate($manager->reveal(), new NullLogger());
$actual = $storedObjectDuplicate->duplicate($storedObject, false);
self::assertNotNull($actual->getCurrentVersion());
self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom());
self::assertSame($version2, $actual->getCurrentVersion()->getCreatedFrom());
}
}

View File

@@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Service;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Security;
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\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class WorkflowStoredObjectPermissionHelperTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataNotBlockByWorkflow
*/
public function testNotBlockByWorkflow(EntityWorkflow $entityWorkflow, User $user, bool $expected, string $message): void
{
// all entities must have this workflow name, so we are ok to set it here
$entityWorkflow->setWorkflowName('dummy');
$object = new \stdClass();
$helper = $this->buildHelper($object, $entityWorkflow, $user);
self::assertEquals($expected, $helper->notBlockedByWorkflow($entityWorkflow), $message);
}
private function buildHelper(object $relatedEntity, EntityWorkflow $entityWorkflow, User $user): WorkflowStoredObjectPermissionHelper
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([$entityWorkflow]);
return new WorkflowStoredObjectPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry());
}
public static function provideDataNotBlockByWorkflow(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
yield [$entityWorkflow, new User(), false, 'blocked 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(), $user);
yield [$entityWorkflow, $user, true, 'allowed because the user is present as a dest user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), $user);
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [$entityWorkflow, $user, false, 'blocked because the step is final, and final positive'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user);
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [$entityWorkflow, $user, true, 'allowed because the step is final, and final negative'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user);
$step = $entityWorkflow->getCurrentStep();
new EntityWorkflowStepSignature($step, new Person());
yield [$entityWorkflow, $user, true, 'allow, a signature is present but still pending'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user);
$step = $entityWorkflow->getCurrentStep();
$signature = new EntityWorkflowStepSignature($step, new Person());
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user);
$step = $entityWorkflow->getCurrentStep();
$signature = new EntityWorkflowStepSignature($step, new Person());
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed, although the workflow is final negative'];
}
private static function buildRegistry(): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces(['initial'])
->addPlaces(['initial', 'test', 'final_positive', 'final_negative'])
->setMetadataStore(
new InMemoryMetadataStore(
placesMetadata: [
'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

@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\DocumentCategory;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class AccompanyingCourseDocumentDuplicatorTest extends TestCase
{
public function testDuplicate(): void
{
$object = new StoredObject();
$document = new AccompanyingCourseDocument();
$document
->setDate($date = new \DateTimeImmutable())
->setObject($object)
->setTitle('Title')
->setUser($user = new User())
->setCategory($category = new DocumentCategory('bundle', 10))
->setDescription($description = 'Description');
$actual = $this->buildDuplicator()->duplicate($document);
self::assertSame($date, $actual->getDate());
// FYI, the duplication of object is checked by the mock
self::assertNotNull($actual->getObject());
self::assertStringStartsWith('Title', $actual->getTitle());
self::assertSame($user, $actual->getUser());
self::assertSame($category, $actual->getCategory());
self::assertEquals($description, $actual->getDescription());
}
private function buildDuplicator(): AccompanyingCourseDocumentDuplicator
{
$storedObjectDuplicate = $this->createMock(StoredObjectDuplicate::class);
$storedObjectDuplicate->expects($this->once())->method('duplicate')
->with($this->isInstanceOf(StoredObject::class))->willReturn(new StoredObject());
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->withAnyParameters()->willReturn('duplicated');
$clock = new MockClock();
return new AccompanyingCourseDocumentDuplicator(
$storedObjectDuplicate,
$translator,
$clock
);
}
}

View File

@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentWorkflowHandler;
use Chill\DocStoreBundle\Workflow\WorkflowWithPublicViewDocumentHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvidePersonsAssociated;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvideThirdPartiesAssociated;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class AccompanyingCourseDocumentWorkflowHandlerTest extends TestCase
{
use ProphecyTrait;
public function testGetSuggestedUsers()
{
$accompanyingPeriod = new AccompanyingPeriod();
$document = new AccompanyingCourseDocument();
$document->setCourse($accompanyingPeriod)->setUser($user1 = new User());
$accompanyingPeriod->setUser($user = new User());
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setRelatedEntityId(1);
$handler = new AccompanyingCourseDocumentWorkflowHandler(
$this->prophesize(TranslatorInterface::class)->reveal(),
$this->prophesize(EntityWorkflowRepository::class)->reveal(),
$this->buildRepository($document, 1),
new WorkflowWithPublicViewDocumentHelper($this->prophesize(Environment::class)->reveal()),
$this->prophesize(ProvideThirdPartiesAssociated::class)->reveal(),
$this->prophesize(ProvidePersonsAssociated::class)->reveal(),
);
$users = $handler->getSuggestedUsers($entityWorkflow);
self::assertCount(2, $users);
self::assertContains($user, $users);
self::assertContains($user1, $users);
}
public function testGetSuggestedUsersWithDuplicates()
{
$accompanyingPeriod = new AccompanyingPeriod();
$document = new AccompanyingCourseDocument();
$document->setCourse($accompanyingPeriod)->setUser($user1 = new User());
$accompanyingPeriod->setUser($user1);
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setRelatedEntityId(1);
$handler = new AccompanyingCourseDocumentWorkflowHandler(
$this->prophesize(TranslatorInterface::class)->reveal(),
$this->prophesize(EntityWorkflowRepository::class)->reveal(),
$this->buildRepository($document, 1),
new WorkflowWithPublicViewDocumentHelper($this->prophesize(Environment::class)->reveal()),
$this->prophesize(ProvideThirdPartiesAssociated::class)->reveal(),
$this->prophesize(ProvidePersonsAssociated::class)->reveal(),
);
$users = $handler->getSuggestedUsers($entityWorkflow);
self::assertCount(1, $users);
self::assertContains($user1, $users);
}
private function buildRepository(AccompanyingCourseDocument $document, int $id): AccompanyingCourseDocumentRepository
{
$repository = $this->prophesize(AccompanyingCourseDocumentRepository::class);
$repository->find($id)->willReturn($document);
return $repository->reveal();
}
}

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Stores the logic to duplicate an AccompanyingCourseDocument associated to a workflow.
*/
class AccompanyingCourseDocumentDuplicator
{
public function __construct(
private readonly StoredObjectDuplicate $storedObjectDuplicate,
private readonly TranslatorInterface $translator,
private readonly ClockInterface $clock,
) {}
public function duplicate(AccompanyingCourseDocument $document): AccompanyingCourseDocument
{
$newDoc = new AccompanyingCourseDocument();
$newDoc
->setCourse($document->getCourse())
->setTitle($document->getTitle().' ('.$this->translator->trans('acc_course_document.duplicated_at', ['at' => $this->clock->now()]).')')
->setDate($document->getDate())
->setDescription($document->getDescription())
->setCategory($document->getCategory())
->setUser($document->getUser())
->setObject($this->storedObjectDuplicate->duplicate($document->getObject()))
;
return $newDoc;
}
}

View File

@@ -16,28 +16,20 @@ use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowWithPublicViewInterface;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\MainBundle\Workflow\Templating\EntityWorkflowViewMetadataDTO;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvideThirdPartiesAssociated;
use Chill\PersonBundle\Service\AccompanyingPeriod\ProvidePersonsAssociated;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @implements EntityWorkflowWithStoredObjectHandlerInterface<AccompanyingCourseDocument>
*/
final readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface, EntityWorkflowWithPublicViewInterface
readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface
{
public function __construct(
private TranslatorInterface $translator,
private EntityWorkflowRepository $workflowRepository,
private AccompanyingCourseDocumentRepository $repository,
private WorkflowWithPublicViewDocumentHelper $publicViewDocumentHelper,
private ProvideThirdPartiesAssociated $thirdPartiesAssociated,
private ProvidePersonsAssociated $providePersonsAssociated,
) {}
public function getDeletionRoles(): array
@@ -95,28 +87,12 @@ final readonly class AccompanyingCourseDocumentWorkflowHandler implements Entity
public function getSuggestedUsers(EntityWorkflow $entityWorkflow): array
{
$related = $this->getRelatedEntity($entityWorkflow);
$suggestedUsers = $entityWorkflow->getUsersInvolved();
if (null === $related) {
return [];
}
$referrer = $this->getRelatedEntity($entityWorkflow)->getCourse()->getUser();
$suggestedUsers[spl_object_hash($referrer)] = $referrer;
$users = [];
if (null !== $user = $related->getUser()) {
$users[] = $user;
}
if (null !== $user = $related->getCourse()->getUser()) {
$users[] = $user;
}
return array_values(
// filter objects to remove duplicates
array_filter(
$users,
fn ($o, $k) => array_search($o, $users, true) === $k,
ARRAY_FILTER_USE_BOTH
)
);
return $suggestedUsers;
}
public function getTemplate(EntityWorkflow $entityWorkflow, array $options = []): string
@@ -160,31 +136,4 @@ final readonly class AccompanyingCourseDocumentWorkflowHandler implements Entity
return $this->workflowRepository->findByRelatedEntity(AccompanyingCourseDocument::class, $object->getId());
}
public function renderPublicView(EntityWorkflowSend $entityWorkflowSend, EntityWorkflowViewMetadataDTO $metadata): string
{
return $this->publicViewDocumentHelper->render($entityWorkflowSend, $metadata, $this);
}
public function getSuggestedPersons(EntityWorkflow $entityWorkflow): array
{
$related = $this->getRelatedEntity($entityWorkflow);
if (null === $related) {
return [];
}
return $this->providePersonsAssociated->getPersonsAssociated($related->getCourse());
}
public function getSuggestedThirdParties(EntityWorkflow $entityWorkflow): array
{
$related = $this->getRelatedEntity($entityWorkflow);
if (null === $related) {
return [];
}
return $this->thirdPartiesAssociated->getThirdPartiesAssociated($related->getCourse());
}
}

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\MainBundle\Workflow\Templating\EntityWorkflowViewMetadataDTO;
use Twig\Environment;
class WorkflowWithPublicViewDocumentHelper
{
public function __construct(private readonly Environment $twig) {}
public function render(EntityWorkflowSend $send, EntityWorkflowViewMetadataDTO $metadata, EntityWorkflowHandlerInterface&EntityWorkflowWithStoredObjectHandlerInterface $handler): string
{
$entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow();
$storedObject = $handler->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
return 'document removed';
}
$title = $handler->getEntityTitle($entityWorkflow);
return $this->twig->render(
'@ChillDocStore/Workflow/public_view_with_document_render.html.twig',
[
'title' => $title,
'storedObject' => $storedObject,
'send' => $send,
'metadata' => $metadata,
]
);
}
}

View File

@@ -5,6 +5,5 @@ module.exports = function(encore)
});
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts');
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
encore.addEntry('mod_document_download_button', __dirname + '/Resources/public/module/button_download/index');
encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index');
encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index.ts');
};

View File

@@ -1,11 +0,0 @@
acc_course_document:
duplicated_at: >-
Dupliqué le {at, date, long} à {at, time, short}
workflow:
public_link:
doc_shared_by_at_explanation: >-
Le document a été partagé avec vous par {byUser}, le {at, date, long} à {at, time, short}.
doc_shared_automatically_at_explanation: >-
Le document a été partagé avec vous le {at, date, long} à {at, time, short}

View File

@@ -80,10 +80,6 @@ online_edit_document: Éditer en ligne
workflow:
Document deleted: Document supprimé
public_link:
shared_doc: Document partagé
title: Document partagé
main_document: Document principal
# ROLES
accompanyingCourseDocument: Documents dans les parcours d'accompagnement

View File

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

View File

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

View File

@@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Repository\UserGroupRepositoryInterface;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
use Chill\MainBundle\Security\Authorization\UserGroupVoter;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Translation\TranslatableMessage;
use Twig\Environment;
/**
* Controller to see and manage user groups.
*/
final readonly class UserGroupController
{
public function __construct(
private UserGroupRepositoryInterface $userGroupRepository,
private Security $security,
private PaginatorFactoryInterface $paginatorFactory,
private Environment $twig,
private FormFactoryInterface $formFactory,
private ChillUrlGeneratorInterface $chillUrlGenerator,
private EntityManagerInterface $objectManager,
private ChillEntityRenderManagerInterface $chillEntityRenderManager,
) {}
#[Route('/{_locale}/main/user-groups/my', name: 'chill_main_user_groups_my')]
public function myUserGroups(): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$nb = $this->userGroupRepository->countByUser($user);
$paginator = $this->paginatorFactory->create($nb);
$groups = $this->userGroupRepository->findByUser($user, true, $paginator->getItemsPerPage(), $paginator->getCurrentPageFirstItemNumber());
$forms = new \SplObjectStorage();
foreach ($groups as $group) {
$forms->attach($group, $this->createFormAppendUserForGroup($group)?->createView());
}
return new Response($this->twig->render('@ChillMain/UserGroup/my_user_groups.html.twig', [
'groups' => $groups,
'paginator' => $paginator,
'forms' => $forms,
]));
}
#[Route('/{_locale}/main/user-groups/{id}/append', name: 'chill_main_user_groups_append_users')]
public function appendUsersToGroup(UserGroup $userGroup, Request $request, Session $session): Response
{
if (!$this->security->isGranted(UserGroupVoter::APPEND_TO_GROUP, $userGroup)) {
throw new AccessDeniedHttpException();
}
$form = $this->createFormAppendUserForGroup($userGroup);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
foreach ($form['users']->getData() as $user) {
$userGroup->addUser($user);
$session->getFlashBag()->add(
'success',
new TranslatableMessage(
'user_group.user_added',
[
'user_group' => $this->chillEntityRenderManager->renderString($userGroup, []),
'user' => $this->chillEntityRenderManager->renderString($user, []),
]
)
);
}
$this->objectManager->flush();
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_user_groups_my')
);
}
if ($form->isSubmitted()) {
$errors = [];
foreach ($form->getErrors() as $error) {
$errors[] = $error->getMessage();
}
return new Response(implode(', ', $errors));
}
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_user_groups_my')
);
}
/**
* @ParamConverter("user", class=User::class, options={"id" = "userId"})
*/
#[Route('/{_locale}/main/user-group/{id}/user/{userId}/remove', name: 'chill_main_user_groups_remove_user')]
public function removeUserToGroup(UserGroup $userGroup, User $user, Session $session): Response
{
if (!$this->security->isGranted(UserGroupVoter::APPEND_TO_GROUP, $userGroup)) {
throw new AccessDeniedHttpException();
}
$userGroup->removeUser($user);
$this->objectManager->flush();
$session->getFlashBag()->add(
'success',
new TranslatableMessage(
'user_group.user_removed',
[
'user_group' => $this->chillEntityRenderManager->renderString($userGroup, []),
'user' => $this->chillEntityRenderManager->renderString($user, []),
]
)
);
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_user_groups_my')
);
}
private function createFormAppendUserForGroup(UserGroup $group): ?FormInterface
{
if (!$this->security->isGranted(UserGroupVoter::APPEND_TO_GROUP, $group)) {
return null;
}
$builder = $this->formFactory->createBuilder(FormType::class, ['users' => []], [
'action' => $this->chillUrlGenerator->generateWithReturnPath('chill_main_user_groups_append_users', ['id' => $group->getId()]),
]);
$builder->add('users', PickUserDynamicType::class, [
'submit_on_adding_new_entity' => true,
'label' => 'user_group.append_users',
'mapped' => false,
'multiple' => true,
]);
return $builder->getForm();
}
}

View File

@@ -71,7 +71,7 @@ final readonly class WorkflowAddSignatureController
return new Response(
$this->twig->render(
'@ChillMain/Workflow/signature_sign.html.twig',
'@ChillMain/Workflow/_signature_sign.html.twig',
['signature' => $signatureClient]
)
);

View File

@@ -300,12 +300,19 @@ class WorkflowController extends AbstractController
if (\count($workflow->getEnabledTransitions($entityWorkflow)) > 0) {
// possible transition
$stepDTO = new WorkflowTransitionContextDTO($entityWorkflow);
$usersInvolved = $entityWorkflow->getUsersInvolved();
$currentUserFound = array_search($this->security->getUser(), $usersInvolved, true);
if (false !== $currentUserFound) {
unset($usersInvolved[$currentUserFound]);
}
$transitionForm = $this->createForm(
WorkflowStepType::class,
$stepDTO,
[
'entity_workflow' => $entityWorkflow,
'suggested_users' => $usersInvolved,
]
);
@@ -423,7 +430,7 @@ class WorkflowController extends AbstractController
}
return $this->render(
'@ChillMain/Workflow/signature_metadata.html.twig',
'@ChillMain/Workflow/_signature_metadata.html.twig',
[
'metadata_form' => $metadataForm->createView(),
'person' => $signature->getSigner(),

View File

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

View File

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

View File

@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\DataFixtures\ORM;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
class LoadUserGroup extends Fixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return ['user-group'];
}
public function load(ObjectManager $manager)
{
$centerASocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_social']);
$centerBSocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_social']);
$multiCenter = $manager->getRepository(User::class)->findOneBy(['username' => 'multi_center']);
$administrativeA = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_administrative']);
$administrativeB = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_administrative']);
$level1 = $this->generateLevelGroup('Niveau 1', '#eec84aff', '#000000ff', 'level');
$level1->addUser($centerASocial)->addUser($centerBSocial);
$manager->persist($level1);
$level2 = $this->generateLevelGroup('Niveau 2', ' #e2793dff', '#000000ff', 'level');
$level2->addUser($multiCenter);
$manager->persist($level2);
$level3 = $this->generateLevelGroup('Niveau 3', ' #df4949ff', '#000000ff', 'level');
$level3->addUser($multiCenter);
$manager->persist($level3);
$tss = $this->generateLevelGroup('Travailleur sociaux', '#43b29dff', '#000000ff', '');
$tss->addUser($multiCenter)->addUser($centerASocial)->addUser($centerBSocial);
$manager->persist($tss);
$admins = $this->generateLevelGroup('Administratif', '#334d5cff', '#000000ff', '');
$admins->addUser($administrativeA)->addUser($administrativeB);
$manager->persist($admins);
$manager->flush();
}
private function generateLevelGroup(string $title, string $backgroundColor, string $foregroundColor, string $excludeKey): UserGroup
{
$userGroup = new UserGroup();
return $userGroup
->setLabel(['fr' => $title])
->setBackgroundColor($backgroundColor)
->setForegroundColor($foregroundColor)
->setExcludeKey($excludeKey)
;
}
}

View File

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

View File

@@ -1,244 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_user_group')]
// this discriminator key is required for automated denormalization
#[DiscriminatorMap('type', mapping: ['user_group' => UserGroup::class])]
class UserGroup
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
private array $label = [];
/**
* @var Collection<int, User>&Selectable<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_user_group_user')]
private Collection&Selectable $users;
/**
* @var Collection<int, User>&Selectable<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_user_group_user_admin')]
private Collection&Selectable $adminUsers;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#ffffffff'])]
private string $backgroundColor = '#ffffffff';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#000000ff'])]
private string $foregroundColor = '#000000ff';
/**
* Groups with same exclude key are mutually exclusive: adding one in a many-to-one relationship
* will exclude others.
*
* An empty string means "no exclusion"
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $excludeKey = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Assert\Email]
private string $email = '';
public function __construct()
{
$this->adminUsers = new ArrayCollection();
$this->users = new ArrayCollection();
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): self
{
$this->active = $active;
return $this;
}
public function addAdminUser(User $user): self
{
if (!$this->adminUsers->contains($user)) {
$this->adminUsers[] = $user;
}
return $this;
}
public function removeAdminUser(User $user): self
{
$this->adminUsers->removeElement($user);
return $this;
}
public function addUser(User $user): self
{
if (!$this->users->contains($user)) {
$this->users[] = $user;
}
return $this;
}
public function removeUser(User $user): self
{
if ($this->users->contains($user)) {
$this->users->removeElement($user);
}
return $this;
}
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): array
{
return $this->label;
}
/**
* @return Selectable<int, User>&Collection<int, User>
*/
public function getUsers(): Collection&Selectable
{
return $this->users;
}
/**
* @return Selectable<int, User>&Collection<int, User>
*/
public function getAdminUsers(): Collection&Selectable
{
return $this->adminUsers;
}
public function getForegroundColor(): string
{
return $this->foregroundColor;
}
public function getExcludeKey(): string
{
return $this->excludeKey;
}
public function getBackgroundColor(): string
{
return $this->backgroundColor;
}
public function setForegroundColor(string $foregroundColor): self
{
$this->foregroundColor = $foregroundColor;
return $this;
}
public function setBackgroundColor(string $backgroundColor): self
{
$this->backgroundColor = $backgroundColor;
return $this;
}
public function setExcludeKey(string $excludeKey): self
{
$this->excludeKey = $excludeKey;
return $this;
}
public function setLabel(array $label): self
{
$this->label = $label;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function hasEmail(): bool
{
return '' !== $this->email;
}
/**
* Checks if the current object is an instance of the UserGroup class.
*
* In use in twig template, to discriminate when there an object can be polymorphic.
*
* @return bool returns true if the current object is an instance of UserGroup, false otherwise
*/
public function isUserGroup(): bool
{
return true;
}
public function contains(User $user): bool
{
return $this->users->contains($user);
}
public function getUserListByLabelAscending(): ReadableCollection
{
$criteria = Criteria::create();
$criteria->orderBy(['label' => Order::Ascending]);
return $this->getUsers()->matching($criteria);
}
public function getAdminUserListByLabelAscending(): ReadableCollection
{
$criteria = Criteria::create();
$criteria->orderBy(['label' => Order::Ascending]);
return $this->getAdminUsers()->matching($criteria);
}
}

View File

@@ -442,18 +442,18 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
$newStep->addCcUser($user);
}
foreach ($transitionContextDTO->getFutureDestUsers() as $user) {
foreach ($transitionContextDTO->futureDestUsers as $user) {
$newStep->addDestUser($user);
}
foreach ($transitionContextDTO->getFutureDestUserGroups() as $userGroup) {
$newStep->addDestUserGroup($userGroup);
}
if (null !== $transitionContextDTO->futureUserSignature) {
$newStep->addDestUser($transitionContextDTO->futureUserSignature);
}
foreach ($transitionContextDTO->futureDestEmails as $email) {
$newStep->addDestEmail($email);
}
if (null !== $transitionContextDTO->futureUserSignature) {
new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature);
} else {
@@ -462,13 +462,6 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
}
}
foreach ($transitionContextDTO->futureDestineeThirdParties as $thirdParty) {
new EntityWorkflowSend($newStep, $thirdParty, $transitionAt->add(new \DateInterval('P30D')));
}
foreach ($transitionContextDTO->futureDestineeEmails as $email) {
new EntityWorkflowSend($newStep, $email, $transitionAt->add(new \DateInterval('P30D')));
}
// copy the freeze
if ($this->isFreeze()) {
$newStep->setFreezeAfter(true);

View File

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

View File

@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity\Workflow;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Register the viewing action from an external destinee.
*/
#[ORM\Entity(readOnly: true)]
#[ORM\Table(name: 'chill_main_workflow_entity_send_views')]
class EntityWorkflowSendView
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: EntityWorkflowSend::class, inversedBy: 'views')]
#[ORM\JoinColumn(nullable: false)]
private EntityWorkflowSend $send,
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeInterface $viewAt,
#[ORM\Column(type: Types::TEXT)]
private string $remoteIp = '',
) {
$this->send->addView($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getRemoteIp(): string
{
return $this->remoteIp;
}
public function getSend(): EntityWorkflowSend
{
return $this->send;
}
public function getViewAt(): \DateTimeInterface
{
return $this->viewAt;
}
}

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -49,13 +48,6 @@ class EntityWorkflowStep
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user')]
private Collection $destUser;
/**
* @var Collection<int, UserGroup>
*/
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user_group')]
private Collection $destUserGroups;
/**
* @var Collection<int, User>
*/
@@ -112,21 +104,13 @@ class EntityWorkflowStep
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)]
private Collection $holdsOnStep;
/**
* @var Collection<int, EntityWorkflowSend>
*/
#[ORM\OneToMany(mappedBy: 'entityWorkflowStep', targetEntity: EntityWorkflowSend::class, cascade: ['persist'], orphanRemoval: true)]
private Collection $sends;
public function __construct()
{
$this->ccUser = new ArrayCollection();
$this->destUser = new ArrayCollection();
$this->destUserGroups = new ArrayCollection();
$this->destUserByAccessKey = new ArrayCollection();
$this->signatures = new ArrayCollection();
$this->holdsOnStep = new ArrayCollection();
$this->sends = new ArrayCollection();
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(32));
}
@@ -139,9 +123,6 @@ class EntityWorkflowStep
return $this;
}
/**
* @deprecated
*/
public function addDestEmail(string $email): self
{
if (!\in_array($email, $this->destEmail, true)) {
@@ -160,22 +141,6 @@ class EntityWorkflowStep
return $this;
}
public function addDestUserGroup(UserGroup $userGroup): self
{
if (!$this->destUserGroups->contains($userGroup)) {
$this->destUserGroups[] = $userGroup;
}
return $this;
}
public function removeDestUserGroup(UserGroup $userGroup): self
{
$this->destUserGroups->removeElement($userGroup);
return $this;
}
public function addDestUserByAccessKey(User $user): self
{
if (!$this->destUserByAccessKey->contains($user) && !$this->destUser->contains($user)) {
@@ -197,18 +162,6 @@ class EntityWorkflowStep
return $this;
}
/**
* @internal use @see{EntityWorkflowSend}'s constructor instead
*/
public function addSend(EntityWorkflowSend $send): self
{
if (!$this->sends->contains($send)) {
$this->sends[] = $send;
}
return $this;
}
public function removeSignature(EntityWorkflowStepSignature $signature): self
{
if ($this->signatures->contains($signature)) {
@@ -225,9 +178,7 @@ class EntityWorkflowStep
/**
* get all the users which are allowed to apply a transition: those added manually, and
* those added automatically by using an access key.
*
* This method exclude the users associated with user groups
* those added automatically bu using an access key.
*
* @psalm-suppress DuplicateArrayKey
*/
@@ -241,14 +192,6 @@ class EntityWorkflowStep
);
}
/**
* @return Collection<int, UserGroup>
*/
public function getDestUserGroups(): Collection
{
return $this->destUserGroups;
}
public function getCcUser(): Collection
{
return $this->ccUser;
@@ -264,11 +207,6 @@ class EntityWorkflowStep
return $this->currentStep;
}
/**
* @return array<string>
*
* @deprecated
*/
public function getDestEmail(): array
{
return $this->destEmail;
@@ -303,14 +241,6 @@ class EntityWorkflowStep
return $this->signatures;
}
/**
* @return Collection<int, EntityWorkflowSend>
*/
public function getSends(): Collection
{
return $this->sends;
}
public function getId(): ?int
{
return $this->id;

View File

@@ -161,16 +161,6 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return EntityWorkflowSignatureStateEnum::PENDING == $this->getState();
}
public function isCanceled(): bool
{
return EntityWorkflowSignatureStateEnum::CANCELED === $this->getState();
}
public function isRejected(): bool
{
return EntityWorkflowSignatureStateEnum::REJECTED === $this->getState();
}
/**
* Checks whether all signatures associated with a given workflow step are not pending.
*

View File

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

View File

@@ -12,8 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\DataTransformer;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Symfony\Component\Form\DataTransformerInterface;
@@ -28,14 +26,10 @@ class EntityToJsonTransformer implements DataTransformerInterface
public function reverseTransform($value)
{
if (false === $this->multiple && '' === $value) {
if ('' === $value) {
return null;
}
if ($this->multiple && [] === $value) {
return [];
}
$denormalized = json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
if ($this->multiple) {
@@ -80,23 +74,15 @@ class EntityToJsonTransformer implements DataTransformerInterface
'user' => User::class,
'person' => Person::class,
'thirdparty' => ThirdParty::class,
'user_group' => UserGroup::class,
'user_group_or_user' => DiscriminatedObjectDenormalizer::TYPE,
default => throw new \UnexpectedValueException('This type is not supported'),
};
$context = [AbstractNormalizer::GROUPS => ['read']];
if ('user_group_or_user' === $this->type) {
$context[DiscriminatedObjectDenormalizer::ALLOWED_TYPES] = [UserGroup::class, User::class];
}
return
$this->denormalizer->denormalize(
['type' => $item['type'], 'id' => $item['id']],
$class,
'json',
$context,
[AbstractNormalizer::GROUPS => ['read']],
);
}
}

View File

@@ -18,30 +18,16 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Pick user dymically, using vuejs module "AddPerson".
*
* Possible options:
*
* - `multiple`: pick one or more users
* - `suggested`: a list of suggested users
* - `suggest_myself`: append the current user to the list of suggested
* - `as_id`: only the id will be set in the returned data
* - `submit_on_adding_new_entity`: the browser will immediately submit the form when new users are checked
*/
class PickUserDynamicType extends AbstractType
{
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly SerializerInterface $serializer,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
) {}
public function __construct(private readonly DenormalizerInterface $denormalizer, private readonly SerializerInterface $serializer, private readonly NormalizerInterface $normalizer) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
@@ -60,12 +46,6 @@ class PickUserDynamicType extends AbstractType
foreach ($options['suggested'] as $user) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
$user = $this->security->getUser();
if ($user instanceof User) {
if (true === $options['suggest_myself'] && !in_array($user, $options['suggested'], true)) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
}
}
public function configureOptions(OptionsResolver $resolver)
@@ -74,8 +54,6 @@ class PickUserDynamicType extends AbstractType
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggest_myself', false)
->setAllowedTypes('suggest_myself', ['bool'])
->setDefault('suggested', [])
// if set to true, only the id will be set inside the content. The denormalization will not work.
->setDefault('as_id', false)

View File

@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\DataTransformer\EntityToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Entity which picks a user **or** a user group.
*/
final class PickUserGroupOrUserDynamicType extends AbstractType
{
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly SerializerInterface $serializer,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(new EntityToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'], 'user_group_or_user'));
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['multiple'] = $options['multiple'];
$view->vars['types'] = ['user-group', 'user'];
$view->vars['uniqid'] = uniqid('pick_usergroup_dyn');
$view->vars['suggested'] = [];
$view->vars['as_id'] = true === $options['as_id'] ? '1' : '0';
$view->vars['submit_on_adding_new_entity'] = true === $options['submit_on_adding_new_entity'] ? '1' : '0';
foreach ($options['suggested'] as $userGroup) {
$view->vars['suggested'][] = $this->normalizer->normalize($userGroup, 'json', ['groups' => 'read']);
}
$user = $this->security->getUser();
if ($user instanceof User) {
if (true === $options['suggest_myself'] && !in_array($user, $options['suggested'], true)) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
}
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggested', [])
->setDefault('suggest_myself', false)
->setAllowedTypes('suggest_myself', ['bool'])
// if set to true, only the id will be set inside the content. The denormalization will not work.
->setDefault('as_id', false)
->setAllowedTypes('as_id', ['bool'])
->setDefault('submit_on_adding_new_entity', false)
->setAllowedTypes('submit_on_adding_new_entity', ['bool']);
}
public function getBlockPrefix()
{
return 'pick_entity_dynamic';
}
}

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ColorType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class UserGroupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('label', TranslatableStringFormType::class, [
'label' => 'user_group.Label',
'required' => true,
])
->add('active')
->add('backgroundColor', ColorType::class, [
'label' => 'user_group.BackgroundColor',
])
->add('foregroundColor', ColorType::class, [
'label' => 'user_group.ForegroundColor',
])
->add('email', EmailType::class, [
'label' => 'user_group.Email',
'help' => 'user_group.EmailHelp',
'empty_data' => '',
])
->add('excludeKey', TextType::class, [
'label' => 'user_group.ExcludeKey',
'help' => 'user_group.ExcludeKeyHelp',
'required' => false,
'empty_data' => '',
])
->add('users', PickUserDynamicType::class, [
'label' => 'user_group.Users',
'multiple' => true,
'required' => false,
'empty_data' => [],
])
->add('adminUsers', PickUserDynamicType::class, [
'label' => 'user_group.adminUsers',
'multiple' => true,
'required' => false,
'empty_data' => [],
'help' => 'user_group.adminUsersHelp',
])
;
}
}

View File

@@ -15,18 +15,19 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Transition;
@@ -35,7 +36,6 @@ class WorkflowStepType extends AbstractType
public function __construct(
private readonly Registry $registry,
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly EntityWorkflowManager $entityWorkflowManager,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
@@ -45,9 +45,6 @@ class WorkflowStepType extends AbstractType
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$place = $workflow->getMarking($entityWorkflow);
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata(array_keys($place->getPlaces())[0]);
$suggestedUsers = $this->entityWorkflowManager->getSuggestedUsers($entityWorkflow);
$suggestedThirdParties = $this->entityWorkflowManager->getSuggestedThirdParties($entityWorkflow);
$suggestedPersons = $this->entityWorkflowManager->getSuggestedPersons($entityWorkflow);
if (null === $options['entity_workflow']) {
throw new \LogicException('if transition is true, entity_workflow should be defined');
@@ -89,6 +86,7 @@ class WorkflowStepType extends AbstractType
$builder
->add('transition', ChoiceType::class, [
'label' => 'workflow.Next step',
'mapped' => false,
'multiple' => false,
'expanded' => true,
'choices' => $choices,
@@ -106,7 +104,6 @@ class WorkflowStepType extends AbstractType
$toFinal = true;
$isForward = 'neutral';
$isSignature = [];
$isSentExternal = false;
$metadata = $workflow->getMetadataStore()->getTransitionMetadata($transition);
@@ -130,8 +127,6 @@ class WorkflowStepType extends AbstractType
if (\array_key_exists('isSignature', $meta)) {
$isSignature = $meta['isSignature'];
}
$isSentExternal = $isSentExternal ? true : $meta['isSentExternal'] ?? false;
}
return [
@@ -139,7 +134,6 @@ class WorkflowStepType extends AbstractType
'data-to-final' => $toFinal ? '1' : '0',
'data-is-forward' => $isForward,
'data-is-signature' => json_encode($isSignature),
'data-is-sent-external' => $isSentExternal ? '1' : '0',
];
},
])
@@ -157,49 +151,39 @@ class WorkflowStepType extends AbstractType
'label' => 'workflow.signature_zone.person signatures',
'multiple' => true,
'empty_data' => '[]',
'suggested' => $suggestedPersons,
])
->add('futureUserSignature', PickUserDynamicType::class, [
'label' => 'workflow.signature_zone.user signature',
'multiple' => false,
'suggest_myself' => true,
'suggested' => $suggestedUsers,
])
->add('futureDestUsers', PickUserGroupOrUserDynamicType::class, [
->add('futureDestUsers', PickUserDynamicType::class, [
'label' => 'workflow.dest for next steps',
'multiple' => true,
'empty_data' => '[]',
'suggested' => $suggestedUsers,
'suggest_myself' => true,
'suggested' => $options['suggested_users'],
])
->add('futureCcUsers', PickUserDynamicType::class, [
'label' => 'workflow.cc for next steps',
'multiple' => true,
'required' => false,
'suggested' => $suggestedUsers,
'suggested' => $options['suggested_users'],
'empty_data' => '[]',
'attr' => ['class' => 'future-cc-users'],
'suggest_myself' => true,
])
->add('futureDestineeEmails', ChillCollectionType::class, [
'entry_type' => EmailType::class,
'entry_options' => [
'empty_data' => '',
],
->add('futureDestEmails', ChillCollectionType::class, [
'label' => 'workflow.dest by email',
'help' => 'workflow.dest by email help',
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => static fn (?string $email) => '' === $email || null === $email,
'button_add_label' => 'workflow.transition_destinee_add_emails',
'button_remove_label' => 'workflow.transition_destinee_remove_emails',
'help' => 'workflow.transition_destinee_emails_help',
'label' => 'workflow.transition_destinee_emails_label',
])
->add('futureDestineeThirdParties', PickThirdpartyDynamicType::class, [
'label' => 'workflow.transition_destinee_third_party',
'help' => 'workflow.transition_destinee_third_party_help',
'multiple' => true,
'empty_data' => [],
'suggested' => $suggestedThirdParties,
'entry_type' => EmailType::class,
'button_add_label' => 'workflow.Add an email',
'button_remove_label' => 'workflow.Remove an email',
'empty_collection_explain' => 'workflow.Any email',
'entry_options' => [
'constraints' => [
new NotNull(), new NotBlank(), new Email(),
],
'label' => 'Email',
],
]);
$builder
@@ -215,6 +199,40 @@ class WorkflowStepType extends AbstractType
$resolver
->setDefault('data_class', WorkflowTransitionContextDTO::class)
->setRequired('entity_workflow')
->setAllowedTypes('entity_workflow', EntityWorkflow::class);
->setAllowedTypes('entity_workflow', EntityWorkflow::class)
->setDefault('suggested_users', [])
->setDefault('constraints', [
new Callback(
function (WorkflowTransitionContextDTO $step, ExecutionContextInterface $context, $payload) {
$workflow = $this->registry->get($step->entityWorkflow, $step->entityWorkflow->getWorkflowName());
$transition = $step->transition;
$toFinal = true;
if (null === $transition) {
$context
->buildViolation('workflow.You must select a next step, pick another decision if no next steps are available');
} else {
foreach ($transition->getTos() as $to) {
$meta = $workflow->getMetadataStore()->getPlaceMetadata($to);
if (
!\array_key_exists('isFinal', $meta) || false === $meta['isFinal']
) {
$toFinal = false;
}
}
$destUsers = $step->futureDestUsers;
$destEmails = $step->futureDestEmails;
if (!$toFinal && [] === $destUsers && [] === $destEmails) {
$context
->buildViolation('workflow.You must add at least one dest user or email')
->atPath('future_dest_users')
->addViolation();
}
}
}
),
]);
}
}

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSendView;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-implements ObjectRepository<EntityWorkflowSendView>
*/
class EntityWorkflowSendViewRepository implements ObjectRepository
{
private readonly ObjectRepository $repository;
public function __construct(ManagerRegistry $registry)
{
$this->repository = $registry->getRepository($this->getClassName());
}
public function find($id): ?EntityWorkflowSendView
{
return $this->repository->find($id);
}
public function findAll()
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?EntityWorkflowSendView
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return EntityWorkflowSendView::class;
}
}

View File

@@ -1,133 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Contracts\Translation\LocaleAwareInterface;
final class UserGroupRepository implements UserGroupRepositoryInterface, LocaleAwareInterface
{
private readonly EntityRepository $repository;
private string $locale;
public function __construct(EntityManagerInterface $em)
{
$this->repository = $em->getRepository(UserGroup::class);
}
public function setLocale(string $locale): void
{
$this->locale = $locale;
}
public function getLocale(): string
{
return $this->locale;
}
public function find($id): ?UserGroup
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?UserGroup
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return UserGroup::class;
}
public function provideSearchApiQuery(string $pattern, string $lang, string $selectKey = 'user-group'): SearchApiQuery
{
$query = new SearchApiQuery();
$query
->setSelectKey($selectKey)
->setSelectJsonbMetadata("jsonb_build_object('id', ug.id)")
->setSelectPertinence('3 + SIMILARITY(LOWER(UNACCENT(?)), ug.label->>?) + CASE WHEN (EXISTS(SELECT 1 FROM unnest(string_to_array(label->>?, \' \')) AS t WHERE LOWER(t) LIKE \'%\' || LOWER(UNACCENT(?)) || \'%\')) THEN 100 ELSE 0 END', [$pattern, $lang, $lang, $pattern])
->setFromClause('chill_main_user_group AS ug')
->setWhereClauses('
ug.active AND (
SIMILARITY(LOWER(UNACCENT(?)), ug.label->>?) > 0.15
OR ug.label->>? LIKE \'%\' || LOWER(UNACCENT(?)) || \'%\')
', [$pattern, $lang, $pattern, $lang]);
return $query;
}
public function findByUser(User $user, bool $onlyActive = true, ?int $limit = null, ?int $offset = null): array
{
$qb = $this->buildQueryByUser($user, $onlyActive);
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
// ordering thing
$qb->addSelect('JSON_EXTRACT(ug.label, :lang) AS HIDDEN label_ordering')
->addOrderBy('label_ordering', 'ASC')
->setParameter('lang', $this->getLocale());
return $qb->getQuery()->getResult();
}
public function countByUser(User $user, bool $onlyActive = true): int
{
$qb = $this->buildQueryByUser($user, $onlyActive);
$qb->select('count(ug)');
return $qb->getQuery()->getSingleScalarResult();
}
private function buildQueryByUser(User $user, bool $onlyActive): \Doctrine\ORM\QueryBuilder
{
$qb = $this->repository->createQueryBuilder('ug');
$qb->where(
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'ug.users'),
$qb->expr()->isMemberOf(':user', 'ug.adminUsers')
)
);
$qb->setParameter('user', $user);
if ($onlyActive) {
$qb->andWhere(
$qb->expr()->eq('ug.active', ':active')
);
$qb->setParameter('active', true);
}
return $qb;
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-extends ObjectRepository<UserGroup>
*/
interface UserGroupRepositoryInterface extends ObjectRepository
{
/**
* Provide a SearchApiQuery for searching amongst user groups.
*/
public function provideSearchApiQuery(string $pattern, string $lang, string $selectKey = 'user-group'): SearchApiQuery;
public function findByUser(User $user, bool $onlyActive = true, ?int $limit = null, ?int $offset = null): array;
public function countByUser(User $user, bool $onlyActive = true): int;
}

View File

@@ -12,9 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -205,29 +203,27 @@ class EntityWorkflowRepository implements ObjectRepository
*
* @param \DateTimeImmutable $olderThanDate the date to compare against
*
* @return iterable<EntityWorkflow>
* @return list<int> the list of workflow IDs that meet the criteria
*/
public function findWorkflowsWithoutFinalStepAndOlderThan(\DateTimeImmutable $olderThanDate): iterable
public function findWorkflowsWithoutFinalStepAndOlderThan(\DateTimeImmutable $olderThanDate): array
{
$qb = $this->repository->createQueryBuilder('sw');
$qb->select('sw')
$qb->select('sw.id')
// only the workflow which are not finalized
->where(sprintf('NOT EXISTS (SELECT 1 FROM %s ews WHERE ews.isFinal = TRUE AND ews.entityWorkflow = sw.id)', EntityWorkflowStep::class))
->where('NOT EXISTS (SELECT 1 FROM chill_main_entity_workflow_step ews WHERE ews.isFinal = TRUE AND ews.entityWorkflow = sw.id)')
->andWhere(
$qb->expr()->orX(
// only the workflow where all the transitions are older than transitionAt
sprintf(':olderThanDate > ALL (SELECT ews2.transitionAt FROM %s ews2 WHERE ews2.transitionAt IS NOT NULL AND ews2.entityWorkflow = sw)', EntityWorkflowStep::class),
// only the workflow where all the last transition is older than transitionAt
':olderThanDate > ALL (SELECT ews.transitionAt FROM chill_main_entity_workflow_step ews WHERE ews.transitionAt IS NOT NULL AND ews.entityWorkflow = sw.id)',
// or the workflow which have only the initial step, with no transition
sprintf('1 = (SELECT COUNT(ews3.id) FROM %s ews3 WHERE ews3.currentStep = :initial AND ews3.transitionAt IS NULL AND ews3.entityWorkflow = sw)', EntityWorkflowStep::class),
'1 = (SELECT COUNT(ews.id) FROM chill_main_entity_workflow_step ews WHERE ews.step = :initial AND ews.transitionAt IS NULL AND ews.createdAt < :olderThanDate AND ews.entityWorkflow = sw.id)',
)
)
->andWhere('sw.createdAt < :olderThanDate')
->setParameter('olderThanDate', $olderThanDate)
->setParameter('initial', 'initial')
;
->setParameter('olderThanDate', $olderThanDate);
return $qb->getQuery()->toIterable();
return $qb->getQuery()->getResult();
}
public function getClassName(): string
@@ -265,7 +261,6 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->isMemberOf(':user', 'step.destUserByAccessKey'),
$qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF step.destUserGroups AND :user MEMBER OF ug.users', UserGroup::class))
),
$qb->expr()->isNull('step.transitionAfter'),
$qb->expr()->eq('step.isFinal', "'FALSE'")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,27 @@
import {ShowHide} from 'ChillMainAssets/lib/show_hide/show_hide.js';
window.addEventListener('DOMContentLoaded', function() {
const
let
divTransitions = document.querySelector('#transitions'),
futureDestUsersContainer = document.querySelector('#futureDests'),
personSignatureField = document.querySelector('#person-signature-field'),
userSignatureField = document.querySelector('#user-signature-field'),
signatureTypeChoices = document.querySelector('#signature-type-choice'),
personChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_0'),
userChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_1'),
signatureZone = document.querySelector('#signature-zone'),
transitionFilterContainer = document.querySelector('#transitionFilter'),
transitionsContainer = document.querySelector('#transitions'),
sendExternalContainer = document.querySelector('#sendExternalContainer')
futureDestUsersContainer = document.querySelector('#futureDests')
personSignatureField = document.querySelector('#person-signature-field');
userSignatureField = document.querySelector('#user-signature-field');
signatureTypeChoices = document.querySelector('#signature-type-choice');
personChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_0');
userChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_1');
signatureZone = document.querySelector('#signature-zone');
;
let
transitionFilterContainer = document.querySelector('#transitionFilter'),
transitionsContainer = document.querySelector('#transitions')
;
// ShowHide instance for signatureTypeChoices. This should always be present in the DOM and we toggle visibility.
// The field is not mapped and so not submitted with the form. Without it's presence upon DOM loading other show hides do not function well.
signatureTypeChoices.style.display = 'none';
// ShowHide instance for future dest users
new ShowHide({
debug: false,
@@ -25,17 +32,15 @@ window.addEventListener('DOMContentLoaded', function() {
for (let transition of froms) {
for (let input of transition.querySelectorAll('input')) {
if (input.checked) {
if ('1' === input.dataset.isSentExternal) {
return false;
}
const inputData = JSON.parse(input.getAttribute('data-is-signature'))
if (inputData.includes('person') || inputData.includes('user')) {
signatureTypeChoices.style.display = '';
return false;
} else {
personChoice.checked = false
userChoice.checked = false
signatureTypeChoices.style.display = 'none';
return true;
}
}
@@ -45,26 +50,6 @@ window.addEventListener('DOMContentLoaded', function() {
}
});
// ShowHide instance for send external
new ShowHide({
debug: false,
load_event: null,
froms: [divTransitions],
container: [sendExternalContainer],
test: function(froms, event) {
for (let transition of froms) {
for (let input of transition.querySelectorAll('input')) {
if (input.checked) {
if ('1' === input.dataset.isSentExternal) {
return true;
}
}
}
}
return false;
}
})
// ShowHide signature zone
new ShowHide({
debug: false,
@@ -77,6 +62,7 @@ window.addEventListener('DOMContentLoaded', function() {
if (input.checked) {
const inputData = JSON.parse(input.getAttribute('data-is-signature'))
if (inputData.includes('person') || inputData.includes('user')) {
signatureTypeChoices.style.display = '';
return true;
}
}

View File

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

View File

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

View File

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

View File

@@ -102,17 +102,11 @@ export default {
},
getPopContent(step) {
if (step.transitionPrevious != null) {
if (step.transitionPreviousBy !== null) {
return `<ul class="small_in_title">
<li><span class="item-key">${i18n.messages.fr.by} : </span><b>${step.transitionPreviousBy.text}</b></li>
<li><span class="item-key">${i18n.messages.fr.at} : </span><b>${this.formatDate(step.transitionPreviousAt.datetime)}</b></li>
</ul>`
;
} else {
return `<ul class="small_in_title">
<li><span class="item-key">${i18n.messages.fr.at} : </span><b>${this.formatDate(step.transitionPreviousAt.datetime)}</b></li>
</ul>`
}
return `<ul class="small_in_title">
<li><span class="item-key">${i18n.messages.fr.by} : </span><b>${step.transitionPreviousBy.text}</b></li>
<li><span class="item-key">${i18n.messages.fr.at} : </span><b>${this.formatDate(step.transitionPreviousAt.datetime)}</b></li>
</ul>`
;
}
},
formatDate(datetime) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<div class="flex-table">
{% for step in entity_workflow.stepsChained %}
{% set place_labels = workflow_metadata(entity_workflow, 'label', step.currentStep, entity_workflow.workflowName) %}
{% set place_labels = workflow_metadata(entity_workflow, 'label', step.currentStep) %}
{% set place_label = place_labels is null ? step.currentStep : place_labels|localize_translatable_string %}
<div class="item-bloc {{ 'bloc' ~ step.id }} {% if loop.first %}initial{% endif %}">
@@ -13,31 +13,31 @@
{{ 'workflow.No transitions'|trans }}
</div>
{% else %}
<div class="item-col">
{% if step.previous is not null and step.previous.freezeAfter == true %}
<i class="fa fa-snowflake-o fa-sm me-1" title="{{ 'workflow.Freezed'|trans }}"></i>
{% endif %}
{% if loop.last %}
{% if entity_workflow.isOnHoldAtCurrentStep %}
{% for hold in step.holdsOnStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold by'|trans({'by': hold.byUser|chill_entity_render_string})|escape('html_attr') }}">{{ 'workflow.On hold by'|trans({'by': hold.byUser|chill_entity_render_string}) }}</span>
{% endfor %}
{% endif %}
{% endif %}
</div>
<div class="item-col flex-column align-items-end">
<div class="decided">
{{ place_label }}
</div>
{#
<div class="decided">
<i class="fa fa-times fa-fw text-danger"></i>
Refusé
</div>
#}
</div>
{% endif %}
</div>
{% if step.next is not null %}
{% set transition = chill_workflow_transition_by_string(step.entityWorkflow, step.transitionAfter) %}
{% set transition_labels = workflow_metadata(step.entityWorkflow, 'label', transition, step.entityWorkflow.workflowName) %}
{% set transition_labels = workflow_metadata(step.entityWorkflow, 'label', transition) %}
{% set transition_label = transition_labels is null ? step.transitionAfter : transition_labels|localize_translatable_string %}
{% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition, step.entityWorkflow.workflowName) %}
{% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition) %}
<div class="item-row separator">
<div class="item-col" style="width: inherit;">
<div>
@@ -71,33 +71,19 @@
</blockquote>
</div>
{% endif %}
{% if not loop.last and step.signatures|length > 0 %}
<div class="separator">
<div>
<p><b>{{ 'workflow.signature_required_title'|trans({'nb_signatures': step.signatures|length}) }}&nbsp;:</b></p>
<div>
{{ include('@ChillMain/Workflow/_signature_list.html.twig', {'signatures': step.signatures, is_small: true }) }}
</div>
</div>
</div>
{% endif %}
{% if loop.last and not step.isFinal %}
{% if loop.last and step.allDestUser|length > 0 %}
<div class="item-row separator">
<div>
{% if step.destUser|length > 0 or step.destUserGroups|length > 0 %}
{% if step.destUser|length > 0 %}
<p><b>{{ 'workflow.Users allowed to apply transition'|trans }}&nbsp;: </b></p>
<ul>
{% for u in step.destUser %}
<li>
<span class="badge-user">{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</span>
{% if step.isOnHoldByUser(u) %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}
{% if entity_workflow.isOnHoldAtCurrentStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %}
</li>
{% endfor %}
{% for u in step.destUserGroups %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</li>
{% endfor %}
</ul>
{% endif %}
@@ -129,17 +115,6 @@
{% endif %}
</div>
</div>
{% if step.signatures|length > 0 %}
<p><b>{{ 'workflow.signature_required_title'|trans({'nb_signatures': step.signatures|length}) }}&nbsp;:</b></p>
{{ include('@ChillMain/Workflow/_signature_list.html.twig', {'signatures': step.signatures, is_small: true }) }}
{% endif %}
{% endif %}
{% if step.sends|length > 0 %}
<div>
<p><b>{{ 'workflow.sent_through_secured_link'|trans }}</b></p>
{{ include('@ChillMain/Workflow/_send_views_list.html.twig', {'sends': step.sends}) }}
</div>
{% endif %}
</div>

View File

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

View File

@@ -1,3 +1,43 @@
<h2>{{ 'workflow.signature_required_title'|trans({'nb_signatures': signatures|length}) }}</h2>
<h2>{{ 'workflow.signature_zone.title'|trans }}</h2>
<div class="container">
{% for s in signatures %}
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
{% if s.signerKind == 'person' %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
action: 'show', displayBadge: true,
targetEntity: { name: 'person', id: s.signer.id },
buttonText: s.signer|chill_entity_render_string,
isDead: s.signer.deathDate is not null
} %}
{% else %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
action: 'show', displayBadge: true,
targetEntity: { name: 'user', id: s.signer.id },
buttonText: s.signer|chill_entity_render_string,
} %}
{% endif %}
</div>
<div class="col-sm-12 col-md-4">
{% if s.isSigned %}
<span class="text-end">{{ 'workflow.signature_zone.has_signed_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% else %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s) %}
<ul class="record_actions slim">
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a>
{% if s.state is same as('signed') %}
<p class="updatedBy">{{ s.stateDate }}</p>
{% endif %}
</li>
</ul>
{% else %}
<span class="text-end">{{ 'workflow.waiting_for_signature'|trans }}</span>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{{ include('@ChillMain/Workflow/_signature_list.html.twig') }}

View File

@@ -1,52 +0,0 @@
<div class="container">
{% for s in signatures %}
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-5">
{% if s.signerKind == 'person' %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
action: 'show', displayBadge: true,
targetEntity: { name: 'person', id: s.signer.id },
buttonText: s.signer|chill_entity_render_string,
isDead: s.signer.deathDate is not null
} %}
{% else %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
action: 'show', displayBadge: true,
targetEntity: { name: 'user', id: s.signer.id },
buttonText: s.signer|chill_entity_render_string,
} %}
{% endif %}
</div>
<div class="col-sm-12 col-md-7 text-end">
{% if s.isSigned %}
<span class="text-end">{{ 'workflow.signature.signed_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% elseif s.isCanceled %}
<span class="text-end">{{ 'workflow.signature.canceled_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% elseif s.isRejected%}
<span class="text-end">{{ 'workflow.signature.rejected_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% else %}
{% if (is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) or is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s)) %}
<ul class="record_actions slim {% if is_small|default(false) %}small{% endif %}">
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_REJECT', s) %}
<li>
<a class="btn btn-remove" href="{{ chill_path_add_return_path('chill_main_workflow_signature_reject', { 'id': s.id}) }}">{{ 'workflow.signature_zone.button_reject'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_CANCEL', s) %}
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_cancel', { 'id': s.id}) }}">{{ 'workflow.signature_zone.button_cancel'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ENTITY_WORKFLOW_SIGNATURE_SIGN', s) %}
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a>
</li>
{% endif %}
</ul>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>

View File

@@ -1,10 +1,12 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title 'workflow.signature_zone.metadata.sign_by'|trans({ '%name%' : person|chill_entity_render_string}) %}
{% block title %}
{{ 'Signature'|trans }}
{% endblock %}
{% block content %}
<div class="col-10 workflow">
<h1 class="mb-5">{{ block('title') }}</h1>
<h1 class="mb-5">{{ 'workflow.signature_zone.metadata.sign_by'|trans({ '%name%' : person.firstname ~ ' ' ~ person.lastname}) }}</h1>
{% if metadata_form is not null %}
{{ form_start(metadata_form) }}

View File

@@ -1,11 +1,12 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title -%}
{% block title %}
{{ 'Workflow'|trans }}
{%- endblock %}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{{ encore_entry_script_tags('mod_entity_workflow_subscribe') }}
{{ encore_entry_script_tags('page_workflow_show') }}
@@ -61,17 +62,26 @@
<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 %}
<section class="step my-4">
<h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2>
{% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %}
</section>
{% else %}
<section class="step my-4">{% include '@ChillMain/Workflow/_decision.html.twig' %}</section>
{% endif %}
{# <section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
<section class="step my-4">{% include '@ChillMain/Workflow/_decision.html.twig' %}</section>{#
<section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
<section class="step my-4">{% include '@ChillMain/Workflow/_history.html.twig' %}</section>
<ul class="record_actions sticky-form-buttons">
{% if entity_workflow.isOnHoldByUser(app.user) %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_remove_hold', {'id': entity_workflow.currentStep.id }) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Remove hold'|trans }}
</a>
</li>
{% else %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_on_hold', {'id': entity_workflow.id}) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Put on hold'|trans }}
</a>
</li>
{% endif %}
</ul>
</div>
{% endblock %}

View File

@@ -44,7 +44,7 @@
{% endif %}
{% if step.previous is not null %}
{% set transition = chill_workflow_transition_by_string(step.entityWorkflow, step.previous.transitionAfter) %}
{% set labels = workflow_metadata(step.entityWorkflow, 'label', transition, step.entityWorkflow.workflowName) %}
{% set labels = workflow_metadata(step.entityWorkflow, 'label', transition) %}
{% set label = labels is null ? step.previous.transitionAfter : labels|localize_translatable_string %}
{{ label }}
{% endif %}
@@ -53,7 +53,7 @@
{% macro breadcrumb(_ctx) %}
<div class="breadcrumb">
{% for step in _ctx.entity_workflow.stepsChained %}
{% set labels = workflow_metadata(_ctx.entity_workflow, 'label', step.currentStep, _ctx.entity_workflow.workflowName) %}
{% set labels = workflow_metadata(_ctx.entity_workflow, 'label', step.currentStep) %}
{% set label = labels is null ? step.currentStep : labels|localize_translatable_string %}
{% set popTitle = _self.popoverTitle(step) %}
{% set popContent = _self.popoverContent(step) %}

View File

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

View File

@@ -0,0 +1,15 @@
Madame, Monsieur,
Un suivi "{{ workflow.text }}" a atteint une nouvelle étape: {{ workflow.text }}.
Titre du workflow: "{{ entityTitle }}".
Vous êtes invité·e à valider cette étape. Pour obtenir un accès, vous pouvez cliquer sur le lien suivant:
{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey, '_locale': fr})) }}
Dès que vous aurez cliqué une fois sur le lien, vous serez autorisé à valider cette étape.
Notez que vous devez disposer d'un compte utilisateur valide dans Chill.
Cordialement,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Routing;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final readonly class ChillUrlGenerator implements ChillUrlGeneratorInterface
{
public function __construct(private UrlGeneratorInterface $urlGenerator, private RequestStack $requestStack) {}
public function generate(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return $this->urlGenerator->generate($name, $parameters, $referenceType);
}
public function generateWithReturnPath(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
$uri = $this->requestStack->getCurrentRequest()->getRequestUri();
return $this->urlGenerator->generate($name, [...$parameters, 'returnPath' => $uri], $referenceType);
}
public function returnPathOr(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
$request = $this->requestStack->getCurrentRequest();
if ($request->query->has('returnPath')) {
return $request->query->get('returnPath');
}
return $this->urlGenerator->generate($name, $parameters, $referenceType);
}
}

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Routing;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Interface for generating URLs with returnPath information.
*/
interface ChillUrlGeneratorInterface
{
/**
* Generate an URL without any return path. This is the same as using @see{UrlGeneratorInterface::generate}.
*/
public function generate(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string;
/**
* Generate an url, append a return path in it.
*/
public function generateWithReturnPath(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string;
/**
* Get the return path or, if any, generate an url.
*/
public function returnPathOr(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string;
}

View File

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

View File

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

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