Merge branch '285-cancel-calendar' into 'master'

Permettre d'annuler un rendez-vous

Closes #285

See merge request Chill-Projet/chill-bundles!775
This commit is contained in:
2025-11-06 15:07:11 +00:00
18 changed files with 296 additions and 78 deletions

View File

@@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Form\CalendarType;
use Chill\CalendarBundle\Form\CancelType;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
@@ -30,6 +31,7 @@ use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\EntityManagerInterface;
use http\Exception\UnexpectedValueException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -60,6 +62,7 @@ class CalendarController extends AbstractController
private readonly UserRepositoryInterface $userRepository,
private readonly TranslatorInterface $translator,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
private readonly EntityManagerInterface $em,
) {}
/**
@@ -111,6 +114,55 @@ class CalendarController extends AbstractController
]);
}
#[Route(path: '/{_locale}/calendar/calendar/{id}/cancel', name: 'chill_calendar_calendar_cancel')]
public function cancelAction(Calendar $calendar, Request $request): Response
{
// Deal with sms being sent or not
// Communicate cancellation with the remote calendar.
$this->denyAccessUnlessGranted(CalendarVoter::EDIT, $calendar);
[$person, $accompanyingPeriod] = [$calendar->getPerson(), $calendar->getAccompanyingPeriod()];
$form = $this->createForm(CancelType::class, $calendar);
$form->add('submit', SubmitType::class);
if ($accompanyingPeriod instanceof AccompanyingPeriod) {
$view = '@ChillCalendar/Calendar/cancelCalendarByAccompanyingCourse.html.twig';
$redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_period', ['id' => $accompanyingPeriod->getId()]);
} elseif ($person instanceof Person) {
$view = '@ChillCalendar/Calendar/cancelCalendarByPerson.html.twig';
$redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_person', ['id' => $person->getId()]);
} else {
throw new \RuntimeException('nor person or accompanying period');
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->logger->notice('A calendar event has been cancelled', [
'by_user' => $this->getUser()->getUsername(),
'calendar_id' => $calendar->getId(),
]);
$calendar->setStatus($calendar::STATUS_CANCELED);
$calendar->setSmsStatus($calendar::SMS_CANCEL_PENDING);
$this->em->flush();
$this->addFlash('success', $this->translator->trans('chill_calendar.calendar_canceled'));
return new RedirectResponse($redirectRoute);
}
return $this->render($view, [
'calendar' => $calendar,
'form' => $form->createView(),
'accompanyingCourse' => $accompanyingPeriod,
'person' => $person,
]);
}
/**
* Edit a calendar item.
*/

View File

@@ -35,7 +35,7 @@ class LoadCancelReason extends Fixture implements FixtureGroupInterface
$arr = [
['name' => CancelReason::CANCELEDBY_USER],
['name' => CancelReason::CANCELEDBY_PERSON],
['name' => CancelReason::CANCELEDBY_DONOTCOUNT],
['name' => CancelReason::CANCELEDBY_OTHER],
];
foreach ($arr as $a) {

View File

@@ -269,6 +269,11 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
return $this->cancelReason;
}
public function isCanceled(): bool
{
return null !== $this->cancelReason;
}
public function getCenters(): ?iterable
{
return match ($this->getContext()) {

View File

@@ -18,14 +18,14 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'chill_calendar.cancel_reason')]
class CancelReason
{
final public const CANCELEDBY_DONOTCOUNT = 'CANCELEDBY_DONOTCOUNT';
final public const CANCELEDBY_OTHER = 'CANCELEDBY_OTHER';
final public const CANCELEDBY_PERSON = 'CANCELEDBY_PERSON';
final public const CANCELEDBY_USER = 'CANCELEDBY_USER';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN)]
private ?bool $active = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => true])]
private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
private ?string $canceledBy = null;

View File

@@ -15,7 +15,7 @@ use Chill\CalendarBundle\Entity\CancelReason;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -28,7 +28,14 @@ class CancelReasonType extends AbstractType
->add('active', CheckboxType::class, [
'required' => false,
])
->add('canceledBy', TextType::class);
->add('canceledBy', ChoiceType::class, [
'choices' => [
'chill_calendar.canceled_by.user' => CancelReason::CANCELEDBY_USER,
'chill_calendar.canceled_by.person' => CancelReason::CANCELEDBY_PERSON,
'chill_calendar.canceled_by.other' => CancelReason::CANCELEDBY_OTHER,
],
'required' => true,
]);
}
public function configureOptions(OptionsResolver $resolver)

View File

@@ -0,0 +1,42 @@
<?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\CalendarBundle\Form;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CancelReason;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CancelType extends AbstractType
{
public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('cancelReason', EntityType::class, [
'class' => CancelReason::class,
'required' => true,
'choice_label' => fn (CancelReason $cancelReason) => $this->translatableStringHelper->localize($cancelReason->getName()),
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Calendar::class,
]);
}
}

View File

