Compare commits

..

1 Commits

Author SHA1 Message Date
95d9a75e46 feat: add invitation list
- Introduced `MyInvitationsController` for managing user invitations
- Added `InviteACLAwareRepository` and its interface for handling invite data operations
- Created views for listing and displaying user-specific invitations
- Updated user menu to include "My invitations list" option
2025-09-05 16:10:51 +02:00
33 changed files with 415 additions and 449 deletions

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Create invitation list in user menu
time: 2025-08-08T12:08:02.446361367+02:00
custom:
Issue: "385"
SchemaChange: No schema change

View File

@@ -1,10 +0,0 @@
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method

View File

@@ -6,17 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method
## v4.2.1 - 2025-09-03
### Fixed
* Fix exports to work with DirectExportInterface

View File

@@ -266,7 +266,7 @@ class CalendarController extends AbstractController
}
if (!$this->getUser() instanceof User) {
throw new UnauthorizedHttpException('you are not an user');
throw new UnauthorizedHttpException('you are not a user');
}
$view = '@ChillCalendar/Calendar/listByUser.html.twig';

View File

@@ -0,0 +1,62 @@
<?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\Controller;
use Chill\CalendarBundle\Repository\InviteACLAwareRepository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\Annotation\Route;
class MyInvitationsController extends AbstractController
{
public function __construct(private readonly InviteACLAwareRepository $inviteACLAwareRepository, private readonly PaginatorFactory $paginator) {}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
#[Route(path: '/{_locale}/calendar/invitations/my', name: 'chill_calendar_invitations_list_my')]
public function myInvitations(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new UnauthorizedHttpException('you are not a user');
}
$total = $this->inviteACLAwareRepository->countByUser($user);
$paginator = $this->paginator->create($total);
$invitations = $this->inviteACLAwareRepository->findByUser(
$user,
['createdAt' => 'DESC'],
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
dump($invitations);
$view = '@ChillCalendar/Invitations/listByUser.html.twig';
return $this->render($view, [
'invitations' => $invitations,
'paginator' => $paginator,
]);
}
}

View File

@@ -30,6 +30,13 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
'order' => 9,
'icon' => 'tasks',
]);
$menu->addChild('My invitations list', [
'route' => 'chill_calendar_invitations_list_my',
])
->setExtras([
'order' => 9,
'icon' => 'tasks',
]);
}
}

View File

