Compare commits

...

18 Commits

Author SHA1 Message Date
10de431c48 Correct eslint issues 2025-09-29 17:07:21 +02:00
cb173c6341 Remove console.logs 2025-09-29 17:06:05 +02:00
20bbb6b485 Fix test according to Junie guidelines 2025-09-29 16:23:13 +02:00
c731f1967b Change logic for triggering notification when task is edited and assigned a new user 2025-09-29 15:58:36 +02:00
2434d91e4a Fix the notification dashboard widget to include task related notifications 2025-09-29 15:44:49 +02:00
13a4795333 Pipeline fixes 2025-09-29 15:14:34 +02:00
2bb5776002 Add missing parameter in creation of new AssignTaskEvent 2025-09-29 15:14:34 +02:00
34dde37789 Add TaskAssignEventSubscriber unit tests
- Covered `getSubscribedEvents` method behavior
- Added tests for `onTaskAssigned` with changed and unchanged assignees
- Validated notification properties in appropriate scenarios
2025-09-29 15:14:34 +02:00
609b8f9af1 Improve assignee handling and task assignment logic
- Added `hasAssigneeChanged` method for detecting assignee change
- Updated `onTaskAssigned` logic to trigger only on assignee change
2025-09-29 15:14:34 +02:00
57d922c05e Trans task assignment notification email
- Updated `task_assignment_notification_content.txt.twig` with dynamic translations and URLs
2025-09-29 15:14:34 +02:00
ad579f3269 Add task assignment notification system
- Introduced `AssignTaskEvent`, `TaskAssignEventSubscriber`, and `AssignTaskNotificationFlagProvider` for task assignment notifications
- WIP : Added notification templates for task assignment title and content
- Updated `SingleTaskController` to dispatch `AssignTaskEvent`
- Adjusted translations and service configurations accordingly
2025-09-29 15:14:34 +02:00
30e1416018 Refactor TaskNotificationHandler logic 2025-09-29 15:14:34 +02:00
b6b03cfcec feat: add task notification handler
- WIP Implement `TaskNotificationHandler` for generating task-related notifications
- Created `showInNotification.html.twig` for rendering task notification view
- Added logic to fetch and display task data in notifications
2025-09-29 15:14:34 +02:00
c8bb7575e7 Merge branch '426-increase_nb_chars_to_14_chill_password' into 'master'
#426 Increased the number of required characters when setting a new password in Chill

Closes #426

See merge request Chill-Projet/chill-bundles!883
2025-09-19 07:03:51 +00:00
juminet
80a3734171 #426 Increased the number of required characters when setting a new password in Chill 2025-09-19 07:03:51 +00:00
ab98f3a102 Release v4.4.2 2025-09-12 12:47:06 +02:00
7516e68d77 Merge branch 'fix/docgen-after-accp-work-refacto' into 'master'
Fix document generation and workflow generation do not work on accompanying period work documents

See merge request Chill-Projet/chill-bundles!880
2025-09-12 10:42:34 +00:00
7b60b7a8af Fix document generation and workflow generation do not work on accompanying period work documents 2025-09-12 10:42:34 +00:00
27 changed files with 615 additions and 85 deletions

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
time: 2025-09-18T11:40:44.858533536+02:00
custom:
Issue: "426"
SchemaChange: No schema change

3
.changes/v4.4.2.md Normal file
View File

@@ -0,0 +1,3 @@
## v4.4.2 - 2025-09-12
### Fixed
* Fix document generation and workflow generation do not work on accompanying period work documents

View File

@@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.4.2 - 2025-09-12
### Fixed
* Fix document generation and workflow generation do not work on accompanying period work documents
## v4.4.1 - 2025-09-11 ## v4.4.1 - 2025-09-11
### Fixed ### Fixed
* fix translations in duplicate evaluation document modal and realign close modal button * fix translations in duplicate evaluation document modal and realign close modal button

View File