@@ -21,6 +21,7 @@ namespace Chill\CalendarBundle\Messenger\Doctrine;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Messenger\Message\CalendarMessage;
use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
@@ -31,6 +32,17 @@ class CalendarEntityListener
{
public function __construct(private readonly MessageBusInterface $messageBus, private readonly Security $security) {}
private function getAuthenticatedUser(): User
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \LogicException('Expected an instance of User.');
}
return $user;
}
public function postPersist(Calendar $calendar, PostPersistEventArgs $args): void
{
if (!$calendar->preventEnqueueChanges) {
@@ -38,7 +50,7 @@ class CalendarEntityListener
new CalendarMessage(
$calendar,
CalendarMessage::CALENDAR_PERSIST,
$this->security->getUser()
$this->getAuthenticatedUser()
)
);
}
@@ -50,7 +62,7 @@ class CalendarEntityListener
$this->messageBus->dispatch(
new CalendarRemovedMessage(
$calendar,
$this->security->getUser()
$this->getAuthenticatedUser()
)
);
}
@@ -58,12 +70,19 @@ class CalendarEntityListener
public function postUpdate(Calendar $calendar, PostUpdateEventArgs $args): void
{
if (!$calendar->preventEnqueueChanges) {
if ($calendar->getStatus() === $calendar::STATUS_CANCELED) {
$this->messageBus->dispatch(
new CalendarRemovedMessage(
$calendar,
$this->getAuthenticatedUser()
)
);
} elseif (!$calendar->preventEnqueueChanges) {
$this->messageBus->dispatch(
new CalendarMessage(
$calendar,
CalendarMessage::CALENDAR_UPDATE,
$this->security->getUser()
$this->getAuthenticatedUser()
)
);
}

View File

@@ -70,6 +70,8 @@ class CalendarRemovedMessage
public function getRemoteId(): string
{
dump($this->remoteId);
return $this->remoteId;
}
}

View File

@@ -191,6 +191,7 @@ class CalendarRepository implements ObjectRepository
$qb->expr()->eq('c.mainUser', ':user'),
$qb->expr()->gte('c.startDate', ':startDate'),
$qb->expr()->lte('c.endDate', ':endDate'),
$qb->expr()->isNull('c.cancelReason'),
)
)
->setParameters([

View File

@@ -1,5 +1,6 @@
services:
Chill\CalendarBundle\Controller\:
autowire: true
autoconfigure: true
resource: '../../../Controller'
tags: ['controller.service_arguments']

View File

@@ -5,29 +5,45 @@
<div class="item-col">
<div class="wrap-header">
<div class="wl-row">
<div class="wl-col title">
<p class="date-label">
{% if context == 'person' and calendar.context == 'accompanying_period' %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_course_index', {'accompanying_period_id': calendar.accompanyingPeriod.id}) }}" style="text-decoration: none;">
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ calendar.accompanyingPeriod.id }}
</span>
</a>
{% endif %}
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('short', 'short') }}
{% else %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('none', 'short') }}
{% endif %}
</p>
{% if calendar.status == 'canceled' %}
<div class="badge rounded-pill bg-danger">
<span>{{ 'chill_calendar.canceled'|trans }}: </span>
<span>{{ calendar.cancelReason.name|localize_translatable_string }}</span>
</div>
{% endif %}
</div>
<div class="wl-row">
<div class="wl-col title">
<p class="date-label">
{% if calendar.status == 'canceled' %}
<del>
{% endif %}
{% if context == 'person' and calendar.context == 'accompanying_period' %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_course_index', {'accompanying_period_id': calendar.accompanyingPeriod.id}) }}" style="text-decoration: none;">
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ calendar.accompanyingPeriod.id }}
</span>
</a>
{% endif %}
{% if calendar.status == 'canceled' %}
<del>
{% endif %}
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('short', 'short') }}
{% else %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('none', 'short') }}
{% endif %}
{% if calendar.status == 'canceled' %}
</del>
{% endif %}
<div class="duration short-message">
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I') }}
{% if false == calendar.sendSMS or null == calendar.sendSMS %}
<!-- no sms will be send -->
<!-- no sms will be sent -->
{% else %}
{% if calendar.smsStatus == 'sms_sent' %}
<span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
@@ -59,17 +75,17 @@
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">
<div class="item-col">
{% include '@ChillActivity/Activity/concernedGroups.html.twig' with {
'context': calendar.context == 'person' ? 'calendar_person' : 'calendar_accompanyingCourse',
'render': 'wrap-list',
'entity': calendar
} %}
</div>
or calendar.users|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">
<div class="item-col">
{% include '@ChillActivity/Activity/concernedGroups.html.twig' with {
'context': calendar.context == 'person' ? 'calendar_person' : 'calendar_accompanyingCourse',
'render': 'wrap-list',
'entity': calendar
} %}
</div>
</div>
{% endif %}
@@ -98,12 +114,13 @@
</div>
{% endif %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
{% if calendar.documents is not empty %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
</div>
{% endif %}
{% if calendar.activity is not null %}
<div class="item-row separator">
@@ -146,7 +163,7 @@
<div class="item-row separator">
<ul class="record_actions">
{% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) %}
{% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) and calendar.status is not constant('STATUS_CANCELED', calendar) %}
{% if templates|length == 0 %}
<li>
<a class="btn btn-create"
@@ -186,6 +203,7 @@
or
(calendar.context == 'person' and is_granted('CHILL_ACTIVITY_CREATE', calendar.person))
)
and calendar.status is not constant('STATUS_CANCELED', calendar)
%}
<li>
<a class="btn btn-create"
@@ -195,32 +213,38 @@
</li>
{% endif %}
{% if (calendar.isInvited(app.user)) %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}"
data-calendar-id="{{ calendar.id|e('html_attr') }}"></div>
</li>
{% endif %}
{% if false %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_show', { 'id': calendar.id}) }}"
class="btn btn-show "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { 'id': calendar.id }) }}"
class="btn btn-update "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_delete', { 'id': calendar.id } ) }}"
class="btn btn-delete "></a>
</li>
{% endif %}
</ul>
{% if calendar.isInvited(app.user) and not calendar.isCanceled %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}"
data-calendar-id="{{ calendar.id|e('html_attr') }}"></div>
</li>
{% endif %}
{% if false %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_show', { 'id': calendar.id}) }}"
class="btn btn-show "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) and calendar.status is not constant('STATUS_CANCELED', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { 'id': calendar.id }) }}"
class="btn btn-update "></a>
</li>
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_cancel', { 'id': calendar.id } ) }}"
class="btn btn-action"><i class="bi bi-x-circle"></i> {{ 'Cancel'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_delete', { 'id': calendar.id } ) }}"
class="btn btn-delete "></a>
</li>
{% endif %}
</ul>
</div>

View File

@@ -0,0 +1,29 @@
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_calendar_list' %}
{% block title 'chill_calendar.cancel_calendar_item'|trans %}
{% block content %}
{{ form_start(form) }}
{{ form_row(form.cancelReason) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a
class="btn btn-cancel"
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'id': accompanyingCourse.id } )}}"
>
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
<li>
{{ form_widget(form.submit, { 'attr' : { 'class': 'btn btn-save' }, 'label': 'Save' } ) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "@ChillPerson/Person/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_calendar_list' %}
{% block title 'chill_calendar.cancel_calendar_item'|trans %}
{% block content %}
{{ form_start(form) }}
{{ form_row(form.cancelReason) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a
class="btn btn-cancel"
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'id': person.id } )}}"
>
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
<li>
{{ form_widget(form.submit, { 'attr' : { 'class': 'btn btn-save' }, 'label': 'Save' } ) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Name'|trans }}</th>
<th>{{ 'canceledBy'|trans }}</th>
<th>{{ 'Canceled by'|trans }}</th>
<th>{{ 'active'|trans }}</th>
<th>&nbsp;</th>
{% endblock %}
@@ -40,4 +40,4 @@
</li>
{% endblock %}
{% endembed %}
{% endblock %}
{% endblock %}

View File

@@ -19,6 +19,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CancelReason;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Notifier\Message\SmsMessage;
@@ -57,7 +58,7 @@ class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBu
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]),
);
} elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) {
} elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus() && (null === $calendar->getCancelReason() || CancelReason::CANCELEDBY_PERSON !== $calendar->getCancelReason()->getCanceledBy())) {
$toUsers[] = new SmsMessage(
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]),

