feat: Permettre aux administrateurs de configurer les règles de devoirs
Les établissements ont besoin de protéger les élèves et familles des devoirs de dernière minute. Cette configuration au niveau tenant permet de définir des règles de timing (délai minimum, pas de devoir pour lundi après une heure limite) et un mode d'application (avertissement, blocage ou désactivé). Le service de validation est prêt pour être branché dans le flux de création de devoirs (Stories 5.4/5.5). L'historique des changements assure la traçabilité des modifications de configuration.
This commit is contained in:
2
Makefile
2
Makefile
@@ -125,7 +125,7 @@ setup-test-db: ## Créer et migrer la base de test PostgreSQL
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction --env=test -q
|
||||
|
||||
.PHONY: test-php
|
||||
test-php: setup-test-db ## Lancer les tests PHPUnit
|
||||
test-php: ## Lancer les tests PHPUnit
|
||||
docker compose exec -e APP_ENV=test php composer test
|
||||
|
||||
.PHONY: warmup
|
||||
|
||||
@@ -264,6 +264,13 @@ services:
|
||||
App\Administration\Domain\Repository\SchoolBrandingRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolBrandingRepository
|
||||
|
||||
# Homework Rules Repository
|
||||
App\Administration\Domain\Repository\HomeworkRulesRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRulesRepository
|
||||
|
||||
App\Administration\Domain\Repository\HomeworkRulesHistoryRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRulesHistoryRepository
|
||||
|
||||
App\Administration\Application\Port\LogoStorage:
|
||||
alias: App\Administration\Infrastructure\Storage\LocalLogoStorage
|
||||
|
||||
|
||||
55
backend/migrations/Version20260317100224.php
Normal file
55
backend/migrations/Version20260317100224.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260317100224 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create homework_rules and homework_rules_history tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE homework_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL UNIQUE,
|
||||
rules JSONB NOT NULL DEFAULT '[]',
|
||||
enforcement_mode VARCHAR(20) DEFAULT 'soft' NOT NULL,
|
||||
enabled BOOLEAN DEFAULT true NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_homework_rules_tenant ON homework_rules(tenant_id)');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE homework_rules_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
previous_rules JSONB,
|
||||
new_rules JSONB NOT NULL,
|
||||
enforcement_mode VARCHAR(20) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
changed_by UUID REFERENCES users(id),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_homework_rules_history_tenant ON homework_rules_history(tenant_id)');
|
||||
$this->addSql('CREATE INDEX idx_homework_rules_history_changed_at ON homework_rules_history(changed_at DESC)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS homework_rules_history');
|
||||
$this->addSql('DROP TABLE IF EXISTS homework_rules');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateHomeworkRules;
|
||||
|
||||
/**
|
||||
* Commande pour mettre à jour les règles de devoirs d'un établissement.
|
||||
*
|
||||
* @see FR81: Configurer règles de timing des devoirs
|
||||
*/
|
||||
final readonly class UpdateHomeworkRulesCommand
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{type: string, params: array<string, mixed>}> $rules
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public array $rules,
|
||||
public string $enforcementMode,
|
||||
public bool $enabled,
|
||||
public string $changedBy,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateHomeworkRules;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Orchestre la mise à jour des règles de devoirs.
|
||||
*
|
||||
* Crée la configuration si elle n'existe pas encore pour le tenant.
|
||||
* Enregistre l'historique des changements pour l'audit (AC6).
|
||||
*
|
||||
* L'historique est conditionné sur un changement effectif (comparaison avant/après).
|
||||
* Les domain events ne sont PAS consommés ici — ils restent disponibles pour
|
||||
* le Processor qui les dispatche sur l'event bus.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateHomeworkRulesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkRulesRepository $homeworkRulesRepository,
|
||||
private HomeworkRulesHistoryRepository $historyRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdateHomeworkRulesCommand $command): HomeworkRules
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$enforcementMode = EnforcementMode::from($command->enforcementMode);
|
||||
$now = $this->clock->now();
|
||||
$rules = array_map(HomeworkRule::fromArray(...), $command->rules);
|
||||
|
||||
$homeworkRules = $this->homeworkRulesRepository->findByTenantId($tenantId);
|
||||
|
||||
// Capturer l'état précédent pour l'historique.
|
||||
// null = première configuration du tenant (aucune règle avant).
|
||||
$previousRules = $homeworkRules?->rules;
|
||||
$previousMode = $homeworkRules?->enforcementMode;
|
||||
$previousEnabled = $homeworkRules?->enabled;
|
||||
|
||||
if ($homeworkRules === null) {
|
||||
$homeworkRules = HomeworkRules::creer(tenantId: $tenantId, now: $now);
|
||||
}
|
||||
|
||||
$homeworkRules->mettreAJour(
|
||||
rules: $rules,
|
||||
enforcementMode: $enforcementMode,
|
||||
enabled: $command->enabled,
|
||||
now: $now,
|
||||
);
|
||||
|
||||
$this->homeworkRulesRepository->save($homeworkRules);
|
||||
|
||||
// Enregistrer l'historique uniquement si quelque chose a changé.
|
||||
// On compare l'état avant/après au lieu de consommer pullDomainEvents(),
|
||||
// car le Processor en a besoin pour dispatcher sur l'event bus.
|
||||
$changed = $previousRules === null
|
||||
|| $previousMode !== $enforcementMode
|
||||
|| $previousEnabled !== $command->enabled
|
||||
|| !$this->rulesEqual($previousRules, $rules);
|
||||
|
||||
if ($changed) {
|
||||
$this->historyRepository->record(
|
||||
tenantId: $tenantId,
|
||||
previousRules: $previousRules,
|
||||
newRules: $rules,
|
||||
enforcementMode: $enforcementMode,
|
||||
enabled: $command->enabled,
|
||||
changedBy: UserId::fromString($command->changedBy),
|
||||
changedAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
return $homeworkRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param HomeworkRule[] $a
|
||||
* @param HomeworkRule[] $b
|
||||
*/
|
||||
private function rulesEqual(array $a, array $b): bool
|
||||
{
|
||||
if (count($a) !== count($b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($a as $i => $rule) {
|
||||
if (!isset($b[$i]) || !$rule->equals($b[$i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* Résultat de la validation des règles de devoirs.
|
||||
*
|
||||
* Selon le mode d'application, les violations sont des warnings (soft)
|
||||
* ou des erreurs bloquantes (hard).
|
||||
*/
|
||||
final readonly class HomeworkRulesValidationResult
|
||||
{
|
||||
/**
|
||||
* @param RuleViolation[] $violations
|
||||
*/
|
||||
public function __construct(
|
||||
public array $violations,
|
||||
public EnforcementMode $enforcementMode,
|
||||
) {
|
||||
}
|
||||
|
||||
public function estValide(): bool
|
||||
{
|
||||
return count($this->violations) === 0;
|
||||
}
|
||||
|
||||
public function estBloquant(): bool
|
||||
{
|
||||
return !$this->estValide() && $this->enforcementMode->estBloquant();
|
||||
}
|
||||
|
||||
public function estAvertissement(): bool
|
||||
{
|
||||
return !$this->estValide() && !$this->enforcementMode->estBloquant();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (RuleViolation $v): string => $v->message,
|
||||
$this->violations,
|
||||
);
|
||||
}
|
||||
|
||||
public static function ok(EnforcementMode $mode): self
|
||||
{
|
||||
return new self(violations: [], enforcementMode: $mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use DateTimeImmutable;
|
||||
use LogicException;
|
||||
|
||||
use function sprintf;
|
||||
use function strtolower;
|
||||
|
||||
/**
|
||||
* Valide une date d'échéance de devoir contre les règles configurées.
|
||||
*
|
||||
* @see FR81: Configurer règles de timing des devoirs
|
||||
*/
|
||||
final readonly class HomeworkRulesValidator
|
||||
{
|
||||
/**
|
||||
* Valide si un devoir peut être créé avec la date d'échéance donnée.
|
||||
*/
|
||||
public function valider(
|
||||
HomeworkRules $rules,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesValidationResult {
|
||||
if (!$rules->estActif()) {
|
||||
return HomeworkRulesValidationResult::ok($rules->enforcementMode);
|
||||
}
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($rules->rules as $rule) {
|
||||
$violation = $this->validerRegle($rule, $dueDate, $creationDate);
|
||||
if ($violation !== null) {
|
||||
$violations[] = $violation;
|
||||
}
|
||||
}
|
||||
|
||||
return new HomeworkRulesValidationResult(
|
||||
violations: $violations,
|
||||
enforcementMode: $rules->enforcementMode,
|
||||
);
|
||||
}
|
||||
|
||||
private function validerRegle(
|
||||
HomeworkRule $rule,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): ?RuleViolation {
|
||||
return match ($rule->type) {
|
||||
RuleType::MINIMUM_DELAY => $this->validerDelaiMinimum($rule, $dueDate, $creationDate),
|
||||
RuleType::NO_MONDAY_AFTER => $this->validerPasLundiApres($rule, $dueDate, $creationDate),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AC3 : Configuration "3 jours" → devoir pour vendredi ne peut pas être créé après mardi.
|
||||
*/
|
||||
private function validerDelaiMinimum(
|
||||
HomeworkRule $rule,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): ?RuleViolation {
|
||||
/** @var int|string $rawDays */
|
||||
$rawDays = $rule->params['days'];
|
||||
$delayDays = (int) $rawDays;
|
||||
|
||||
$deadline = $dueDate->modify(sprintf('-%d days', $delayDays));
|
||||
$creationDay = $creationDate->format('Y-m-d');
|
||||
$deadlineDay = $deadline->format('Y-m-d');
|
||||
|
||||
if ($creationDay > $deadlineDay) {
|
||||
return new RuleViolation(
|
||||
ruleType: RuleType::MINIMUM_DELAY,
|
||||
message: sprintf(
|
||||
'Le devoir doit être créé au moins %d jours avant l\'échéance. Date limite de création : %s.',
|
||||
$delayDays,
|
||||
$deadline->format('d/m/Y'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AC4 : Configuration "après vendredi 12h" → devoir pour lundi ne peut pas être créé après vendredi midi.
|
||||
*/
|
||||
private function validerPasLundiApres(
|
||||
HomeworkRule $rule,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): ?RuleViolation {
|
||||
$dueDayOfWeek = (int) $dueDate->format('N');
|
||||
|
||||
// Règle ne s'applique que si le devoir est pour lundi (jour 1)
|
||||
if ($dueDayOfWeek !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var string $rawDay */
|
||||
$rawDay = $rule->params['day'];
|
||||
$cutoffDay = strtolower($rawDay);
|
||||
/** @var string $cutoffTime */
|
||||
$cutoffTime = $rule->params['time'];
|
||||
|
||||
$cutoffDayNumber = $this->dayNameToNumber($cutoffDay);
|
||||
|
||||
// Trouver le jour cutoff de la semaine précédente par rapport au lundi
|
||||
$daysDiff = $dueDayOfWeek - $cutoffDayNumber;
|
||||
if ($daysDiff <= 0) {
|
||||
$daysDiff += 7;
|
||||
}
|
||||
|
||||
$cutoffDate = $dueDate->modify(sprintf('-%d days', $daysDiff));
|
||||
$cutoffDateTime = new DateTimeImmutable(
|
||||
$cutoffDate->format('Y-m-d') . ' ' . $cutoffTime,
|
||||
);
|
||||
|
||||
if ($creationDate > $cutoffDateTime) {
|
||||
return new RuleViolation(
|
||||
ruleType: RuleType::NO_MONDAY_AFTER,
|
||||
message: sprintf(
|
||||
'Les devoirs pour lundi ne peuvent pas être créés après %s %s.',
|
||||
$this->dayLabel($cutoffDay),
|
||||
$cutoffTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function dayNameToNumber(string $day): int
|
||||
{
|
||||
return match ($day) {
|
||||
'monday' => 1,
|
||||
'tuesday' => 2,
|
||||
'wednesday' => 3,
|
||||
'thursday' => 4,
|
||||
'friday' => 5,
|
||||
'saturday' => 6,
|
||||
'sunday' => 7,
|
||||
default => throw new LogicException(sprintf('Jour invalide "%s" — HomeworkRule aurait dû rejeter cette valeur.', $day)),
|
||||
};
|
||||
}
|
||||
|
||||
private function dayLabel(string $day): string
|
||||
{
|
||||
return match ($day) {
|
||||
'monday' => 'lundi',
|
||||
'tuesday' => 'mardi',
|
||||
'wednesday' => 'mercredi',
|
||||
'thursday' => 'jeudi',
|
||||
'friday' => 'vendredi',
|
||||
'saturday' => 'samedi',
|
||||
'sunday' => 'dimanche',
|
||||
default => throw new LogicException(sprintf('Jour invalide "%s" — HomeworkRule aurait dû rejeter cette valeur.', $day)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
|
||||
/**
|
||||
* Représente une violation d'une règle de devoirs.
|
||||
*/
|
||||
final readonly class RuleViolation
|
||||
{
|
||||
public function __construct(
|
||||
public RuleType $ruleType,
|
||||
public string $message,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRulesId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la modification des règles de devoirs d'un établissement.
|
||||
*
|
||||
* Porte l'état avant/après pour permettre l'historique des changements (AC6).
|
||||
*/
|
||||
final readonly class ReglesDevoirsModifiees implements DomainEvent
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{type: string, params: array<string, mixed>}> $previousRules
|
||||
* @param array<int, array{type: string, params: array<string, mixed>}> $newRules
|
||||
*/
|
||||
public function __construct(
|
||||
public HomeworkRulesId $homeworkRulesId,
|
||||
public TenantId $tenantId,
|
||||
public array $previousRules,
|
||||
public array $newRules,
|
||||
public EnforcementMode $enforcementMode,
|
||||
public bool $enabled,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->homeworkRulesId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use DomainException;
|
||||
|
||||
use function implode;
|
||||
use function sprintf;
|
||||
|
||||
final class HomeworkRuleParamsInvalidException extends DomainException
|
||||
{
|
||||
/**
|
||||
* @param string[] $missing
|
||||
*/
|
||||
public static function missingParams(RuleType $type, array $missing): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La règle "%s" requiert les paramètres manquants : %s.',
|
||||
$type->value,
|
||||
implode(', ', $missing),
|
||||
));
|
||||
}
|
||||
|
||||
public static function invalidValue(RuleType $type, string $param, string $reason): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La règle "%s", paramètre "%s" : %s.',
|
||||
$type->value,
|
||||
$param,
|
||||
$reason,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class HomeworkRulesNotFoundException extends DomainException
|
||||
{
|
||||
public static function pourTenant(TenantId $tenantId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Aucune configuration de règles de devoirs trouvée pour le tenant "%s".',
|
||||
$tenantId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
/**
|
||||
* Mode d'application des règles de devoirs.
|
||||
*
|
||||
* - SOFT : avertissement uniquement, l'enseignant peut passer outre
|
||||
* - HARD : blocage, impossible de créer le devoir
|
||||
* - DISABLED : aucune vérification
|
||||
*
|
||||
* @see FR81: Configurer règles de timing des devoirs
|
||||
*/
|
||||
enum EnforcementMode: string
|
||||
{
|
||||
case SOFT = 'soft';
|
||||
case HARD = 'hard';
|
||||
case DISABLED = 'disabled';
|
||||
|
||||
/**
|
||||
* Détermine si les règles doivent être vérifiées dans ce mode.
|
||||
*/
|
||||
public function estActif(): bool
|
||||
{
|
||||
return $this !== self::DISABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si les violations doivent bloquer la création du devoir.
|
||||
*/
|
||||
public function estBloquant(): bool
|
||||
{
|
||||
return $this === self::HARD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé utilisateur en français.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SOFT => 'Avertissement',
|
||||
self::HARD => 'Blocage',
|
||||
self::DISABLED => 'Désactivé',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
use App\Administration\Domain\Exception\HomeworkRuleParamsInvalidException;
|
||||
|
||||
use function array_diff;
|
||||
use function array_keys;
|
||||
use function count;
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* Définition d'une règle individuelle de devoirs.
|
||||
*
|
||||
* Value Object immutable contenant le type de règle et ses paramètres.
|
||||
* Chaque type de règle impose ses propres paramètres requis.
|
||||
*
|
||||
* @see FR81: Configurer règles de timing des devoirs
|
||||
*/
|
||||
final readonly class HomeworkRule
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function __construct(
|
||||
public RuleType $type,
|
||||
public array $params,
|
||||
) {
|
||||
$missing = array_diff($type->requiredParams(), array_keys($params));
|
||||
if (count($missing) > 0) {
|
||||
throw HomeworkRuleParamsInvalidException::missingParams($type, $missing);
|
||||
}
|
||||
|
||||
self::validateValues($type, $params);
|
||||
}
|
||||
|
||||
private const array VALID_DAYS = [
|
||||
'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private static function validateValues(RuleType $type, array $params): void
|
||||
{
|
||||
match ($type) {
|
||||
RuleType::MINIMUM_DELAY => self::validateMinimumDelay($params),
|
||||
RuleType::NO_MONDAY_AFTER => self::validateNoMondayAfter($params),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private static function validateMinimumDelay(array $params): void
|
||||
{
|
||||
$days = $params['days'];
|
||||
|
||||
if (!is_int($days) || $days < 1 || $days > 30) {
|
||||
throw HomeworkRuleParamsInvalidException::invalidValue(
|
||||
RuleType::MINIMUM_DELAY,
|
||||
'days',
|
||||
'doit être un entier entre 1 et 30',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private static function validateNoMondayAfter(array $params): void
|
||||
{
|
||||
$day = $params['day'];
|
||||
|
||||
if (!is_string($day) || !in_array($day, self::VALID_DAYS, true)) {
|
||||
throw HomeworkRuleParamsInvalidException::invalidValue(
|
||||
RuleType::NO_MONDAY_AFTER,
|
||||
'day',
|
||||
'doit être un jour valide en anglais (monday, tuesday, ...)',
|
||||
);
|
||||
}
|
||||
|
||||
$time = $params['time'];
|
||||
|
||||
if (!is_string($time) || preg_match('/^\d{2}:\d{2}$/', $time) !== 1) {
|
||||
throw HomeworkRuleParamsInvalidException::invalidValue(
|
||||
RuleType::NO_MONDAY_AFTER,
|
||||
'time',
|
||||
'doit être au format HH:MM',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->type === $other->type && $this->params === $other->params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type: string, params: array<string, mixed>}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'params' => $this->params,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type: string, params: array<string, mixed>} $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::from($data['type']),
|
||||
params: $data['params'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
use App\Administration\Domain\Event\ReglesDevoirsModifiees;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant la configuration des règles de devoirs d'un établissement.
|
||||
*
|
||||
* Chaque établissement dispose d'une unique configuration de règles (1:1 avec Tenant).
|
||||
* Les règles protègent les élèves et familles des devoirs de dernière minute.
|
||||
*
|
||||
* @see FR81: Configurer règles de timing des devoirs
|
||||
*/
|
||||
final class HomeworkRules extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
/**
|
||||
* @param HomeworkRule[] $rules
|
||||
*/
|
||||
private function __construct(
|
||||
public private(set) HomeworkRulesId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) array $rules,
|
||||
public private(set) EnforcementMode $enforcementMode,
|
||||
public private(set) bool $enabled,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une configuration de règles par défaut pour un établissement.
|
||||
*
|
||||
* Par défaut : aucune règle active, mode soft, activé.
|
||||
*/
|
||||
public static function creer(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
return new self(
|
||||
id: HomeworkRulesId::generate(),
|
||||
tenantId: $tenantId,
|
||||
rules: [],
|
||||
enforcementMode: EnforcementMode::SOFT,
|
||||
enabled: true,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les règles, le mode d'application et l'état d'activation.
|
||||
*
|
||||
* @param HomeworkRule[] $rules
|
||||
*/
|
||||
public function mettreAJour(
|
||||
array $rules,
|
||||
EnforcementMode $enforcementMode,
|
||||
bool $enabled,
|
||||
DateTimeImmutable $now,
|
||||
): void {
|
||||
$rulesChanged = !$this->rulesEqual($rules);
|
||||
$modeChanged = $this->enforcementMode !== $enforcementMode;
|
||||
$enabledChanged = $this->enabled !== $enabled;
|
||||
|
||||
if (!$rulesChanged && !$modeChanged && !$enabledChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previousRules = $this->rules;
|
||||
$this->rules = $rules;
|
||||
$this->enforcementMode = $enforcementMode;
|
||||
$this->enabled = $enabled;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
$this->recordEvent(new ReglesDevoirsModifiees(
|
||||
homeworkRulesId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
previousRules: array_map(
|
||||
static fn (HomeworkRule $rule): array => $rule->toArray(),
|
||||
$previousRules,
|
||||
),
|
||||
newRules: array_map(
|
||||
static fn (HomeworkRule $rule): array => $rule->toArray(),
|
||||
$rules,
|
||||
),
|
||||
enforcementMode: $enforcementMode,
|
||||
enabled: $enabled,
|
||||
occurredOn: $now,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Désactive complètement les règles.
|
||||
*
|
||||
* Les enseignants peuvent créer des devoirs librement.
|
||||
*/
|
||||
public function desactiver(DateTimeImmutable $now): void
|
||||
{
|
||||
$this->mettreAJour(
|
||||
rules: $this->rules,
|
||||
enforcementMode: EnforcementMode::DISABLED,
|
||||
enabled: false,
|
||||
now: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Réactive les règles avec le mode précédemment configuré.
|
||||
*/
|
||||
public function activer(EnforcementMode $mode, DateTimeImmutable $now): void
|
||||
{
|
||||
$this->mettreAJour(
|
||||
rules: $this->rules,
|
||||
enforcementMode: $mode,
|
||||
enabled: true,
|
||||
now: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si les règles sont actives et applicables.
|
||||
*/
|
||||
public function estActif(): bool
|
||||
{
|
||||
return $this->enabled && $this->enforcementMode->estActif();
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*
|
||||
* @param HomeworkRule[] $rules
|
||||
*/
|
||||
public static function reconstitute(
|
||||
HomeworkRulesId $id,
|
||||
TenantId $tenantId,
|
||||
array $rules,
|
||||
EnforcementMode $enforcementMode,
|
||||
bool $enabled,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
): self {
|
||||
$homeworkRules = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
rules: $rules,
|
||||
enforcementMode: $enforcementMode,
|
||||
enabled: $enabled,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$homeworkRules->updatedAt = $updatedAt;
|
||||
|
||||
return $homeworkRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param HomeworkRule[] $other
|
||||
*/
|
||||
private function rulesEqual(array $other): bool
|
||||
{
|
||||
if (count($this->rules) !== count($other)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->rules as $i => $rule) {
|
||||
if (!isset($other[$i]) || !$rule->equals($other[$i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class HomeworkRulesId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
/**
|
||||
* Type de règle de devoirs configurable par l'établissement.
|
||||
*
|
||||
* Extensible : de nouveaux types pourront être ajoutés (V2)
|
||||
* sans impacter les règles existantes.
|
||||
*
|
||||
* @see FR81: Configurer règles de timing des devoirs
|
||||
*/
|
||||
enum RuleType: string
|
||||
{
|
||||
case MINIMUM_DELAY = 'minimum_delay';
|
||||
case NO_MONDAY_AFTER = 'no_monday_after';
|
||||
|
||||
/**
|
||||
* Clés de paramètres attendues pour ce type de règle.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function requiredParams(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::MINIMUM_DELAY => ['days'],
|
||||
self::NO_MONDAY_AFTER => ['day', 'time'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé utilisateur en français.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MINIMUM_DELAY => 'Délai minimum avant échéance',
|
||||
self::NO_MONDAY_AFTER => 'Pas de devoir pour lundi après un horaire',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface HomeworkRulesHistoryRepository
|
||||
{
|
||||
/**
|
||||
* @param HomeworkRule[]|null $previousRules
|
||||
* @param HomeworkRule[] $newRules
|
||||
*/
|
||||
public function record(
|
||||
TenantId $tenantId,
|
||||
?array $previousRules,
|
||||
array $newRules,
|
||||
EnforcementMode $enforcementMode,
|
||||
bool $enabled,
|
||||
UserId $changedBy,
|
||||
DateTimeImmutable $changedAt,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* @return array<array{
|
||||
* id: string,
|
||||
* previous_rules: string|null,
|
||||
* new_rules: string,
|
||||
* enforcement_mode: string,
|
||||
* enabled: bool,
|
||||
* changed_by: string,
|
||||
* changed_at: string,
|
||||
* }>
|
||||
*/
|
||||
public function findByTenant(TenantId $tenantId, int $limit = 50): array;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Exception\HomeworkRulesNotFoundException;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface HomeworkRulesRepository
|
||||
{
|
||||
public function save(HomeworkRules $homeworkRules): void;
|
||||
|
||||
/** @throws HomeworkRulesNotFoundException */
|
||||
public function get(TenantId $tenantId): HomeworkRules;
|
||||
|
||||
public function findByTenantId(TenantId $tenantId): ?HomeworkRules;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UpdateHomeworkRules\UpdateHomeworkRulesCommand;
|
||||
use App\Administration\Application\Command\UpdateHomeworkRules\UpdateHomeworkRulesHandler;
|
||||
use App\Administration\Domain\Exception\HomeworkRuleParamsInvalidException;
|
||||
use App\Administration\Infrastructure\Api\Resource\HomeworkRulesResource;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* Processor pour la mise à jour des règles de devoirs.
|
||||
*
|
||||
* @implements ProcessorInterface<HomeworkRulesResource, HomeworkRulesResource>
|
||||
*/
|
||||
final readonly class UpdateHomeworkRulesProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateHomeworkRulesHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param HomeworkRulesResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkRulesResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedHttpException('Seuls les administrateurs peuvent modifier les règles de devoirs.');
|
||||
}
|
||||
|
||||
try {
|
||||
$command = new UpdateHomeworkRulesCommand(
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
rules: $data->rules ?? [],
|
||||
enforcementMode: $data->enforcementMode ?? 'soft',
|
||||
enabled: $data->enabled ?? true,
|
||||
changedBy: $user->userId(),
|
||||
);
|
||||
|
||||
$homeworkRules = ($this->handler)($command);
|
||||
|
||||
foreach ($homeworkRules->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return HomeworkRulesResource::fromDomain($homeworkRules);
|
||||
} catch (HomeworkRuleParamsInvalidException|ValueError $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\HomeworkRulesHistoryResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function is_string;
|
||||
use function json_decode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour l'historique des modifications des règles de devoirs.
|
||||
*
|
||||
* @implements ProviderInterface<HomeworkRulesHistoryResource>
|
||||
*/
|
||||
final readonly class HomeworkRulesHistoryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkRulesHistoryRepository $historyRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HomeworkRulesHistoryResource[]
|
||||
*/
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedHttpException('Seuls les administrateurs peuvent voir l\'historique des règles.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$rows = $this->historyRepository->findByTenant(
|
||||
$this->tenantContext->getCurrentTenantId(),
|
||||
);
|
||||
|
||||
$resources = [];
|
||||
foreach ($rows as $row) {
|
||||
$resource = new HomeworkRulesHistoryResource();
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
$resource->id = $id;
|
||||
|
||||
/** @var string|null $previousRules */
|
||||
$previousRules = $row['previous_rules'];
|
||||
if (is_string($previousRules)) {
|
||||
/** @var array<int, array{type: string, params: array<string, mixed>}> $decoded */
|
||||
$decoded = json_decode($previousRules, true, 512, JSON_THROW_ON_ERROR);
|
||||
$resource->previousRules = $decoded;
|
||||
}
|
||||
|
||||
/** @var string $newRules */
|
||||
$newRules = $row['new_rules'];
|
||||
/** @var array<int, array{type: string, params: array<string, mixed>}> $decodedNew */
|
||||
$decodedNew = json_decode($newRules, true, 512, JSON_THROW_ON_ERROR);
|
||||
$resource->newRules = $decodedNew;
|
||||
|
||||
/** @var string $enforcementMode */
|
||||
$enforcementMode = $row['enforcement_mode'];
|
||||
$resource->enforcementMode = $enforcementMode;
|
||||
$resource->enabled = (bool) $row['enabled'];
|
||||
|
||||
/** @var string $changedBy */
|
||||
$changedBy = $row['changed_by'];
|
||||
$resource->changedBy = $changedBy;
|
||||
|
||||
/** @var string $changedAt */
|
||||
$changedAt = $row['changed_at'];
|
||||
$resource->changedAt = $changedAt;
|
||||
|
||||
$resources[] = $resource;
|
||||
}
|
||||
|
||||
return $resources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\HomeworkRulesResource;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer la configuration des règles de devoirs.
|
||||
*
|
||||
* @implements ProviderInterface<HomeworkRulesResource>
|
||||
*/
|
||||
final readonly class HomeworkRulesProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkRulesRepository $homeworkRulesRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): HomeworkRulesResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedHttpException('Seuls les administrateurs peuvent gérer les règles de devoirs.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
$homeworkRules = $this->homeworkRulesRepository->findByTenantId($tenantId);
|
||||
|
||||
if ($homeworkRules === null) {
|
||||
$homeworkRules = HomeworkRules::creer(
|
||||
tenantId: $tenantId,
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
$this->homeworkRulesRepository->save($homeworkRules);
|
||||
}
|
||||
|
||||
return HomeworkRulesResource::fromDomain($homeworkRules);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Administration\Infrastructure\Api\Provider\HomeworkRulesHistoryProvider;
|
||||
|
||||
/**
|
||||
* DTO représentant une entrée d'historique des règles de devoirs.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'HomeworkRulesHistory',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/settings/homework-rules/history',
|
||||
provider: HomeworkRulesHistoryProvider::class,
|
||||
name: 'get_homework_rules_history',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class HomeworkRulesHistoryResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
/** @var array<int, array{type: string, params: array<string, mixed>}>|null */
|
||||
public ?array $previousRules = null;
|
||||
|
||||
/** @var array<int, array{type: string, params: array<string, mixed>}> */
|
||||
public array $newRules = [];
|
||||
|
||||
public string $enforcementMode = '';
|
||||
|
||||
public bool $enabled = true;
|
||||
|
||||
public string $changedBy = '';
|
||||
|
||||
public string $changedAt = '';
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateHomeworkRulesProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\HomeworkRulesProvider;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la configuration des règles de devoirs.
|
||||
*
|
||||
* @see FR81 - Configurer règles de timing des devoirs
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'HomeworkRules',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/settings/homework-rules',
|
||||
provider: HomeworkRulesProvider::class,
|
||||
name: 'get_homework_rules',
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/settings/homework-rules',
|
||||
read: false,
|
||||
processor: UpdateHomeworkRulesProcessor::class,
|
||||
name: 'update_homework_rules',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class HomeworkRulesResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
/** @var array<int, array{type: string, params: array<string, mixed>}>|null */
|
||||
#[Assert\NotNull(message: 'Les règles sont obligatoires.')]
|
||||
public ?array $rules = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Le mode d\'application est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['soft', 'hard', 'disabled'], message: 'Mode invalide.')]
|
||||
public ?string $enforcementMode = null;
|
||||
|
||||
public ?bool $enabled = null;
|
||||
|
||||
public ?DateTimeImmutable $createdAt = null;
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public static function fromDomain(HomeworkRules $homeworkRules): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = (string) $homeworkRules->id;
|
||||
$resource->rules = array_map(
|
||||
static fn (HomeworkRule $rule): array => $rule->toArray(),
|
||||
$homeworkRules->rules,
|
||||
);
|
||||
$resource->enforcementMode = $homeworkRules->enforcementMode->value;
|
||||
$resource->enabled = $homeworkRules->enabled;
|
||||
$resource->createdAt = $homeworkRules->createdAt;
|
||||
$resource->updatedAt = $homeworkRules->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function json_encode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineHomeworkRulesHistoryRepository implements HomeworkRulesHistoryRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function record(
|
||||
TenantId $tenantId,
|
||||
?array $previousRules,
|
||||
array $newRules,
|
||||
EnforcementMode $enforcementMode,
|
||||
bool $enabled,
|
||||
UserId $changedBy,
|
||||
DateTimeImmutable $changedAt,
|
||||
): void {
|
||||
$previousJson = $previousRules !== null
|
||||
? json_encode(
|
||||
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $previousRules),
|
||||
JSON_THROW_ON_ERROR,
|
||||
)
|
||||
: null;
|
||||
|
||||
$newJson = json_encode(
|
||||
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $newRules),
|
||||
JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO homework_rules_history (tenant_id, previous_rules, new_rules, enforcement_mode, enabled, changed_by, changed_at)
|
||||
VALUES (:tenant_id, :previous_rules, :new_rules, :enforcement_mode, :enabled, :changed_by, :changed_at)',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'previous_rules' => $previousJson,
|
||||
'new_rules' => $newJson,
|
||||
'enforcement_mode' => $enforcementMode->value,
|
||||
'enabled' => $enabled ? 'true' : 'false',
|
||||
'changed_by' => (string) $changedBy,
|
||||
'changed_at' => $changedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenant(TenantId $tenantId, int $limit = 50): array
|
||||
{
|
||||
/** @var array<array{id: string, previous_rules: string|null, new_rules: string, enforcement_mode: string, enabled: bool, changed_by: string, changed_at: string}> $rows */
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT id, previous_rules, new_rules, enforcement_mode, enabled, changed_by, changed_at
|
||||
FROM homework_rules_history
|
||||
WHERE tenant_id = :tenant_id
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT :limit',
|
||||
['tenant_id' => (string) $tenantId, 'limit' => $limit],
|
||||
);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\HomeworkRulesNotFoundException;
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRulesId;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function is_string;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineHomeworkRulesRepository implements HomeworkRulesRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(HomeworkRules $homeworkRules): void
|
||||
{
|
||||
$rulesJson = json_encode(
|
||||
array_map(
|
||||
static fn (HomeworkRule $rule): array => $rule->toArray(),
|
||||
$homeworkRules->rules,
|
||||
),
|
||||
JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :rules, :enforcement_mode, :enabled, :created_at, :updated_at)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
rules = EXCLUDED.rules,
|
||||
enforcement_mode = EXCLUDED.enforcement_mode,
|
||||
enabled = EXCLUDED.enabled,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'id' => (string) $homeworkRules->id,
|
||||
'tenant_id' => (string) $homeworkRules->tenantId,
|
||||
'rules' => $rulesJson,
|
||||
'enforcement_mode' => $homeworkRules->enforcementMode->value,
|
||||
'enabled' => $homeworkRules->enabled ? 'true' : 'false',
|
||||
'created_at' => $homeworkRules->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $homeworkRules->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(TenantId $tenantId): HomeworkRules
|
||||
{
|
||||
$rules = $this->findByTenantId($tenantId);
|
||||
|
||||
if ($rules === null) {
|
||||
throw HomeworkRulesNotFoundException::pourTenant($tenantId);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenantId(TenantId $tenantId): ?HomeworkRules
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM homework_rules WHERE tenant_id = :tenant_id',
|
||||
['tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): HomeworkRules
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $rulesJson */
|
||||
$rulesJson = $row['rules'];
|
||||
/** @var string $enforcementMode */
|
||||
$enforcementMode = $row['enforcement_mode'];
|
||||
/** @var bool|string $enabled */
|
||||
$enabled = $row['enabled'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
|
||||
/** @var array<int, array{type: string, params: array<string, mixed>}> $rulesData */
|
||||
$rulesData = json_decode($rulesJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return HomeworkRules::reconstitute(
|
||||
id: HomeworkRulesId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
rules: array_map(HomeworkRule::fromArray(...), $rulesData),
|
||||
enforcementMode: EnforcementMode::from($enforcementMode),
|
||||
enabled: is_string($enabled) ? $enabled === 'true' || $enabled === 't' : (bool) $enabled,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function array_slice;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function json_encode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Override;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
final class InMemoryHomeworkRulesHistoryRepository implements HomeworkRulesHistoryRepository
|
||||
{
|
||||
/** @var array<string, list<array{id: string, previous_rules: string|null, new_rules: string, enforcement_mode: string, enabled: bool, changed_by: string, changed_at: string}>> */
|
||||
private array $history = [];
|
||||
|
||||
#[Override]
|
||||
public function record(
|
||||
TenantId $tenantId,
|
||||
?array $previousRules,
|
||||
array $newRules,
|
||||
EnforcementMode $enforcementMode,
|
||||
bool $enabled,
|
||||
UserId $changedBy,
|
||||
DateTimeImmutable $changedAt,
|
||||
): void {
|
||||
$key = (string) $tenantId;
|
||||
|
||||
$previousJson = $previousRules !== null
|
||||
? json_encode(
|
||||
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $previousRules),
|
||||
JSON_THROW_ON_ERROR,
|
||||
)
|
||||
: null;
|
||||
|
||||
$this->history[$key][] = [
|
||||
'id' => Uuid::uuid7()->toString(),
|
||||
'previous_rules' => $previousJson,
|
||||
'new_rules' => json_encode(
|
||||
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $newRules),
|
||||
JSON_THROW_ON_ERROR,
|
||||
),
|
||||
'enforcement_mode' => $enforcementMode->value,
|
||||
'enabled' => $enabled,
|
||||
'changed_by' => (string) $changedBy,
|
||||
'changed_at' => $changedAt->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenant(TenantId $tenantId, int $limit = 50): array
|
||||
{
|
||||
$entries = $this->history[(string) $tenantId] ?? [];
|
||||
|
||||
return array_slice(array_reverse($entries), 0, $limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\HomeworkRulesNotFoundException;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemoryHomeworkRulesRepository implements HomeworkRulesRepository
|
||||
{
|
||||
/** @var array<string, HomeworkRules> Indexed by tenant_id */
|
||||
private array $byTenant = [];
|
||||
|
||||
#[Override]
|
||||
public function save(HomeworkRules $homeworkRules): void
|
||||
{
|
||||
$this->byTenant[(string) $homeworkRules->tenantId] = $homeworkRules;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(TenantId $tenantId): HomeworkRules
|
||||
{
|
||||
$rules = $this->findByTenantId($tenantId);
|
||||
|
||||
if ($rules === null) {
|
||||
throw HomeworkRulesNotFoundException::pourTenant($tenantId);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenantId(TenantId $tenantId): ?HomeworkRules
|
||||
{
|
||||
return $this->byTenant[(string) $tenantId] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* Functional tests for homework rules API endpoints.
|
||||
*
|
||||
* Validates routing, tenant isolation, authentication, and
|
||||
* basic request/response contracts without full auth flow.
|
||||
*/
|
||||
final class HomeworkRulesEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
// =========================================================================
|
||||
// GET /settings/homework-rules — Without tenant
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getHomeworkRulesReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/api/settings/homework-rules', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /settings/homework-rules — Without authentication (with tenant)
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getHomeworkRulesReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PUT /settings/homework-rules — Without tenant
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function updateHomeworkRulesReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('PUT', '/api/settings/homework-rules', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'rules' => [],
|
||||
'enforcementMode' => 'soft',
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function updateHomeworkRulesReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'rules' => [],
|
||||
'enforcementMode' => 'soft',
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /settings/homework-rules/history — Without tenant / auth
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getHistoryReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/api/settings/homework-rules/history', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getHistoryReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules/history', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route existence — tenant + no auth proves route exists (401 not 404)
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getHomeworkRulesRouteExistsWithTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
// 401 (no auth) not 404 — proves route is registered
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function putHomeworkRulesRouteExistsWithTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'rules' => [],
|
||||
'enforcementMode' => 'soft',
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function historyRouteExistsWithTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules/history', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Application;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels pour la persistence des HomeworkRules.
|
||||
*
|
||||
* Vérifie le round-trip DBAL : save → findByTenantId → hydration correcte.
|
||||
*/
|
||||
final class HomeworkRulesPersistenceFunctionalTest extends KernelTestCase
|
||||
{
|
||||
private const string TENANT_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee01';
|
||||
|
||||
private HomeworkRulesRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->repository = static::getContainer()->get(HomeworkRulesRepository::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
/** @var Connection $connection */
|
||||
$connection = static::getContainer()->get(Connection::class);
|
||||
$connection->executeStatement(
|
||||
'DELETE FROM homework_rules_history WHERE tenant_id = :t',
|
||||
['t' => self::TENANT_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
'DELETE FROM homework_rules WHERE tenant_id = :t',
|
||||
['t' => self::TENANT_ID],
|
||||
);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveAndRetrieveEmptyRules(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00'));
|
||||
$this->repository->save($rules);
|
||||
|
||||
$loaded = $this->repository->findByTenantId($tenantId);
|
||||
|
||||
self::assertNotNull($loaded);
|
||||
self::assertTrue($loaded->tenantId->equals($tenantId));
|
||||
self::assertSame([], $loaded->rules);
|
||||
self::assertSame(EnforcementMode::SOFT, $loaded->enforcementMode);
|
||||
self::assertTrue($loaded->enabled);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveAndRetrieveWithRules(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00'));
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]),
|
||||
new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']),
|
||||
],
|
||||
enforcementMode: EnforcementMode::HARD,
|
||||
enabled: true,
|
||||
now: new DateTimeImmutable('2026-03-17 11:00:00'),
|
||||
);
|
||||
|
||||
$this->repository->save($rules);
|
||||
|
||||
$loaded = $this->repository->findByTenantId($tenantId);
|
||||
|
||||
self::assertNotNull($loaded);
|
||||
self::assertCount(2, $loaded->rules);
|
||||
self::assertSame(RuleType::MINIMUM_DELAY, $loaded->rules[0]->type);
|
||||
self::assertSame(3, $loaded->rules[0]->params['days']);
|
||||
self::assertSame(RuleType::NO_MONDAY_AFTER, $loaded->rules[1]->type);
|
||||
self::assertSame('friday', $loaded->rules[1]->params['day']);
|
||||
self::assertSame('12:00', $loaded->rules[1]->params['time']);
|
||||
self::assertSame(EnforcementMode::HARD, $loaded->enforcementMode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function upsertOverwritesExistingConfig(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00'));
|
||||
$this->repository->save($rules);
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 5])],
|
||||
enforcementMode: EnforcementMode::HARD,
|
||||
enabled: true,
|
||||
now: new DateTimeImmutable('2026-03-17 12:00:00'),
|
||||
);
|
||||
$this->repository->save($rules);
|
||||
|
||||
$loaded = $this->repository->findByTenantId($tenantId);
|
||||
|
||||
self::assertNotNull($loaded);
|
||||
self::assertCount(1, $loaded->rules);
|
||||
self::assertSame(5, $loaded->rules[0]->params['days']);
|
||||
self::assertSame(EnforcementMode::HARD, $loaded->enforcementMode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByTenantIdReturnsNullWhenNotFound(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('99999999-9999-9999-9999-999999999999');
|
||||
|
||||
self::assertNull($this->repository->findByTenantId($tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getThrowsWhenNotFound(): void
|
||||
{
|
||||
$this->expectException(\App\Administration\Domain\Exception\HomeworkRulesNotFoundException::class);
|
||||
|
||||
$this->repository->get(TenantId::fromString('99999999-9999-9999-9999-999999999999'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function disabledStatePersistedCorrectly(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00'));
|
||||
$rules->desactiver(new DateTimeImmutable('2026-03-17 11:00:00'));
|
||||
$this->repository->save($rules);
|
||||
|
||||
$loaded = $this->repository->findByTenantId($tenantId);
|
||||
|
||||
self::assertNotNull($loaded);
|
||||
self::assertFalse($loaded->enabled);
|
||||
self::assertSame(EnforcementMode::DISABLED, $loaded->enforcementMode);
|
||||
self::assertFalse($loaded->estActif());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\UpdateHomeworkRules;
|
||||
|
||||
use App\Administration\Application\Command\UpdateHomeworkRules\UpdateHomeworkRulesCommand;
|
||||
use App\Administration\Application\Command\UpdateHomeworkRules\UpdateHomeworkRulesHandler;
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryHomeworkRulesHistoryRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryHomeworkRulesRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UpdateHomeworkRulesHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryHomeworkRulesRepository $rulesRepository;
|
||||
private InMemoryHomeworkRulesHistoryRepository $historyRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->rulesRepository = new InMemoryHomeworkRulesRepository();
|
||||
$this->historyRepository = new InMemoryHomeworkRulesHistoryRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-17 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesRulesWhenNoneExist(): void
|
||||
{
|
||||
$handler = $this->handler();
|
||||
|
||||
$result = $handler($this->commandAvecDelai(3));
|
||||
|
||||
self::assertCount(1, $result->rules);
|
||||
self::assertSame(RuleType::MINIMUM_DELAY, $result->rules[0]->type);
|
||||
self::assertSame(EnforcementMode::SOFT, $result->enforcementMode);
|
||||
self::assertTrue($result->enabled);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUpdatesExistingRules(): void
|
||||
{
|
||||
$this->seedRules();
|
||||
|
||||
$handler = $this->handler();
|
||||
$result = $handler($this->commandAvecDelai(5, 'hard'));
|
||||
|
||||
self::assertCount(1, $result->rules);
|
||||
self::assertSame(5, $result->rules[0]->params['days']);
|
||||
self::assertSame(EnforcementMode::HARD, $result->enforcementMode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsHistory(): void
|
||||
{
|
||||
$handler = $this->handler();
|
||||
$handler($this->commandAvecDelai(3));
|
||||
|
||||
$history = $this->historyRepository->findByTenant(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertCount(1, $history);
|
||||
self::assertSame('soft', $history[0]['enforcement_mode']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesToRepository(): void
|
||||
{
|
||||
$handler = $this->handler();
|
||||
$handler($this->commandAvecDelai(3));
|
||||
|
||||
$saved = $this->rulesRepository->findByTenantId(TenantId::fromString(self::TENANT_ID));
|
||||
self::assertNotNull($saved);
|
||||
self::assertCount(1, $saved->rules);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDisablesRules(): void
|
||||
{
|
||||
$this->seedRules();
|
||||
|
||||
$handler = $this->handler();
|
||||
$result = $handler(new UpdateHomeworkRulesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
rules: [],
|
||||
enforcementMode: 'disabled',
|
||||
enabled: false,
|
||||
changedBy: self::USER_ID,
|
||||
));
|
||||
|
||||
self::assertFalse($result->enabled);
|
||||
self::assertSame(EnforcementMode::DISABLED, $result->enforcementMode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesMultipleRules(): void
|
||||
{
|
||||
$handler = $this->handler();
|
||||
$result = $handler(new UpdateHomeworkRulesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
rules: [
|
||||
['type' => 'minimum_delay', 'params' => ['days' => 3]],
|
||||
['type' => 'no_monday_after', 'params' => ['day' => 'friday', 'time' => '12:00']],
|
||||
],
|
||||
enforcementMode: 'soft',
|
||||
enabled: true,
|
||||
changedBy: self::USER_ID,
|
||||
));
|
||||
|
||||
self::assertCount(2, $result->rules);
|
||||
}
|
||||
|
||||
private function handler(): UpdateHomeworkRulesHandler
|
||||
{
|
||||
return new UpdateHomeworkRulesHandler(
|
||||
$this->rulesRepository,
|
||||
$this->historyRepository,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function commandAvecDelai(int $days, string $mode = 'soft'): UpdateHomeworkRulesCommand
|
||||
{
|
||||
return new UpdateHomeworkRulesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
rules: [['type' => 'minimum_delay', 'params' => ['days' => $days]]],
|
||||
enforcementMode: $mode,
|
||||
enabled: true,
|
||||
changedBy: self::USER_ID,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedRules(): void
|
||||
{
|
||||
$rules = HomeworkRules::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3])],
|
||||
enforcementMode: EnforcementMode::SOFT,
|
||||
enabled: true,
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
$this->rulesRepository->save($rules);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Application\Service\HomeworkRulesValidator;
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HomeworkRulesValidatorTest extends TestCase
|
||||
{
|
||||
private HomeworkRulesValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = new HomeworkRulesValidator();
|
||||
}
|
||||
|
||||
// -- Règles désactivées --
|
||||
|
||||
#[Test]
|
||||
public function disabledRulesAlwaysValid(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3, EnforcementMode::DISABLED, false);
|
||||
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-20'), // vendredi
|
||||
new DateTimeImmutable('2026-03-19 15:00'), // jeudi (1 jour, < 3)
|
||||
);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
// -- Délai minimum (AC3) --
|
||||
|
||||
#[Test]
|
||||
public function delaiMinimumOkQuandRespected(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3);
|
||||
|
||||
// Devoir vendredi 20 mars, créé mardi 17 mars = 3 jours = OK
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-20'),
|
||||
new DateTimeImmutable('2026-03-17 10:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delaiMinimumViolationQuandTropTard(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3);
|
||||
|
||||
// Devoir vendredi 20 mars, créé mercredi 18 mars = 2 jours < 3
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-20'),
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertCount(1, $result->violations);
|
||||
self::assertSame(RuleType::MINIMUM_DELAY, $result->violations[0]->ruleType);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delaiMinimumSoftModeRetourneAvertissement(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3, EnforcementMode::SOFT);
|
||||
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-20'),
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->estAvertissement());
|
||||
self::assertFalse($result->estBloquant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delaiMinimumHardModeRetourneErreur(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3, EnforcementMode::HARD);
|
||||
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-20'),
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->estBloquant());
|
||||
self::assertFalse($result->estAvertissement());
|
||||
}
|
||||
|
||||
// -- Règle pas de devoir pour lundi (AC4) --
|
||||
|
||||
#[Test]
|
||||
public function pasLundiApresOkQuandCreationAvantCutoff(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecLundi();
|
||||
|
||||
// Devoir pour lundi 23 mars, créé vendredi 20 mars à 11h (avant 12h)
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-23'), // lundi
|
||||
new DateTimeImmutable('2026-03-20 11:00'), // vendredi 11h
|
||||
);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pasLundiApresViolationQuandCreationApresCutoff(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecLundi();
|
||||
|
||||
// Devoir pour lundi 23 mars, créé vendredi 20 mars à 14h (après 12h)
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-23'), // lundi
|
||||
new DateTimeImmutable('2026-03-20 14:00'), // vendredi 14h
|
||||
);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertSame(RuleType::NO_MONDAY_AFTER, $result->violations[0]->ruleType);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pasLundiApresIgnorePourJoursNonLundi(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecLundi();
|
||||
|
||||
// Devoir pour mardi 24 mars, créé n'importe quand
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-24'), // mardi
|
||||
new DateTimeImmutable('2026-03-23 20:00'), // lundi soir
|
||||
);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pasLundiApresViolationSamediCreation(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecLundi();
|
||||
|
||||
// Devoir pour lundi 23 mars, créé samedi 21 mars (après vendredi 12h)
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-23'), // lundi
|
||||
new DateTimeImmutable('2026-03-21 10:00'), // samedi
|
||||
);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
}
|
||||
|
||||
// -- Règles combinées --
|
||||
|
||||
#[Test]
|
||||
public function multipleViolationsQuandPlusieursReglesEnfreintes(): void
|
||||
{
|
||||
$rules = $this->creerRulesComplet();
|
||||
|
||||
// Devoir pour lundi 23 mars, créé samedi 21 mars 10h
|
||||
// Violation délai minimum (3 jours: 23-3 = 20, samedi 21 > 20)
|
||||
// Violation lundi (après vendredi 12h)
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-23'), // lundi
|
||||
new DateTimeImmutable('2026-03-21 10:00'), // samedi
|
||||
);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertCount(2, $result->violations);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function messagesRetourneListeMessages(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3);
|
||||
|
||||
$result = $this->validator->valider(
|
||||
$rules,
|
||||
new DateTimeImmutable('2026-03-20'),
|
||||
new DateTimeImmutable('2026-03-19 10:00'),
|
||||
);
|
||||
|
||||
self::assertCount(1, $result->messages());
|
||||
self::assertStringContainsString('3 jours', $result->messages()[0]);
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
private function creerRulesAvecDelai(
|
||||
int $days,
|
||||
EnforcementMode $mode = EnforcementMode::SOFT,
|
||||
bool $enabled = true,
|
||||
): HomeworkRules {
|
||||
$rules = HomeworkRules::creer(
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => $days])],
|
||||
enforcementMode: $mode,
|
||||
enabled: $enabled,
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
private function creerRulesAvecLundi(
|
||||
EnforcementMode $mode = EnforcementMode::SOFT,
|
||||
): HomeworkRules {
|
||||
$rules = HomeworkRules::creer(
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: [new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00'])],
|
||||
enforcementMode: $mode,
|
||||
enabled: true,
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
private function creerRulesComplet(
|
||||
EnforcementMode $mode = EnforcementMode::SOFT,
|
||||
): HomeworkRules {
|
||||
$rules = HomeworkRules::creer(
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]),
|
||||
new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']),
|
||||
],
|
||||
enforcementMode: $mode,
|
||||
enabled: true,
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EnforcementModeTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function softEstActif(): void
|
||||
{
|
||||
self::assertTrue(EnforcementMode::SOFT->estActif());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hardEstActif(): void
|
||||
{
|
||||
self::assertTrue(EnforcementMode::HARD->estActif());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function disabledEstPasActif(): void
|
||||
{
|
||||
self::assertFalse(EnforcementMode::DISABLED->estActif());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function softEstPasBloquant(): void
|
||||
{
|
||||
self::assertFalse(EnforcementMode::SOFT->estBloquant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hardEstBloquant(): void
|
||||
{
|
||||
self::assertTrue(EnforcementMode::HARD->estBloquant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function disabledEstPasBloquant(): void
|
||||
{
|
||||
self::assertFalse(EnforcementMode::DISABLED->estBloquant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function labelsAreFrench(): void
|
||||
{
|
||||
self::assertSame('Avertissement', EnforcementMode::SOFT->label());
|
||||
self::assertSame('Blocage', EnforcementMode::HARD->label());
|
||||
self::assertSame('Désactivé', EnforcementMode::DISABLED->label());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
use App\Administration\Domain\Exception\HomeworkRuleParamsInvalidException;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HomeworkRuleTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function createMinimumDelayRule(): void
|
||||
{
|
||||
$rule = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]);
|
||||
|
||||
self::assertSame(RuleType::MINIMUM_DELAY, $rule->type);
|
||||
self::assertSame(3, $rule->params['days']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createNoMondayAfterRule(): void
|
||||
{
|
||||
$rule = new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']);
|
||||
|
||||
self::assertSame(RuleType::NO_MONDAY_AFTER, $rule->type);
|
||||
self::assertSame('friday', $rule->params['day']);
|
||||
self::assertSame('12:00', $rule->params['time']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnMissingParamsForMinimumDelay(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, []);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnMissingParamsForNoMondayAfter(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForIdenticalRules(): void
|
||||
{
|
||||
$rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]);
|
||||
$rule2 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]);
|
||||
|
||||
self::assertTrue($rule1->equals($rule2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentParams(): void
|
||||
{
|
||||
$rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]);
|
||||
$rule2 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 5]);
|
||||
|
||||
self::assertFalse($rule1->equals($rule2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentTypes(): void
|
||||
{
|
||||
$rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]);
|
||||
$rule2 = new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']);
|
||||
|
||||
self::assertFalse($rule1->equals($rule2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toArrayReturnsCorrectFormat(): void
|
||||
{
|
||||
$rule = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]);
|
||||
$array = $rule->toArray();
|
||||
|
||||
self::assertSame('minimum_delay', $array['type']);
|
||||
self::assertSame(['days' => 3], $array['params']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromArrayCreatesRule(): void
|
||||
{
|
||||
$data = ['type' => 'minimum_delay', 'params' => ['days' => 3]];
|
||||
$rule = HomeworkRule::fromArray($data);
|
||||
|
||||
self::assertSame(RuleType::MINIMUM_DELAY, $rule->type);
|
||||
self::assertSame(3, $rule->params['days']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function roundTripArrayConversion(): void
|
||||
{
|
||||
$original = new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']);
|
||||
$restored = HomeworkRule::fromArray($original->toArray());
|
||||
|
||||
self::assertTrue($original->equals($restored));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnZeroDays(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnNegativeDays(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => -1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnExcessiveDays(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 31]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnStringDays(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 'three']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnInvalidDayName(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'firday', 'time' => '12:00']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsOnInvalidTimeFormat(): void
|
||||
{
|
||||
$this->expectException(HomeworkRuleParamsInvalidException::class);
|
||||
|
||||
new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => 'banana']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function acceptsBoundaryDays(): void
|
||||
{
|
||||
$rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 1]);
|
||||
$rule30 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 30]);
|
||||
|
||||
self::assertSame(1, $rule1->params['days']);
|
||||
self::assertSame(30, $rule30->params['days']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
use App\Administration\Domain\Event\ReglesDevoirsModifiees;
|
||||
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HomeworkRulesTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
#[Test]
|
||||
public function creerWithDefaultValues(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
|
||||
self::assertTrue($rules->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
|
||||
self::assertSame([], $rules->rules);
|
||||
self::assertSame(EnforcementMode::SOFT, $rules->enforcementMode);
|
||||
self::assertTrue($rules->enabled);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerDoesNotRecordEvent(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
|
||||
self::assertCount(0, $rules->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mettreAJourChangesRulesAndRecordsEvent(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
$now = new DateTimeImmutable('2026-03-17 10:00:00');
|
||||
$newRules = [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]),
|
||||
];
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: $newRules,
|
||||
enforcementMode: EnforcementMode::HARD,
|
||||
enabled: true,
|
||||
now: $now,
|
||||
);
|
||||
|
||||
self::assertCount(1, $rules->rules);
|
||||
self::assertSame(EnforcementMode::HARD, $rules->enforcementMode);
|
||||
self::assertSame($now, $rules->updatedAt);
|
||||
|
||||
$events = $rules->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(ReglesDevoirsModifiees::class, $events[0]);
|
||||
self::assertSame([], $events[0]->previousRules);
|
||||
self::assertCount(1, $events[0]->newRules);
|
||||
self::assertSame(EnforcementMode::HARD, $events[0]->enforcementMode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mettreAJourDoesNotRecordEventWhenNothingChanges(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
$now = new DateTimeImmutable('2026-03-17 10:00:00');
|
||||
$newRules = [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]),
|
||||
];
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: $newRules,
|
||||
enforcementMode: EnforcementMode::SOFT,
|
||||
enabled: true,
|
||||
now: $now,
|
||||
);
|
||||
$rules->pullDomainEvents();
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: $newRules,
|
||||
enforcementMode: EnforcementMode::SOFT,
|
||||
enabled: true,
|
||||
now: new DateTimeImmutable('2026-03-17 11:00:00'),
|
||||
);
|
||||
|
||||
self::assertCount(0, $rules->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mettreAJourWithMultipleRules(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
$now = new DateTimeImmutable('2026-03-17 10:00:00');
|
||||
$newRules = [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]),
|
||||
new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']),
|
||||
];
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: $newRules,
|
||||
enforcementMode: EnforcementMode::SOFT,
|
||||
enabled: true,
|
||||
now: $now,
|
||||
);
|
||||
|
||||
self::assertCount(2, $rules->rules);
|
||||
self::assertSame(RuleType::MINIMUM_DELAY, $rules->rules[0]->type);
|
||||
self::assertSame(RuleType::NO_MONDAY_AFTER, $rules->rules[1]->type);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function desactiverDisablesRulesAndRecordsEvent(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
$now = new DateTimeImmutable('2026-03-17 10:00:00');
|
||||
|
||||
$rules->desactiver($now);
|
||||
|
||||
self::assertFalse($rules->enabled);
|
||||
self::assertSame(EnforcementMode::DISABLED, $rules->enforcementMode);
|
||||
|
||||
$events = $rules->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(ReglesDevoirsModifiees::class, $events[0]);
|
||||
self::assertFalse($events[0]->enabled);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerReenablesRulesWithGivenMode(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
$rules->desactiver(new DateTimeImmutable('2026-03-17 09:00:00'));
|
||||
$rules->pullDomainEvents();
|
||||
|
||||
$rules->activer(EnforcementMode::HARD, new DateTimeImmutable('2026-03-17 10:00:00'));
|
||||
|
||||
self::assertTrue($rules->enabled);
|
||||
self::assertSame(EnforcementMode::HARD, $rules->enforcementMode);
|
||||
|
||||
$events = $rules->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estActifReturnsTrueWhenEnabledAndNotDisabled(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
|
||||
self::assertTrue($rules->estActif());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estActifReturnsFalseWhenDisabled(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
$rules->desactiver(new DateTimeImmutable());
|
||||
|
||||
self::assertFalse($rules->estActif());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteFromStorage(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$rulesList = [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]),
|
||||
];
|
||||
$createdAt = new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
$updatedAt = new DateTimeImmutable('2026-03-15 14:30:00');
|
||||
$id = \App\Administration\Domain\Model\HomeworkRules\HomeworkRulesId::generate();
|
||||
|
||||
$rules = HomeworkRules::reconstitute(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
rules: $rulesList,
|
||||
enforcementMode: EnforcementMode::HARD,
|
||||
enabled: true,
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
|
||||
self::assertTrue($rules->id->equals($id));
|
||||
self::assertTrue($rules->tenantId->equals($tenantId));
|
||||
self::assertCount(1, $rules->rules);
|
||||
self::assertSame(EnforcementMode::HARD, $rules->enforcementMode);
|
||||
self::assertTrue($rules->enabled);
|
||||
self::assertSame($createdAt, $rules->createdAt);
|
||||
self::assertSame($updatedAt, $rules->updatedAt);
|
||||
self::assertCount(0, $rules->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mettreAJourPreservesPreviousRulesInEvent(): void
|
||||
{
|
||||
$rules = $this->creerHomeworkRules();
|
||||
$now1 = new DateTimeImmutable('2026-03-17 10:00:00');
|
||||
$firstRules = [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]),
|
||||
];
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: $firstRules,
|
||||
enforcementMode: EnforcementMode::SOFT,
|
||||
enabled: true,
|
||||
now: $now1,
|
||||
);
|
||||
$rules->pullDomainEvents();
|
||||
|
||||
$now2 = new DateTimeImmutable('2026-03-17 11:00:00');
|
||||
$secondRules = [
|
||||
new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 5]),
|
||||
];
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: $secondRules,
|
||||
enforcementMode: EnforcementMode::HARD,
|
||||
enabled: true,
|
||||
now: $now2,
|
||||
);
|
||||
|
||||
$events = $rules->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
/** @var ReglesDevoirsModifiees $event */
|
||||
$event = $events[0];
|
||||
self::assertSame('minimum_delay', $event->previousRules[0]['type']);
|
||||
self::assertSame(3, $event->previousRules[0]['params']['days']);
|
||||
self::assertSame('minimum_delay', $event->newRules[0]['type']);
|
||||
self::assertSame(5, $event->newRules[0]['params']['days']);
|
||||
}
|
||||
|
||||
private function creerHomeworkRules(): HomeworkRules
|
||||
{
|
||||
return HomeworkRules::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\RuleType;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RuleTypeTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function minimumDelayRequiresParams(): void
|
||||
{
|
||||
self::assertSame(['days'], RuleType::MINIMUM_DELAY->requiredParams());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function noMondayAfterRequiresParams(): void
|
||||
{
|
||||
self::assertSame(['day', 'time'], RuleType::NO_MONDAY_AFTER->requiredParams());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function labelsAreFrench(): void
|
||||
{
|
||||
self::assertSame('Délai minimum avant échéance', RuleType::MINIMUM_DELAY->label());
|
||||
self::assertSame('Pas de devoir pour lundi après un horaire', RuleType::NO_MONDAY_AFTER->label());
|
||||
}
|
||||
}
|
||||
190
frontend/e2e/homework-rules.spec.ts
Normal file
190
frontend/e2e/homework-rules.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const ADMIN_EMAIL = 'e2e-homework-rules-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'HomeworkRulesAdmin123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
test.describe('Homework Rules Configuration', () => {
|
||||
// Serial: les tests d'historique dépendent des entrées créées par les tests précédents
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Clean up homework rules from previous test runs
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM homework_rules_history WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
});
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitForPageLoaded(page: import('@playwright/test').Page) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /règles de devoirs/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await expect(
|
||||
page.locator('.card').first()
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
// AC1: Page configuration pédagogique → section "Règles de devoirs" accessible
|
||||
test('page affiche la configuration des règles de devoirs', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/homework-rules`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /règles de devoirs/i })
|
||||
).toBeVisible();
|
||||
|
||||
// Mode selector visible
|
||||
await expect(page.getByRole('radio', { name: /avertissement/i })).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: /blocage/i })).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: /désactivé/i })).toBeVisible();
|
||||
});
|
||||
|
||||
// AC5: Mode par défaut = "Soft"
|
||||
test('mode par défaut est Avertissement (soft)', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/homework-rules`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
const softRadio = page.getByRole('radio', { name: /avertissement/i });
|
||||
await expect(softRadio).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
// AC2: Règle définissable : délai minimum
|
||||
test('configurer la règle de délai minimum', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/homework-rules`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Enable minimum delay rule
|
||||
const delayCheckbox = page.getByRole('checkbox', { name: /délai minimum/i });
|
||||
await delayCheckbox.check();
|
||||
|
||||
// Set 3 days
|
||||
const daysInput = page.locator('input[type="number"]');
|
||||
await daysInput.fill('3');
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
|
||||
// Success message
|
||||
await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
// AC2: Règle définissable : jours concernés (pas de devoir pour lundi)
|
||||
test('configurer la règle pas de devoir pour lundi', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/homework-rules`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Enable no-monday rule
|
||||
const mondayCheckbox = page.getByRole('checkbox', { name: /pas de devoir pour lundi/i });
|
||||
await mondayCheckbox.check();
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
|
||||
await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
// AC5: Choix mode Hard (blocage)
|
||||
test('changer le mode en Blocage', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/homework-rules`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Select hard mode
|
||||
await page.getByRole('radio', { name: /blocage/i }).click();
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
|
||||
await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
// AC7: Règles désactivables complètement
|
||||
test('désactiver les règles complètement', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/homework-rules`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Select disabled mode
|
||||
await page.getByRole('radio', { name: /désactivé/i }).click();
|
||||
|
||||
// Rules section should disappear
|
||||
await expect(page.getByText(/les enseignants peuvent créer des devoirs librement/i)).toBeVisible();
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
|
||||
await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
// AC6: Historique des changements conservé
|
||||
test('voir l\'historique des changements avec contenu', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/homework-rules`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Click history button
|
||||
await page.getByRole('button', { name: /voir l'historique/i }).click();
|
||||
|
||||
// Modal should appear
|
||||
const dialog = page.getByRole('dialog', { name: /historique des changements/i });
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify history is not empty (previous tests created entries)
|
||||
await expect(dialog.getByText(/aucun changement/i)).not.toBeVisible();
|
||||
|
||||
// Verify at least one history entry with mode and date
|
||||
const firstEntry = dialog.locator('.history-entry').first();
|
||||
await expect(firstEntry).toBeVisible();
|
||||
await expect(firstEntry.locator('.history-mode')).toBeVisible();
|
||||
await expect(firstEntry.locator('.history-date')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,11 @@
|
||||
<span class="action-label">Identité visuelle</span>
|
||||
<span class="action-hint">Logo et couleurs</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/homework-rules">
|
||||
<span class="action-icon">📏</span>
|
||||
<span class="action-label">Règles de devoirs</span>
|
||||
<span class="action-hint">Timing et contraintes</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/import/students">
|
||||
<span class="action-icon">📤</span>
|
||||
<span class="action-label">Importer des élèves</span>
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
links: [
|
||||
{ href: '/admin/image-rights', label: "Droit à l'image" },
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie' },
|
||||
{ href: '/admin/branding', label: 'Identité visuelle' }
|
||||
{ href: '/admin/branding', label: 'Identité visuelle' },
|
||||
{ href: '/admin/homework-rules', label: 'Règles de devoirs' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
868
frontend/src/routes/admin/homework-rules/+page.svelte
Normal file
868
frontend/src/routes/admin/homework-rules/+page.svelte
Normal file
@@ -0,0 +1,868 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authenticatedFetch } from '$lib/auth/auth.svelte';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
|
||||
type RuleDefinition = {
|
||||
type: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type HomeworkRulesConfig = {
|
||||
id: string;
|
||||
rules: RuleDefinition[];
|
||||
enforcementMode: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type HistoryEntry = {
|
||||
previousRules: string | null;
|
||||
newRules: string;
|
||||
enforcementMode: string;
|
||||
enabled: boolean;
|
||||
changedBy: string;
|
||||
changedAt: string;
|
||||
};
|
||||
|
||||
// State
|
||||
let config = $state<HomeworkRulesConfig | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Form state
|
||||
let enforcementMode = $state<string>('soft');
|
||||
let enabled = $state(true);
|
||||
let minimumDelayDays = $state<number>(3);
|
||||
let minimumDelayEnabled = $state(false);
|
||||
let noMondayAfterEnabled = $state(false);
|
||||
let noMondayAfterDay = $state<string>('friday');
|
||||
let noMondayAfterTime = $state<string>('12:00');
|
||||
|
||||
// History
|
||||
let history = $state<HistoryEntry[]>([]);
|
||||
let showHistory = $state(false);
|
||||
|
||||
// Derived
|
||||
let hasChanges = $derived.by(() => {
|
||||
if (!config) return false;
|
||||
|
||||
const currentRules = buildRules();
|
||||
const configRulesJson = JSON.stringify(config.rules);
|
||||
const newRulesJson = JSON.stringify(currentRules);
|
||||
|
||||
return (
|
||||
configRulesJson !== newRulesJson ||
|
||||
enforcementMode !== config.enforcementMode ||
|
||||
enabled !== config.enabled
|
||||
);
|
||||
});
|
||||
|
||||
let modeLabel = $derived(
|
||||
enforcementMode === 'soft'
|
||||
? 'Avertissement'
|
||||
: enforcementMode === 'hard'
|
||||
? 'Blocage'
|
||||
: 'Désactivé'
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
loadConfig();
|
||||
});
|
||||
|
||||
function buildRules(): RuleDefinition[] {
|
||||
const rules: RuleDefinition[] = [];
|
||||
|
||||
if (minimumDelayEnabled) {
|
||||
rules.push({
|
||||
type: 'minimum_delay',
|
||||
params: { days: minimumDelayDays }
|
||||
});
|
||||
}
|
||||
|
||||
if (noMondayAfterEnabled) {
|
||||
rules.push({
|
||||
type: 'no_monday_after',
|
||||
params: { day: noMondayAfterDay, time: noMondayAfterTime }
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function syncFormFromConfig(data: HomeworkRulesConfig) {
|
||||
enforcementMode = data.enforcementMode;
|
||||
enabled = data.enabled;
|
||||
|
||||
minimumDelayEnabled = false;
|
||||
noMondayAfterEnabled = false;
|
||||
|
||||
for (const rule of data.rules) {
|
||||
if (rule.type === 'minimum_delay') {
|
||||
minimumDelayEnabled = true;
|
||||
minimumDelayDays = (rule.params['days'] as number) ?? 3;
|
||||
}
|
||||
if (rule.type === 'no_monday_after') {
|
||||
noMondayAfterEnabled = true;
|
||||
noMondayAfterDay = (rule.params['day'] as string) ?? 'friday';
|
||||
noMondayAfterTime = (rule.params['time'] as string) ?? '12:00';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/settings/homework-rules`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement de la configuration.');
|
||||
}
|
||||
|
||||
const data: HomeworkRulesConfig = await response.json();
|
||||
config = data;
|
||||
syncFormFromConfig(data);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
config = null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasChanges) return;
|
||||
|
||||
try {
|
||||
isSubmitting = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/settings/homework-rules`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rules: buildRules(),
|
||||
enforcementMode,
|
||||
enabled
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData['hydra:description'] || errorData.message || 'Erreur lors de la sauvegarde.'
|
||||
);
|
||||
}
|
||||
|
||||
const data: HomeworkRulesConfig = await response.json();
|
||||
config = data;
|
||||
syncFormFromConfig(data);
|
||||
successMessage = 'Règles de devoirs mises à jour avec succès.';
|
||||
window.setTimeout(() => (successMessage = null), 4000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (config) {
|
||||
syncFormFromConfig(config);
|
||||
}
|
||||
error = null;
|
||||
successMessage = null;
|
||||
}
|
||||
|
||||
function handleModeChange(newMode: string) {
|
||||
enforcementMode = newMode;
|
||||
if (newMode === 'disabled') {
|
||||
enabled = false;
|
||||
} else {
|
||||
enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/settings/homework-rules/history`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement de l\'historique.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
history = (data['hydra:member'] ?? data['member'] ?? data) as HistoryEntry[];
|
||||
showHistory = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
}
|
||||
}
|
||||
|
||||
function ruleTypeLabel(type: string): string {
|
||||
return type === 'minimum_delay'
|
||||
? 'Délai minimum'
|
||||
: type === 'no_monday_after'
|
||||
? 'Pas de devoir pour lundi'
|
||||
: type;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Règles de devoirs - Administration</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Règles de devoirs</h1>
|
||||
<p class="page-description">
|
||||
Configurez les règles de timing pour protéger les élèves et familles des devoirs de dernière
|
||||
minute.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="alert alert-success" role="status">
|
||||
<span>{successMessage}</span>
|
||||
<button class="alert-close" onclick={() => (successMessage = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement de la configuration...</p>
|
||||
</div>
|
||||
{:else if config}
|
||||
<!-- Mode d'application -->
|
||||
<section class="card">
|
||||
<h2>Mode d'application</h2>
|
||||
<p class="section-description">
|
||||
Choisissez comment les règles sont appliquées lorsqu'un enseignant crée un devoir.
|
||||
</p>
|
||||
|
||||
<div class="mode-selector" role="radiogroup" aria-label="Mode d'application">
|
||||
<button
|
||||
class="mode-option"
|
||||
class:selected={enforcementMode === 'soft'}
|
||||
role="radio"
|
||||
aria-checked={enforcementMode === 'soft'}
|
||||
onclick={() => handleModeChange('soft')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span class="mode-icon">⚠</span>
|
||||
<span class="mode-label">Avertissement</span>
|
||||
<span class="mode-description"
|
||||
>L'enseignant voit un avertissement mais peut créer le devoir.</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="mode-option"
|
||||
class:selected={enforcementMode === 'hard'}
|
||||
role="radio"
|
||||
aria-checked={enforcementMode === 'hard'}
|
||||
onclick={() => handleModeChange('hard')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span class="mode-icon">🚫</span>
|
||||
<span class="mode-label">Blocage</span>
|
||||
<span class="mode-description"
|
||||
>L'enseignant ne peut pas créer le devoir si la règle est enfreinte.</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="mode-option"
|
||||
class:selected={enforcementMode === 'disabled'}
|
||||
role="radio"
|
||||
aria-checked={enforcementMode === 'disabled'}
|
||||
onclick={() => handleModeChange('disabled')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span class="mode-icon">❌</span>
|
||||
<span class="mode-label">Désactivé</span>
|
||||
<span class="mode-description"
|
||||
>Aucune vérification. Les enseignants créent les devoirs librement.</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Règles -->
|
||||
{#if enforcementMode !== 'disabled'}
|
||||
<section class="card">
|
||||
<h2>Règles actives</h2>
|
||||
<p class="section-description">Définissez les règles de timing des devoirs.</p>
|
||||
|
||||
<!-- Délai minimum -->
|
||||
<div class="rule-block">
|
||||
<label class="rule-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={minimumDelayEnabled}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span class="rule-name">Délai minimum avant échéance</span>
|
||||
</label>
|
||||
|
||||
{#if minimumDelayEnabled}
|
||||
<div class="rule-params">
|
||||
<label class="param-label">
|
||||
Nombre de jours minimum :
|
||||
<input
|
||||
type="number"
|
||||
bind:value={minimumDelayDays}
|
||||
min="1"
|
||||
max="30"
|
||||
disabled={isSubmitting}
|
||||
class="param-input"
|
||||
/>
|
||||
</label>
|
||||
<p class="rule-example">
|
||||
Exemple : avec {minimumDelayDays} jours, un devoir pour vendredi ne peut pas être
|
||||
créé après {minimumDelayDays === 3 ? 'mardi' : `J-${minimumDelayDays}`}.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pas de devoir pour lundi -->
|
||||
<div class="rule-block">
|
||||
<label class="rule-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={noMondayAfterEnabled}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span class="rule-name">Pas de devoir pour lundi donné trop tard</span>
|
||||
</label>
|
||||
|
||||
{#if noMondayAfterEnabled}
|
||||
<div class="rule-params">
|
||||
<label class="param-label">
|
||||
Jour limite :
|
||||
<select bind:value={noMondayAfterDay} disabled={isSubmitting} class="param-select">
|
||||
<option value="thursday">Jeudi</option>
|
||||
<option value="friday">Vendredi</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="param-label">
|
||||
Heure limite :
|
||||
<input
|
||||
type="time"
|
||||
bind:value={noMondayAfterTime}
|
||||
disabled={isSubmitting}
|
||||
class="param-input"
|
||||
/>
|
||||
</label>
|
||||
<p class="rule-example">
|
||||
Exemple : un devoir pour lundi ne peut pas être créé après {noMondayAfterDay === 'friday' ? 'vendredi' : 'jeudi'}
|
||||
{noMondayAfterTime}.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Preview impact -->
|
||||
<section class="card">
|
||||
<h2>Résumé</h2>
|
||||
<div class="preview">
|
||||
<p>
|
||||
<strong>Mode :</strong>
|
||||
{modeLabel}
|
||||
</p>
|
||||
{#if enforcementMode !== 'disabled'}
|
||||
<p>
|
||||
<strong>Règles :</strong>
|
||||
{#if !minimumDelayEnabled && !noMondayAfterEnabled}
|
||||
Aucune règle configurée
|
||||
{:else}
|
||||
{#if minimumDelayEnabled}
|
||||
Délai minimum de {minimumDelayDays} jours.
|
||||
{/if}
|
||||
{#if noMondayAfterEnabled}
|
||||
Pas de devoir pour lundi après {noMondayAfterDay === 'friday' ? 'vendredi' : 'jeudi'}
|
||||
{noMondayAfterTime}.
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="preview-disabled">Les enseignants peuvent créer des devoirs librement.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" onclick={loadHistory} disabled={isSubmitting}>
|
||||
Voir l'historique
|
||||
</button>
|
||||
<div class="action-group">
|
||||
<button class="btn-secondary" onclick={handleCancel} disabled={isSubmitting || !hasChanges}>
|
||||
Annuler
|
||||
</button>
|
||||
<button class="btn-primary" onclick={handleSave} disabled={isSubmitting || !hasChanges}>
|
||||
{#if isSubmitting}
|
||||
Enregistrement...
|
||||
{:else}
|
||||
Enregistrer
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History modal -->
|
||||
{#if showHistory}
|
||||
<div
|
||||
class="modal-overlay"
|
||||
onclick={() => (showHistory = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (showHistory = false)}
|
||||
role="presentation"
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="modal-content"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Historique des changements"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h2>Historique des changements</h2>
|
||||
<button class="modal-close" onclick={() => (showHistory = false)}>×</button>
|
||||
</div>
|
||||
|
||||
{#if history.length === 0}
|
||||
<p class="empty-history">Aucun changement enregistré.</p>
|
||||
{:else}
|
||||
<div class="history-list">
|
||||
{#each history as entry}
|
||||
<div class="history-entry">
|
||||
<div class="history-date">{formatDate(entry.changedAt)}</div>
|
||||
<div class="history-details">
|
||||
<span class="history-mode">{entry.enforcementMode}</span>
|
||||
{#if !entry.enabled}
|
||||
<span class="history-badge disabled">Désactivé</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.125rem;
|
||||
opacity: 0.6;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
border: 2px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #8b5cf6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
/* Mode selector */
|
||||
.mode-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mode-option:hover:not(:disabled) {
|
||||
border-color: #a5b4fc;
|
||||
background: #f5f3ff;
|
||||
}
|
||||
|
||||
.mode-option.selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.mode-option:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.mode-description {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Rules */
|
||||
.rule-block {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.rule-block:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.rule-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.rule-toggle input[type='checkbox'] {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
accent-color: #6366f1;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.rule-params {
|
||||
margin-top: 0.75rem;
|
||||
margin-left: 1.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.param-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rule-example {
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.preview p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-disabled {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: white;
|
||||
color: #4b5563;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 300;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.history-entry {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.history-mode {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.history-badge.disabled {
|
||||
font-size: 0.75rem;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user