@@ -0,0 +1,68 @@
<?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\Repository;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
readonly class InviteACLAwareRepository implements InviteACLAwareRepositoryInterface
{
public function __construct(private EntityManagerInterface $em) {}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countByUser(User $user): int
{
return $this->buildQueryByUser($user)
->select('COUNT(i)')
->getQuery()
->getSingleScalarResult();
}
public function findByUser(User $user, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array
{
$qb = $this->buildQueryByUser($user)
->select('i');
foreach ($orderBy as $sort => $order) {
$qb->addOrderBy('i.'.$sort, $order);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
if (null !== $limit) {
$qb->setMaxResults($limit);
}
return $qb->getQuery()->getResult();
}
public function buildQueryByUser(User $user): QueryBuilder
{
$qb = $this->em->createQueryBuilder()
->from(Invite::class, 'i');
$qb->where('i.user = :user');
$qb->setParameter('user', $user);
return $qb;
}
}

View File

@@ -0,0 +1,21 @@
<?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\Repository;
use Chill\MainBundle\Entity\User;
interface InviteACLAwareRepositoryInterface
{
public function countByUser(User $user): int;
public function findByUser(User $user, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array;
}

View File

@@ -70,8 +70,6 @@
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select

View File

@@ -32,8 +32,6 @@
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select
@@ -104,8 +102,7 @@
event.title
}}</b>
<b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr, "time") }} -
{{ formatDate(event.endStr, "time") }}:
>{{ formatDate(event.startStr) }} -
{{ event.extendedProps.locationName }}</b
>
<b v-else-if="event.extendedProps.is === 'local'">{{
@@ -297,26 +294,9 @@ const nextWeeks = computed((): Weeks[] =>
}),
);
const formatDate = (datetime: string, format: null | "time" = null) => {
const date = ISOToDate(datetime);
if (!date) return "";
if (format === "time") {
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}
// French date formatting
return date.toLocaleDateString("fr-FR", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const formatDate = (datetime: string) => {
console.log(typeof datetime);
return ISOToDate(datetime);
};
const baseOptions = ref<CalendarOptions>({

View File

@@ -0,0 +1,172 @@
{% if invitations|length > 0 %}
<div class="flex-table list-records">
{% for invitation in invitations %}
{% set calendar = invitation.getCalendar %}
{% if calendar is not null %}
<div class="item-bloc">
<div class="item-row main">
<div class="item-col">
<div class="wrap-header">
<div class="wl-row">
<div class="wl-col title">
<p class="date-label">
{% 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>
<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 -->
{% else %}
{% if calendar.smsStatus == 'sms_sent' %}
<span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<i class="fa fa-check "></i>
<i class="fa fa-envelope "></i>
</span>
{% else %}
<span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
<i class="fa fa-envelope "></i>
<i class="fa fa-hourglass-end "></i>
</span>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
{% endif %}
</ul>
</div>
</div>
</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>
</div>
{% endif %}
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div>
</div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %}
{% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
{% if calendar.activity is not null %}
<div class="item-row separator">
<div class="item-col">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div>
<div class="wl-col list activity-linked">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ calendar.activity.type.name | localize_translatable_string }}
{% if calendar.activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
<ul class="record_actions">
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="item-row separator">
<ul class="record_actions">
{% 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 %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_show', { 'id': calendar.id}) }}"
class="btn btn-show "></a>
</li>
</ul>
</div>
</div>
{% endif %}
{% endfor %}
{% if invitations|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,27 @@
{% extends "@ChillMain/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_invitations_list' %}
{% block title %}{{ 'My invitations list' |trans }}{% endblock title %}
{% block content %}
<h1>{{ 'Invitation list' |trans }}</h1>
{% if invitations|length == 0 %}
<p class="chill-no-data-statement">
{{ "There is no invitation items."|trans }}
</p>
{% else %}
{{ include ('@ChillCalendar/Invitations/_list_item.html.twig') }}
{% endif %}
{% endblock %}
{% block js %}
{{ parent() }}
{% endblock %}
{% block css %}
{{ parent() }}
{% endblock %}

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\Command;
use Chill\MainBundle\Security\RoleDumper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'chill:main:dump-list-permissions', description: 'Print a markdown reference of permissions (roles) grouped by title with dependencies).')]
final class DumpListPermissionsCommand extends Command
{
public function __construct(private readonly RoleDumper $roleDumper)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$markdown = $this->roleDumper->dumpAsMarkdown();
$output->writeln($markdown);
return Command::SUCCESS;
}
}

View File

@@ -48,7 +48,6 @@ class AbsenceController extends AbstractController
$user = $this->security->getUser();
$user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
$em = $this->managerRegistry->getManager();
$em->flush();

View File

@@ -24,7 +24,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
/**
* User.
@@ -46,8 +45,6 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceStart = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceEnd = null;
/**
* Array where SAML attributes's data are stored.
*/
@@ -160,11 +157,6 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this->absenceStart;
}
public function getAbsenceEnd(): ?\DateTimeImmutable
{
return $this->absenceEnd;
}
/**
* Get attributes.
*
@@ -344,13 +336,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
public function isAbsent(): bool
{
$now = new \DateTimeImmutable('now');
$absenceStart = $this->getAbsenceStart();
$absenceEnd = $this->getAbsenceEnd();
return null !== $absenceStart
&& $absenceStart <= $now
&& (null === $absenceEnd || $now <= $absenceEnd);
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new \DateTimeImmutable('now');
}
/**
@@ -424,11 +410,6 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
$this->absenceStart = $absenceStart;
}
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
{
$this->absenceEnd = $absenceEnd;
}
public function setAttributeByDomain(string $domain, string $key, $value): self
{
$this->attributes[$domain][$key] = $value;
@@ -694,16 +675,4 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
{
return 'fr';
}
#[Assert\Callback]
public function validateAbsenceDates(ExecutionContextInterface $context): void
{
if (null !== $this->getAbsenceEnd() && null === $this->getAbsenceStart()) {
$context->buildViolation(
'user.absence_end_requires_start'
)
->atPath('absenceEnd')
->addViolation();
}
}
}

View File

@@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\RegroupmentRepositoryInterface;
/**
* @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter?: string, formatter: array{form: array<string, mixed>, version: int}}
* @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter: string, formatter: array{form: array<string, mixed>, version: int}}
*/
class ExportConfigNormalizer
{

View File

@@ -23,14 +23,9 @@ class AbsenceType extends AbstractType
{
$builder
->add('absenceStart', ChillDateType::class, [
'required' => false,
'required' => true,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]);
}

View File

@@ -105,11 +105,6 @@ class UserType extends AbstractType
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]);
// @phpstan-ignore-next-line

View File

@@ -37,13 +37,8 @@ export const ISOToDate = (str: string | null): Date | null => {
return null;
}
// If the string already contains time info, use it directly
if (str.includes("T") || str.includes(" ")) {
return new Date(str);
}
// Otherwise, parse date only
const [year, month, day] = str.split("-").map((p) => parseInt(p));
return new Date(year, month - 1, day, 0, 0, 0, 0);
};
@@ -74,19 +69,20 @@ export const ISOToDatetime = (str: string | null): Date | null => {
*
*/
export const datetimeToISO = (date: Date): string => {
const cal = [
let cal, time, offset;
cal = [
date.getFullYear(),
(date.getMonth() + 1).toString().padStart(2, "0"),
date.getDate().toString().padStart(2, "0"),
].join("-");
const time = [
time = [
date.getHours().toString().padStart(2, "0"),
date.getMinutes().toString().padStart(2, "0"),
date.getSeconds().toString().padStart(2, "0"),
].join(":");
const offset = [
offset = [
date.getTimezoneOffset() <= 0 ? "+" : "-",
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
.toString()

View File

@@ -8,36 +8,36 @@
<div class="col-md-10">
<h2>{{ 'absence.My absence'|trans }}</h2>
<div>
{% if user.absenceStart is not null %}
<div class="alert alert-success flash_message">{{ 'absence.You are listed as absent, as of {date, date, short}'|trans({
date: user.absenceStart
}) }}
{% if user.absenceEnd is not null %}
{{ 'until %date%'|trans({'%date%': user.absenceEnd|format_date('short') }) }}
{% endif %}
<div>
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p>
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_main_user_absence_unset') }}"
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a>
</li>
</ul>
</div>
{% else %}
<div class="alert alert-warning flash_message">{{ 'absence.No absence listed'|trans }}</div>
{% endif %}
<div>
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p>
</div>
<div>
{{ form_start(form) }}
{{ form_row(form.absenceStart) }}
{{ form_row(form.absenceEnd) }}
<ul class="record_actions sticky-form-buttons">
<li>
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
</li>
<li>
<button class="btn btn-save" type="submit">
{{ 'Save'|trans }}
</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -79,7 +79,7 @@
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}

View File

@@ -1,86 +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\Security;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class RoleDumper
{
public function __construct(
private RoleProvider $roleProvider,
private RoleHierarchyInterface $roleHierarchy,
private TranslatorInterface $translator,
) {}
public function dumpAsMarkdown(): string
{
$roles = $this->roleProvider->getRoles();
$rolesWithoutScopes = $this->roleProvider->getRolesWithoutScopes();
// Group roles by title
$groups = [];
foreach ($roles as $role) {
$title = $this->roleProvider->getRoleTitle($role);
$title ??= 'Other';
$groups[$title][] = $role;
}
// Sort groups by title
ksort($groups, SORT_NATURAL | SORT_FLAG_CASE);
$lines = [];
foreach ($groups as $title => $roleList) {
// Sort roles by translated label for deterministic output
usort($roleList, function (string $a, string $b): int {
$ta = $this->translator->trans($a);
$tb = $this->translator->trans($b);
return strcasecmp($ta, $tb);
});
$translatedTitle = $this->translator->trans($title);
$lines[] = '## '.$translatedTitle;
foreach ($roleList as $role) {
// Translate primary role
$translatedRole = $this->translator->trans($role);
// Scope marker: (S) if needs scope, (~~S~~) if no scope required
$needsScope = !in_array($role, $rolesWithoutScopes, true);
$scopeMarker = $needsScope ? '(S)' : '(~~S~~)';
// Compute dependent roles from hierarchy (exclude itself)
$reachable = $this->roleHierarchy->getReachableRoleNames([$role]);
$dependents = array_values(array_filter($reachable, static fn (string $r): bool => $r !== $role));
// Translate dependents and sort deterministically
$translatedDependents = array_map(fn (string $r) => $this->translator->trans($r), $dependents);
sort($translatedDependents, SORT_NATURAL | SORT_FLAG_CASE);
if (count($translatedDependents) > 0) {
$lines[] = sprintf('- **%s** %s: %s', $translatedRole, $scopeMarker, implode(', ', $translatedDependents));
} else {
$lines[] = sprintf('- **%s** %s', $translatedRole, $scopeMarker);
}
}
// Add a blank line between groups
$lines[] = '';
}
// Trim possible trailing blank line
$markdown = rtrim(implode("\n", $lines));
return $markdown."\n"; // End with newline for POSIX friendliness
}
}

View File

@@ -52,8 +52,12 @@ class RoleProvider
/**
* Get the title for each role.
*
* @param string $role
*
* @return string the title of the role
*/
public function getRoleTitle(string $role): ?string
public function getRoleTitle($role)
{
$this->initializeRolesTitlesCache();
@@ -69,7 +73,7 @@ class RoleProvider
/**
* initialize the array for caching role and titles.
*/
private function initializeRolesTitlesCache(): void
private function initializeRolesTitlesCache()
{
// break if already initialized
if (null !== $this->rolesTitlesCache) {

View File

@@ -39,8 +39,6 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'label' => '',
'email' => '',
'isAbsent' => false,
'absenceStart' => null,
'absenceEnd' => null,
];
public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {}
@@ -79,11 +77,6 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read']
);
$absenceDatesContext = array_merge(
$context,
['docgen:expects' => \DateTimeImmutable::class, 'groups' => 'docgen:read']
);
if (null === $object && 'docgen' === $format) {
return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)];
}
@@ -106,8 +99,6 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext),
'isAbsent' => $object->isAbsent(),
'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext),
'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext),
];
if ('docgen' === $format) {

View File

@@ -67,36 +67,4 @@ class UserTest extends TestCase
->first()->getEndDate()
);
}
public function testIsAbsent()
{
$user = new User();
// Absent: today is within absence period
$absenceStart = new \DateTimeImmutable('-1 day');
$absenceEnd = new \DateTimeImmutable('+1 day');
$user->setAbsenceStart($absenceStart);
$user->setAbsenceEnd($absenceEnd);
self::assertTrue($user->isAbsent(), 'Should be absent when now is between start and end');
// Absent: end is null
$user->setAbsenceStart(new \DateTimeImmutable('-2 days'));
$user->setAbsenceEnd(null);
self::assertTrue($user->isAbsent(), 'Should be absent when started and no end');
// Not absent: absenceStart is in the future
$user->setAbsenceStart(new \DateTimeImmutable('+2 days'));
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is in the future');
// Not absent: absenceEnd is in the past
$user->setAbsenceStart(new \DateTimeImmutable('-5 days'));
$user->setAbsenceEnd(new \DateTimeImmutable('-1 day'));
self::assertFalse($user->isAbsent(), 'Should not be absent if end is in the past');
// Not absent: both are null
$user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is null');
}
}

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\Tests\Security;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\MainBundle\Security\RoleDumper;
use Chill\MainBundle\Security\RoleProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class RoleDumperTest extends TestCase
{
public function testDumpAsMarkdownGroupsByTitleTranslatesAndListsDependencies(): void
{
// Fake provider with two groups
$provider = new class () implements ProvideRoleHierarchyInterface {
public const R_PERSON_SEE = 'CHILL_PERSON_SEE';
public const R_PERSON_UPDATE = 'CHILL_PERSON_UPDATE';
public const R_REPORT_SEE = 'CHILL_REPORT_SEE';
public function getRoles(): array
{
return [self::R_PERSON_SEE, self::R_PERSON_UPDATE, self::R_REPORT_SEE];
}
public function getRolesWithoutScope(): array
{
// In this test, assume REPORT_SEE does not need scope, others do
return [self::R_REPORT_SEE];
}
public function getRolesWithHierarchy(): array
{
return [
'Person' => [self::R_PERSON_SEE, self::R_PERSON_UPDATE],
'Report' => [self::R_REPORT_SEE],
];
}
};
$roleProvider = new RoleProvider([$provider]);
// Fake role hierarchy: UPDATE implies SEE; others none
$roleHierarchy = new class () implements RoleHierarchyInterface {
public function getReachableRoleNames(array $roles): array
{
$output = [];
foreach ($roles as $r) {
$output[] = $r;
if ('CHILL_PERSON_UPDATE' === $r) {
$output[] = 'CHILL_PERSON_SEE';
}
}
return array_values(array_unique($output));
}
};
// Fake translator that clearly shows translation applied
$translator = new class () implements TranslatorInterface {
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
return 'T('.$id.')';
}
public function getLocale(): string
{
return 'en';
}
};
$dumper = new RoleDumper($roleProvider, $roleHierarchy, $translator);
$md = $dumper->dumpAsMarkdown();
$expected = "## T(Person)\n"
."- **T(CHILL_PERSON_SEE)** (S)\n"
."- **T(CHILL_PERSON_UPDATE)** (S): T(CHILL_PERSON_SEE)\n\n"
."## T(Report)\n"
."- **T(CHILL_REPORT_SEE)** (~~S~~)\n";
self::assertSame($expected, $md);
}
}