View File

@@ -31,8 +31,7 @@ Will send SMS: Un SMS de rappel sera envoyé
Will not send SMS: Aucun SMS de rappel ne sera envoyé
SMS already sent: Un SMS a été envoyé
canceledBy: supprimé par
Canceled by: supprimé par
Canceled by: Annulé par
Calendar configuration: Gestion des rendez-vous
crud:
@@ -44,6 +43,14 @@ crud:
title_edit: Modifier le motif d'annulation
chill_calendar:
canceled: Annulé
cancel_reason: Raison d'annulation
cancel_calendar_item: Annuler rendez-vous
calendar_canceled: Le rendez-vous a été annulé
canceled_by:
user: Utilisateur
person: Usager
other: Autre
Document: Document d'un rendez-vous
form:
The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement.

View File

@@ -102,7 +102,6 @@ class CRUDController extends AbstractController
Resolver::class => Resolver::class,
SerializerInterface::class => SerializerInterface::class,
FilterOrderHelperFactoryInterface::class => FilterOrderHelperFactoryInterface::class,
ManagerRegistry::class => ManagerRegistry::class,
]
);
}
@@ -674,7 +673,7 @@ class CRUDController extends AbstractController
protected function getManagerRegistry(): ManagerRegistry
{
return $this->container->get(ManagerRegistry::class);
return $this->container->get('doctrine');
}
/**