feat: Permettre la consultation et gestion des droits à l'image des élèves

Les administrateurs et enseignants ont besoin de consulter et gérer
les autorisations de droit à l'image des élèves pour respecter
la réglementation lors de publications contenant des photos (FR82).

Cette fonctionnalité ajoute une page dédiée avec liste filtrable
par statut, modification individuelle via dropdown, export CSV
avec BOM UTF-8 pour Excel, et préparation du système d'avertissement
avant publication (query/handler prêts, intégration à faire quand
le module publication existera).

Le filtrage par classe (AC2) est bloqué en attente d'une table
d'affectation élève↔classe qui n'existe pas encore.
This commit is contained in:
2026-02-19 13:35:14 +01:00
parent 67734e4de3
commit 1b8bd6cd78
39 changed files with 3264 additions and 19 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateImageRights;
final readonly class UpdateImageRightsCommand
{
public function __construct(
public string $studentId,
public string $status,
public string $modifiedBy,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateImageRights;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateImageRightsHandler
{
public function __construct(
private UserRepository $userRepository,
private Clock $clock,
) {
}
public function __invoke(UpdateImageRightsCommand $command): User
{
$studentId = UserId::fromString($command->studentId);
$user = $this->userRepository->get($studentId);
if (!$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
throw UserNotFoundException::withId($studentId);
}
if (!$user->aLeRole(Role::ELEVE)) {
throw UserNotFoundException::withId($studentId);
}
$modifierId = UserId::fromString($command->modifiedBy);
$status = ImageRightsStatus::from($command->status);
$user->modifierDroitImage($status, $modifierId, $this->clock->now());
$this->userRepository->save($user);
return $user;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\CheckImageRights;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class CheckImageRightsHandler
{
public function __construct(
private UserRepository $userRepository,
) {
}
public function __invoke(CheckImageRightsQuery $query): ImageRightsCheckResult
{
$studentId = UserId::fromString($query->studentId);
$user = $this->userRepository->get($studentId);
if (!$user->tenantId->equals(TenantId::fromString($query->tenantId))) {
throw UserNotFoundException::withId($studentId);
}
$status = $user->imageRightsStatus;
return new ImageRightsCheckResult(
status: $status,
canPublish: $status->estAutorise(),
warningMessage: match ($status) {
ImageRightsStatus::REFUSED => "ATTENTION : Cet élève n'a PAS l'autorisation de droit à l'image. Publication interdite.",
ImageRightsStatus::NOT_SPECIFIED => "Attention : Le statut droit à l'image n'est pas renseigné pour cet élève.",
ImageRightsStatus::AUTHORIZED => null,
},
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\CheckImageRights;
final readonly class CheckImageRightsQuery
{
public function __construct(
public string $studentId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\CheckImageRights;
use App\Administration\Domain\Model\User\ImageRightsStatus;
final readonly class ImageRightsCheckResult
{
public function __construct(
public ImageRightsStatus $status,
public bool $canPublish,
public ?string $warningMessage,
) {
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentsImageRightsHandler
{
public function __construct(
private UserRepository $userRepository,
) {
}
/**
* @return StudentImageRightsDto[]
*/
public function __invoke(GetStudentsImageRightsQuery $query): array
{
$students = $this->userRepository->findStudentsByTenant(
TenantId::fromString($query->tenantId),
);
if ($query->status !== null) {
$filterStatus = ImageRightsStatus::tryFrom($query->status);
if ($filterStatus !== null) {
$students = array_filter(
$students,
static fn ($user) => $user->imageRightsStatus === $filterStatus,
);
}
}
return array_values(array_map(
static fn ($user) => StudentImageRightsDto::fromDomain($user),
$students,
));
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights;
final readonly class GetStudentsImageRightsQuery
{
public function __construct(
public string $tenantId,
public ?string $status = null,
) {
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Domain\Model\User\User;
use DateTimeImmutable;
final readonly class StudentImageRightsDto
{
public function __construct(
public string $id,
public string $firstName,
public string $lastName,
public string $email,
public string $imageRightsStatus,
public string $imageRightsStatusLabel,
public ?DateTimeImmutable $imageRightsUpdatedAt,
public ?string $className,
) {
}
public static function fromDomain(User $user, ?string $className = null): self
{
return new self(
id: (string) $user->id,
firstName: $user->firstName,
lastName: $user->lastName,
email: (string) $user->email,
imageRightsStatus: $user->imageRightsStatus->value,
imageRightsStatusLabel: $user->imageRightsStatus->label(),
imageRightsUpdatedAt: $user->imageRightsUpdatedAt,
className: $className,
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
use RuntimeException;
final readonly class ImageRightsExporter
{
/**
* @param StudentImageRightsDto[] $students
*/
public function export(array $students): string
{
$handle = fopen('php://temp', 'r+');
if ($handle === false) {
throw new RuntimeException('Impossible d\'ouvrir le flux CSV.');
}
fputcsv($handle, ['Nom', 'Prénom', 'Classe', 'Statut'], separator: ';', escape: '\\');
foreach ($students as $student) {
fputcsv($handle, [
$student->lastName,
$student->firstName,
$student->className ?? '',
$student->imageRightsStatusLabel,
], separator: ';', escape: '\\');
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
// BOM UTF-8 pour que Excel Windows affiche correctement les accents
return "\xEF\xBB\xBF" . ($csv !== false ? $csv : '');
}
}