View File

@@ -101,8 +101,6 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'SomeUser',
'isAbsent' => false,
'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]];
yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class],
@@ -122,8 +120,6 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'AnotherUser',
'isAbsent' => false,
'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]];
yield [null, 'docgen', ['docgen:expects' => User::class], [
@@ -142,8 +138,6 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => '',
'isAbsent' => false,
'main_center' => ['context' => Center::class],
'absenceStart' => null,
'absenceEnd' => null,
]];
}
}

View File

@@ -80,7 +80,3 @@ services:
Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand:
tags:
- {name: console.command}
Chill\MainBundle\Command\DumpListPermissionsCommand:
autoconfigure: true
autowire: true

View File

@@ -1,34 +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\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250722140048 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add an absence end date for the user absence';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD absenceEnd TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN users.absenceEnd IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP absenceEnd');
}
}

View File

@@ -136,7 +136,3 @@ filter_order:
Search: Chercher dans la liste
By date: Filtrer par date
search_box: Filtrer par contenu
absence:
You are listed as absent, as of {date, date, short}: Votre absence est indiquée à partir du {date, date, short}

View File

@@ -841,12 +841,12 @@ absence:
# single letter for absence
A: A
My absence: Mon absence
Unset absence: Supprimer mes dates d'absence
Unset absence: Supprimer la date d'absence
Set absence date: Indiquer une date d'absence
Absence start: Absent à partir du
Absence end: Jusqu'au
Absent: Absent
You are marked as being absent: Vous êtes indiqué absent.
You are listed as absent, as of: Votre absence est indiquée à partir du
No absence listed: Aucune absence indiquée.
Is absent: Absent?

View File

@@ -40,7 +40,3 @@ workflow:
rolling_date:
When fixed date is selected, you must provide a date: Indiquez la date fixe choisie
user:
absence_end_requires_start: "Vous ne pouvez pas renseigner une date de fin d'absence sans date de début."

View File

@@ -78,7 +78,7 @@ class AccompanyingPeriodWorkDuplicateController extends AbstractController
* @ParamConverter("acpw1", options={"id": "acpw1_id"})
* @ParamConverter("acpw2", options={"id": "acpw2_id"})
*/
#[Route(path: '/{_locale}/person/{acpw1_id}/acpw-duplicate/{acpw2_id}/confirm', name: 'chill_person_acpw_duplicate_confirm')]
#[Route(path: '/{_locale}/person/{acpw1_id}/duplicate/{acpw2_id}/confirm', name: 'chill_person_acpw_duplicate_confirm')]
public function confirmAction(AccompanyingPeriodWork $acpw1, AccompanyingPeriodWork $acpw2, Request $request)
{
$accompanyingPeriod = $acpw1->getAccompanyingPeriod();