@@ -4,7 +4,7 @@ import { StoredObject, StoredObjectVersion } from "../../types";
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue"; import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import { useToast } from "vue-toast-notification"; import { useToast } from "vue-toast-notification";
import { DOCUMENT_REPLACE, DOCUMENT_ADD, trans } from "translator"; import { DOCUMENT_ADD, trans } from "translator";
interface DropFileConfig { interface DropFileConfig {
allowRemove: boolean; allowRemove: boolean;
@@ -78,9 +78,7 @@ function closeModal(): void {
> >
{{ trans(DOCUMENT_ADD) }} {{ trans(DOCUMENT_ADD) }}
</button> </button>
<button v-else @click="openModal" class="dropdown-item"> <button v-else @click="openModal" class="btn btn-edit"></button>
{{ trans(DOCUMENT_REPLACE) }}
</button>
<modal <modal
v-if="state.showModal" v-if="state.showModal"
:modal-dialog-class="modalClasses" :modal-dialog-class="modalClasses"

View File

@@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType
'invalid_message' => 'The password fields must match', 'invalid_message' => 'The password fields must match',
'constraints' => [ 'constraints' => [
new Length([ new Length([
'min' => 9, 'min' => 14,
'minMessage' => 'The password must be greater than {{ limit }} characters', 'minMessage' => 'The password must be greater than {{ limit }} characters',
]), ]),
new NotBlank(), new NotBlank(),

View File

@@ -80,6 +80,8 @@ export default {
return appMessages.fr.the_evaluation_document; return appMessages.fr.the_evaluation_document;
case "Chill\\MainBundle\\Entity\\Workflow\\EntityWorkflow": case "Chill\\MainBundle\\Entity\\Workflow\\EntityWorkflow":
return appMessages.fr.the_workflow; return appMessages.fr.the_workflow;
case "Chill\\TaskBundle\\Entity\\SingleTask":
return appMessages.fr.the_task;
default: default:
throw "notification type unknown"; throw "notification type unknown";
} }
@@ -96,6 +98,8 @@ export default {
return `/fr/person/accompanying-period/work/evaluation/document/${n.relatedEntityId}/show`; return `/fr/person/accompanying-period/work/evaluation/document/${n.relatedEntityId}/show`;
case "Chill\\MainBundle\\Entity\\Workflow\\EntityWorkflow": case "Chill\\MainBundle\\Entity\\Workflow\\EntityWorkflow":
return `/fr/main/workflow/${n.relatedEntityId}/show`; return `/fr/main/workflow/${n.relatedEntityId}/show`;
case "Chill\\TaskBundle\\Entity\\SingleTask":
return `/fr/task/single-task/${n.relatedEntityId}/show`;
default: default:
throw "notification type unknown"; throw "notification type unknown";
} }

View File

@@ -5,7 +5,7 @@
role="button" role="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
<i class="fa fa-flash"></i> <i class="bi bi-lightning-fill"></i>
</a> </a>
<div class="dropdown-menu"> <div class="dropdown-menu">
{% for menu in menus %} {% for menu in menus %}

View File

@@ -37,7 +37,7 @@ class NotificationNormalizer implements NormalizerAwareInterface, NormalizerInte
->find($object->getRelatedEntityId()); ->find($object->getRelatedEntityId());
return [ return [
'type' => 'notification', 'type' => $object->getType(),
'id' => $object->getId(), 'id' => $object->getId(),
'addressees' => $this->normalizer->normalize($object->getAddressees(), $format, $context), 'addressees' => $this->normalizer->normalize($object->getAddressees(), $format, $context),
'date' => $this->normalizer->normalize($object->getDate(), $format, $context), 'date' => $this->normalizer->normalize($object->getDate(), $format, $context),

View File

@@ -45,7 +45,7 @@ final class UserControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$username = 'Test_user'.uniqid(); $username = 'Test_user'.uniqid();
$password = 'Password1234!'; $password = 'Password_1234!';
// Fill in the form and submit it // Fill in the form and submit it
@@ -99,7 +99,7 @@ final class UserControllerTest extends WebTestCase
{ {
$client = $this->getClientAuthenticatedAsAdmin(); $client = $this->getClientAuthenticatedAsAdmin();
$crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password"); $crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password");
$newPassword = '1234Password!'; $newPassword = '1234_Password!';
$form = $crawler->selectButton('Changer le mot de passe')->form([ $form = $crawler->selectButton('Changer le mot de passe')->form([
'chill_mainbundle_user_password[new_password][first]' => $newPassword, 'chill_mainbundle_user_password[new_password][first]' => $newPassword,

View File

@@ -6,7 +6,7 @@
:id="evaluation.id" :id="evaluation.id"
:templates="templates" :templates="templates"
:preventDefaultMoveToGenerate="true" :preventDefaultMoveToGenerate="true"
@go-to-generate-document="$emit('submitBeforeGenerate', $event)" @go-to-generate-document="submitBeforeGenerate"
> >
<template v-slot:title> <template v-slot:title>
<label class="col-form-label">{{ <label class="col-form-label">{{
@@ -22,7 +22,7 @@
<li> <li>
<drop-file-modal <drop-file-modal
:allow-remove="false" :allow-remove="false"
@add-document="$emit('addDocument', $event)" @add-document="emit('addDocument', $event)"
></drop-file-modal> ></drop-file-modal>
</li> </li>
</ul> </ul>
@@ -39,9 +39,34 @@ import {
EVALUATION_GENERATE_A_DOCUMENT, EVALUATION_GENERATE_A_DOCUMENT,
trans, trans,
} from "translator"; } from "translator";
import { buildLink } from "ChillDocGeneratorAssets/lib/document-generator";
import { useStore } from "vuex";
defineProps(["evaluation", "templates"]); const store = useStore();
defineEmits(["addDocument", "submitBeforeGenerate"]);
const props = defineProps(["evaluation", "templates"]);
const emit = defineEmits(["addDocument"]);
async function submitBeforeGenerate({ template }) {
const callback = (data) => {
let evaluationId = data.accompanyingPeriodWorkEvaluations.find(
(e) => e.key === props.evaluation.key,
).id;
window.location.assign(
buildLink(
template,
evaluationId,
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation",
),
);
};
return store.dispatch("submit", callback).catch((e) => {
console.log(e);
throw e;
});
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -58,7 +58,7 @@
:preventDefaultMoveToGenerate="true" :preventDefaultMoveToGenerate="true"
:goToGenerateWorkflowPayload="{ doc: d }" :goToGenerateWorkflowPayload="{ doc: d }"
@go-to-generate-workflow=" @go-to-generate-workflow="
$emit('goToGenerateWorkflow', $event) goToGenerateWorkflowEvaluationDocument
" "
></list-workflow-modal> ></list-workflow-modal>
</li> </li>
@@ -95,10 +95,9 @@
<a <a
class="dropdown-item" class="dropdown-item"
@click=" @click="
$emit( goToGenerateDocumentNotification(
'goToGenerateNotification',
d, d,
true, false,
) )
" "
> >
@@ -113,8 +112,7 @@
<a <a
class="dropdown-item" class="dropdown-item"
@click=" @click="
$emit( goToGenerateDocumentNotification(
'goToGenerateNotification',
d, d,
false, false,
) )
@@ -150,15 +148,35 @@
" "
></document-action-buttons-group> ></document-action-buttons-group>
</li> </li>
<!--replace document-->
<li
v-if="
Number.isInteger(d.id) &&
d.storedObject._permissions.canEdit
"
>
<drop-file-modal
:existing-doc="d.storedObject"
:allow-remove="false"
@add-document="
(arg) =>
replaceDocument(
d,
arg.stored_object,
arg.stored_object_version,
)
"
></drop-file-modal>
</li>
<li v-if="Number.isInteger(d.id)"> <li v-if="Number.isInteger(d.id)">
<div class="duplicate-dropdown"> <div class="duplicate-dropdown">
<button <button
class="btn btn-edit dropdown-toggle" class="btn btn-outline-primary dropdown-toggle"
type="button" type="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-expanded="false" aria-expanded="false"
> >
{{ trans(EVALUATION_DOCUMENT_EDIT) }} <i class="bi bi-lightning-fill"></i>
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<!--delete--> <!--delete-->
@@ -180,27 +198,6 @@
}} }}
</a> </a>
</li> </li>
<!--replace document-->
<li
v-if="
d.storedObject._permissions
.canEdit
"
>
<drop-file-modal
:existing-doc="d.storedObject"
:allow-remove="false"
@add-document="
(arg) =>
$emit(
'replaceDocument',
d,
arg.stored_object,
arg.stored_object_version,
)
"
></drop-file-modal>
</li>
<!--duplicate document--> <!--duplicate document-->
<li> <li>
<a <a
@@ -300,35 +297,45 @@ import {
EVALUATION_DOCUMENTS, EVALUATION_DOCUMENTS,
EVALUATION_DOCUMENT_MOVE, EVALUATION_DOCUMENT_MOVE,
EVALUATION_DOCUMENT_DELETE, EVALUATION_DOCUMENT_DELETE,
EVALUATION_DOCUMENT_EDIT,
EVALUATION_DOCUMENT_DUPLICATE_HERE, EVALUATION_DOCUMENT_DUPLICATE_HERE,
EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION, EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION,
trans, trans,
} from "translator"; } from "translator";
import { ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import AccompanyingPeriodWorkSelectorModal from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue"; import AccompanyingPeriodWorkSelectorModal from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue";
import { buildLinkCreate } from "ChillMainAssets/lib/entity-workflow/api";
import { buildLinkCreate as buildLinkCreateNotification } from "ChillMainAssets/lib/entity-notification/api";
import { useStore } from "vuex";
defineProps([ const props = defineProps([
"documents", "documents",
"docAnchorId", "docAnchorId",
"accompanyingPeriodId", "accompanyingPeriodId",
"accompanyingPeriodWorkId", "evaluation",
]); ]);
const emit = defineEmits([ const emit = defineEmits([
"inputDocumentTitle", "inputDocumentTitle",
"removeDocument", "removeDocument",
"duplicateDocument", "duplicateDocument",
"statusDocumentChanged", "statusDocumentChanged",
"goToGenerateWorkflow",
"goToGenerateNotification",
"duplicateDocumentToWork", "duplicateDocumentToWork",
]); ]);
const store = useStore();
const showAccompanyingPeriodSelector = ref(false); const showAccompanyingPeriodSelector = ref(false);
const selectedEvaluation = ref(null); const selectedEvaluation = ref(null);
const selectedDocumentToDuplicate = ref(null); const selectedDocumentToDuplicate = ref(null);
const selectedDocumentToMove = ref(null); const selectedDocumentToMove = ref(null);
const AmIRefferer = computed(() => {
return !(
store.state.work.accompanyingPeriod.user &&
store.state.me &&
store.state.work.accompanyingPeriod.user.id !== store.state.me.id
);
});
const prepareDocumentDuplicationToWork = (d) => { const prepareDocumentDuplicationToWork = (d) => {
selectedDocumentToDuplicate.value = d; selectedDocumentToDuplicate.value = d;
/** ensure selectedDocumentToMove is null */ /** ensure selectedDocumentToMove is null */
@@ -358,4 +365,91 @@ watch(selectedEvaluation, (val) => {
}); });
} }
}); });
async function goToGenerateWorkflowEvaluationDocument({
workflowName,
payload,
}) {
const callback = (data) => {
let evaluation = data.accompanyingPeriodWorkEvaluations.find(
(e) => e.key === props.evaluation.key,
);
let updatedDocument = evaluation.documents.find(
(d) => d.key === payload.doc.key,
);
window.location.assign(
buildLinkCreate(
workflowName,
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
updatedDocument.id,
),
);
};
return store.dispatch("submit", callback).catch((e) => {
console.log(e);
throw e;
});
}
/**
* Replaces a document in the store with a new document.
*
* @param {Object} oldDocument - The document to be replaced.
* @param {StoredObject} storedObject - The stored object of the new document.
* @param {StoredObjectVersion} storedObjectVersion - The new version of the document
* @return {void}
*/
async function replaceDocument(oldDocument, storedObject, storedObjectVersion) {
let document = {
type: "accompanying_period_work_evaluation_document",
storedObject: storedObject,
title: oldDocument.title,
};
return store.commit("replaceDocument", {
key: props.evaluation.key,
document,
oldDocument: oldDocument,
stored_object_version: storedObjectVersion,
});
}
async function goToGenerateDocumentNotification(document, tos) {
const callback = (data) => {
let evaluation = data.accompanyingPeriodWorkEvaluations.find(
(e) => e.key === props.evaluation.key,
);
let updatedDocument = evaluation.documents.find(
(d) => d.key === document.key,
);
window.location.assign(
buildLinkCreateNotification(
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
updatedDocument.id,
tos === true
? store.state.work.accompanyingPeriod.user?.id
: null,
window.location.pathname +
window.location.search +
window.location.hash,
),
);
};
return store.dispatch("submit", callback).catch((e) => {
console.log(e);
throw e;
});
}
async function submitBeforeLeaveToEditor() {
console.log("submit beore edit 2");
// empty callback
const callback = () => null;
return store.dispatch("submit", callback).catch((e) => {
console.log(e);
throw e;
});
}
</script> </script>

View File

@@ -24,8 +24,8 @@
v-if="evaluation.documents.length > 0" v-if="evaluation.documents.length > 0"
:documents="evaluation.documents" :documents="evaluation.documents"
:docAnchorId="docAnchorId" :docAnchorId="docAnchorId"
:evaluation="evaluation"
:accompanyingPeriodId="store.state.work.accompanyingPeriod.id" :accompanyingPeriodId="store.state.work.accompanyingPeriod.id"
:accompanying-period-work-id="store.state.work.id"
@inputDocumentTitle="onInputDocumentTitle" @inputDocumentTitle="onInputDocumentTitle"
@removeDocument="removeDocument" @removeDocument="removeDocument"
@duplicateDocument="duplicateDocument" @duplicateDocument="duplicateDocument"
@@ -34,7 +34,6 @@
" "
@move-document-to-evaluation="moveDocumentToEvaluation" @move-document-to-evaluation="moveDocumentToEvaluation"
@statusDocumentChanged="onStatusDocumentChanged" @statusDocumentChanged="onStatusDocumentChanged"
@goToGenerateWorkflow="goToGenerateWorkflowEvaluationDocument"
@goToGenerateNotification="goToGenerateDocumentNotification" @goToGenerateNotification="goToGenerateDocumentNotification"
/> />
@@ -42,7 +41,6 @@
:evaluation="evaluation" :evaluation="evaluation"
:templates="getTemplatesAvailables" :templates="getTemplatesAvailables"
@addDocument="addDocument" @addDocument="addDocument"
@submitBeforeGenerate="submitBeforeGenerate"
/> />
</div> </div>
</div> </div>
@@ -290,29 +288,6 @@ function onStatusDocumentChanged(newStatus) {
}); });
} }
function goToGenerateWorkflowEvaluationDocument({ workflowName, payload }) {
const callback = (data) => {
let evaluation = data.accompanyingPeriodWorkEvaluations.find(
(e) => e.key === props.evaluation.key,
);
let updatedDocument = evaluation.documents.find(
(d) => d.key === payload.doc.key,
);
window.location.assign(
buildLinkCreate(
workflowName,
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
updatedDocument.id,
),
);
};
store.dispatch("submit", callback).catch((e) => {
console.log(e);
throw e;
});
}
function goToGenerateDocumentNotification(document, tos) { function goToGenerateDocumentNotification(document, tos) {
const callback = (data) => { const callback = (data) => {
let evaluation = data.accompanyingPeriodWorkEvaluations.find( let evaluation = data.accompanyingPeriodWorkEvaluations.find(

View File

@@ -13,7 +13,6 @@ namespace Chill\TaskBundle\Controller;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface;
use Chill\MainBundle\Serializer\Model\Collection; use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter; use Chill\MainBundle\Serializer\Model\Counter;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper; use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
@@ -23,6 +22,7 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Privacy\PrivacyEvent; use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\TaskBundle\Entity\SingleTask; use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskEvent; use Chill\TaskBundle\Event\TaskEvent;
use Chill\TaskBundle\Event\UI\UIEvent; use Chill\TaskBundle\Event\UI\UIEvent;
use Chill\TaskBundle\Form\SingleTaskType; use Chill\TaskBundle\Form\SingleTaskType;
@@ -48,7 +48,6 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class SingleTaskController extends AbstractController final class SingleTaskController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly CenterResolverDispatcherInterface $centerResolverDispatcher,
private readonly PaginatorFactory $paginatorFactory, private readonly PaginatorFactory $paginatorFactory,
private readonly SingleTaskAclAwareRepositoryInterface $singleTaskAclAwareRepository, private readonly SingleTaskAclAwareRepositoryInterface $singleTaskAclAwareRepository,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
@@ -169,6 +168,9 @@ final class SingleTaskController extends AbstractController
->setForm($this->setCreateForm($task, TaskVoter::UPDATE)); ->setForm($this->setCreateForm($task, TaskVoter::UPDATE));
$this->eventDispatcher->dispatch($event, UIEvent::EDIT_FORM); $this->eventDispatcher->dispatch($event, UIEvent::EDIT_FORM);
// To keep track of specific assignee change
$initialAssignee = $task->getAssignee();
$form = $event->getForm(); $form = $event->getForm();
$form->handleRequest($request); $form->handleRequest($request);
@@ -178,6 +180,13 @@ final class SingleTaskController extends AbstractController
$em = $this->managerRegistry->getManager(); $em = $this->managerRegistry->getManager();
$em->persist($task); $em->persist($task);
if ($initialAssignee !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, $initialAssignee),
AssignTaskEvent::PERSIST
);
}
$em->flush(); $em->flush();
$this->addFlash('success', $this->translator $this->addFlash('success', $this->translator
@@ -525,6 +534,13 @@ final class SingleTaskController extends AbstractController
$this->eventDispatcher->dispatch(new TaskEvent($task), TaskEvent::PERSIST); $this->eventDispatcher->dispatch(new TaskEvent($task), TaskEvent::PERSIST);
if (null !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, null),
AssignTaskEvent::PERSIST
);
}
$em->flush(); $em->flush();
$this->addFlash('success', $this->translator->trans('The task is created')); $this->addFlash('success', $this->translator->trans('The task is created'));

View File

@@ -42,6 +42,7 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface
$loader->load('services/timeline.yaml'); $loader->load('services/timeline.yaml');
$loader->load('services/fixtures.yaml'); $loader->load('services/fixtures.yaml');
$loader->load('services/form.yaml'); $loader->load('services/form.yaml');
$loader->load('services/notification.yaml');
} }
public function prepend(ContainerBuilder $container) public function prepend(ContainerBuilder $container)

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Event;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Symfony\Contracts\EventDispatcher\Event;
class AssignTaskEvent extends Event
{
final public const PERSIST = 'chill_task.assign_task';
public function __construct(
private readonly SingleTask $task,
private readonly ?User $initialAssignee,
) {}
public function getTask(): SingleTask
{
return $this->task;
}
public function getInitialAssignee(): ?User
{
return $this->initialAssignee;
}
public function hasAssigneeChanged(): bool
{
return $this->initialAssignee !== $this->task->getAssignee();
}
}

View File

@@ -0,0 +1,66 @@
<?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\TaskBundle\Event;
use Chill\MainBundle\Entity\Notification;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
readonly class TaskAssignEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private \Twig\Environment $engine,
) {}
public static function getSubscribedEvents(): array
{
return [
AssignTaskEvent::PERSIST => ['onTaskAssigned', 0],
];
}
/**
* Send a notification when a user is assigned to a task.
* Only triggers when the assignee actually changes.
*/
public function onTaskAssigned(AssignTaskEvent $event): void
{
if (!$event->hasAssigneeChanged()) {
return;
}
$task = $event->getTask();
$assignedUser = $task->getAssignee();
$title = $task->getTitle();
$context = [
'task' => $task,
'assignedUser' => $assignedUser,
'title' => $title,
];
$notification = new Notification();
$notification
->setRelatedEntityId($task->getId())
->setRelatedEntityClass(SingleTask::class)
->setTitle($this->engine->render('@ChillTask/Notification/task_assignment_notification_title.txt.twig', $context))
->setMessage($this->engine->render('@ChillTask/Notification/task_assignment_notification_content.txt.twig', $context))
->addAddressee($assignedUser)
->setType(AssignTaskNotificationFlagProvider::FLAG);
$this->entityManager->persist($notification);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Notification;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class AssignTaskNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'task-assign-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.task_assign');
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Repository\SingleTaskRepository;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
final readonly class TaskNotificationHandler implements NotificationHandlerInterface
{
public function __construct(private SingleTaskRepository $taskRepository) {}
public function getTemplate(Notification $notification, array $options = []): string
{
return '@ChillTask/SingleTask/showInNotification.html.twig';
}
public function getTemplateData(Notification $notification, array $options = []): array
{
return [
'notification' => $notification,
'task' => $this->taskRepository->find($notification->getRelatedEntityId()),
];
}
public function supports(Notification $notification, array $options = []): bool
{
return SingleTask::class === $notification->getRelatedEntityClass();
}
public function getTitle(Notification $notification, array $options = []): TranslatableInterface
{
if (null === $task = $this->getRelatedEntity($notification)) {
return new TranslatableMessage('task.deleted');
}
return new TranslatableMessage('notification.task.title %title%', ['title' => $task->getTitle()]);
}
public function getAssociatedPersons(Notification $notification, array $options = []): array
{
if (null === $task = $this->getRelatedEntity($notification)) {
return [];
}
if (null !== $task->getCourse()) {
return $task->getCourse()->getParticipations()->getValues();
}
return [$task->getPerson()];
}
public function getRelatedEntity(Notification $notification): ?object
{
return $this->taskRepository->find($notification->getRelatedEntityId());
}
}

View File

@@ -0,0 +1,15 @@
{{ assignedUser.label }},
{{ 'notification.email.task_assigned'|trans({}, null, assignedUser.getLocale) }}
{{ 'notification.email.title_label'|trans({}, null, assignedUser.getLocale) }} "{{ task.title }}".
{% if task.endDate %}
{{ 'notification.email.deadline'|trans({'%date%': task.endDate|format_date('long')}, null, assignedUser.getLocale) }}
{% endif %}
{{ 'notification.email.view_task'|trans({}, null, assignedUser.getLocale) }}
{{ absolute_url(path('chill_task_single_task_show', {'id': task.id, '_locale': assignedUser.getLocale})) }}
{{ 'notification.email.regards'|trans({}, null, assignedUser.getLocale) }},

View File

@@ -0,0 +1,3 @@
{{ 'notification.email.title'|trans({}, null, assignedUser.getLocale) }}

View File

@@ -18,14 +18,14 @@
<div> <div>
{% if task.person is not null %} {% if task.person is not null %}
<span class="chill-task-list__row__person"> <span class="chill-task-list__row__person">
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: task.person.id }, targetEntity: { name: 'person', id: task.person.id },
action: 'show', action: 'show',
displayBadge: true, displayBadge: true,
buttonText: task.person|chill_entity_render_string, buttonText: task.person|chill_entity_render_string,
isDead: task.person.deathdate is not null isDead: task.person.deathdate is not null
} %} } %}
</span> </span>
{% elseif task.course is not null %} {% elseif task.course is not null %}
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
{% for part in task.course.currentParticipations %} {% for part in task.course.currentParticipations %}

View File

@@ -110,4 +110,5 @@
</li> </li>
{% endif %} {% endif %}
</ul></div> </ul>
</div>

View File

@@ -0,0 +1,14 @@
{% macro recordAction(task) %}
<li>
<a href="{{ path('chill_person_accompanying_course_index', { 'task_id': task }) }}"
class="btn btn-show" title="{{ 'See task'|trans }}"></a>
</li>
{% endmacro %}
{% if task is not null %}
{# <div>Todo : display task? </div>#}
{% else %}
<div class="alert alert-warning border-warning border-1">
{{ 'You are getting a notification for a task which does not exist any more'|trans }}
</div>
{% endif %}

View File

@@ -0,0 +1,138 @@
<?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\TaskBundle\Tests\EventSubscriber;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskAssignEventSubscriber;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class TaskAssignEventSubscriberTest extends TestCase
{
use ProphecyTrait;
private ObjectProphecy $entityManager;
private ObjectProphecy $twig;
private TaskAssignEventSubscriber $subscriber;
protected function setUp(): void
{
$this->entityManager = $this->prophesize(EntityManagerInterface::class);
$this->twig = $this->prophesize(Environment::class);
$this->subscriber = new TaskAssignEventSubscriber(
$this->entityManager->reveal(),
$this->twig->reveal()
);
}
private function setEntityId(object $entity, int $id): void
{
$reflection = new \ReflectionClass($entity);
$property = $reflection->getProperty('id');
$property->setAccessible(true);
$property->setValue($entity, $id);
}
public function testOnTaskAssignedCreatesNotificationWhenAssigneeChanges(): void
{
// Arrange
$initialAssignee = new User();
$newAssignee = new User();
$task = new SingleTask();
$task->setTitle('Test Task');
$task->setAssignee($newAssignee);
$this->setEntityId($task, 123);
$event = new AssignTaskEvent($task, $initialAssignee);
$this->twig->render('@ChillTask/Notification/task_assignment_notification_title.txt.twig', Argument::type('array'))
->shouldBeCalledOnce()
->willReturn('Notification Title');
$this->twig->render('@ChillTask/Notification/task_assignment_notification_content.txt.twig', Argument::type('array'))
->shouldBeCalledOnce()
->willReturn('Notification Content');
$this->entityManager->persist(Argument::type(Notification::class))
->shouldBeCalledOnce();
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testOnTaskAssignedDoesNothingWhenAssigneeDoesNotChange(): void
{
// Arrange
$assignee = new User();
$task = new SingleTask();
$task->setTitle('Test Task');
$task->setAssignee($assignee);
$event = new AssignTaskEvent($task, $assignee);
$this->twig->render(Argument::any(), Argument::any())->shouldNotBeCalled();
$this->entityManager->persist(Argument::any())->shouldNotBeCalled();
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testNotificationHasCorrectProperties(): void
{
// Arrange
$initialAssignee = new User();
$newAssignee = new User();
$task = new SingleTask();
$task->setTitle('Important Task');
$task->setAssignee($newAssignee);
$this->setEntityId($task, 456);
$event = new AssignTaskEvent($task, $initialAssignee);
$this->twig->render(Argument::any(), Argument::any())->willReturn('Test Content');
// Capture the persisted notification
$persistedNotification = null;
$this->entityManager->persist(Argument::type(Notification::class))
->shouldBeCalledOnce()
->will(function ($args) use (&$persistedNotification) {
$persistedNotification = $args[0];
});
// Act
$this->subscriber->onTaskAssigned($event);
// Assert
$this->assertInstanceOf(Notification::class, $persistedNotification);
$this->assertEquals($task->getId(), $persistedNotification->getRelatedEntityId());
$this->assertEquals(SingleTask::class, $persistedNotification->getRelatedEntityClass());
$this->assertEquals(AssignTaskNotificationFlagProvider::FLAG, $persistedNotification->getType());
$this->assertEquals('Test Content', $persistedNotification->getTitle());
$this->assertEquals('Test Content', $persistedNotification->getMessage());
}
}

View File

@@ -1,7 +1,13 @@
services: services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent: Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent:
arguments: arguments:
$em: '@Doctrine\ORM\EntityManagerInterface' $em: '@Doctrine\ORM\EntityManagerInterface'
$tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface' $tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'
tags: tags:
- { name: kernel.event_subscriber } - { name: kernel.event_subscriber }
Chill\TaskBundle\Event\TaskAssignEventSubscriber: ~

View File

@@ -0,0 +1,7 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Notification\TaskNotificationHandler: ~
Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider: ~

View File

@@ -116,3 +116,16 @@ CHILL_TASK_TASK_UPDATE: Modifier une tâche
CHILL_TASK_TASK_CREATE_FOR_COURSE: Créer une tâche pour un parcours CHILL_TASK_TASK_CREATE_FOR_COURSE: Créer une tâche pour un parcours
CHILL_TASK_TASK_CREATE_FOR_PERSON: Créer une tâche pour un usager CHILL_TASK_TASK_CREATE_FOR_PERSON: Créer une tâche pour un usager
notification:
task:
title %title%: "Tâche: title"
flags:
task_assign: Lorsqu'un autre utilisateur m'assigne à une tâche.
email:
title: "Une tâche demande votre attention"
task_assigned: "Une tâche vous a été assignée."
title_label: "Titre de la tâche:"
deadline: "Vous êtes invités à accomplir cette tâche avant le %date%"
view_task: "Vous pouvez visualiser la tâche sur cette page:"
regards: "Cordialement"