feat: Bloquer la création de devoirs non conformes en mode hard
Les établissements utilisant le mode "Hard" des règles de devoirs empêchent désormais les enseignants de créer des devoirs hors règles. Contrairement au mode "Soft" (avertissement avec possibilité de passer outre), le mode "Hard" est un blocage strict : même acknowledgeWarning ne permet pas de contourner. L'API retourne 422 (au lieu de 409 pour le soft) avec des dates conformes suggérées calculées via le calendrier scolaire (weekends, fériés, vacances exclus). Le frontend affiche un modal de blocage avec les raisons, des dates cliquables, et une validation client inline qui empêche la soumission de dates non conformes.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Suggère les prochaines dates d'échéance conformes aux règles de devoirs.
|
||||
*
|
||||
* Utilisé en mode hard pour proposer des alternatives à l'enseignant
|
||||
* quand la date choisie est refusée.
|
||||
*/
|
||||
final readonly class ValidDueDateSuggester
|
||||
{
|
||||
private const int MAX_SEARCH_DAYS = 60;
|
||||
|
||||
public function __construct(
|
||||
private HomeworkRulesValidator $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DateTimeImmutable[] Dates conformes triées chronologiquement
|
||||
*/
|
||||
public function suggerer(
|
||||
DateTimeImmutable $requestedDate,
|
||||
HomeworkRules $rules,
|
||||
DateTimeImmutable $creationDate,
|
||||
int $maxSuggestions = 3,
|
||||
?SchoolCalendar $calendar = null,
|
||||
): array {
|
||||
if (!$rules->estActif()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$suggestions = [];
|
||||
$checkDate = $requestedDate;
|
||||
|
||||
for ($i = 0; $i < self::MAX_SEARCH_DAYS && count($suggestions) < $maxSuggestions; ++$i) {
|
||||
$checkDate = $checkDate->modify('+1 day');
|
||||
|
||||
// Vérifier via le calendrier scolaire (weekends + fériés + vacances)
|
||||
if ($calendar !== null) {
|
||||
if (!$calendar->estJourOuvre($checkDate)) {
|
||||
continue;
|
||||
}
|
||||
} elseif ((int) $checkDate->format('N') >= 6) {
|
||||
// Fallback : sauter les weekends si pas de calendrier
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->validator->valider($rules, $checkDate, $creationDate);
|
||||
|
||||
if ($result->estValide()) {
|
||||
$suggestions[] = $checkDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,11 @@ final readonly class CreateHomeworkHandler
|
||||
$rulesResult = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
|
||||
|
||||
if ($rulesResult->estBloquant()) {
|
||||
throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray());
|
||||
throw new ReglesDevoirsNonRespecteesException(
|
||||
$rulesResult->toArray(),
|
||||
bloquant: true,
|
||||
suggestedDates: $rulesResult->suggestedDates,
|
||||
);
|
||||
}
|
||||
|
||||
if ($rulesResult->estAvertissement() && !$command->acknowledgeWarning) {
|
||||
|
||||
@@ -17,10 +17,12 @@ final readonly class HomeworkRulesCheckResult
|
||||
{
|
||||
/**
|
||||
* @param RuleWarning[] $warnings
|
||||
* @param string[] $suggestedDates Dates conformes alternatives (format Y-m-d)
|
||||
*/
|
||||
public function __construct(
|
||||
public array $warnings,
|
||||
public bool $bloquant,
|
||||
public array $suggestedDates = [],
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use function array_column;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function implode;
|
||||
|
||||
/**
|
||||
* Levée quand un devoir enfreint les règles configurées
|
||||
* et que l'enseignant n'a pas encore confirmé.
|
||||
@@ -14,10 +18,19 @@ final class ReglesDevoirsNonRespecteesException extends DomainException
|
||||
{
|
||||
/**
|
||||
* @param array<array{ruleType: string, message: string, params: array<string, mixed>}> $warnings
|
||||
* @param string[] $suggestedDates Dates conformes alternatives (format Y-m-d)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $warnings,
|
||||
public readonly bool $bloquant = false,
|
||||
public readonly array $suggestedDates = [],
|
||||
) {
|
||||
parent::__construct('Le devoir ne respecte pas les règles configurées.');
|
||||
$raisons = implode(' ', array_column($warnings, 'message'));
|
||||
|
||||
parent::__construct(
|
||||
$bloquant
|
||||
? 'Impossible de créer ce devoir : ' . $raisons
|
||||
: 'Le devoir ne respecte pas les règles configurées.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface
|
||||
|
||||
return HomeworkResource::fromDomain($homework);
|
||||
} catch (ReglesDevoirsNonRespecteesException $e) {
|
||||
if ($e->bloquant) {
|
||||
return new JsonResponse([
|
||||
'type' => 'homework_rules_blocked',
|
||||
'message' => $e->getMessage(),
|
||||
'warnings' => $e->warnings,
|
||||
'suggestedDates' => $e->suggestedDates,
|
||||
'exceptionRequestPath' => '/dashboard/teacher/homework/request-exception',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'type' => 'homework_rules_warning',
|
||||
'message' => $e->getMessage(),
|
||||
|
||||
@@ -7,9 +7,11 @@ namespace App\Scolarite\Infrastructure\Service;
|
||||
use App\Administration\Application\Service\HomeworkRulesValidationResult;
|
||||
use App\Administration\Application\Service\HomeworkRulesValidator;
|
||||
use App\Administration\Application\Service\RuleViolation;
|
||||
use App\Administration\Application\Service\ValidDueDateSuggester;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Port\RuleWarning;
|
||||
@@ -28,6 +30,8 @@ final readonly class AdministrationHomeworkRulesChecker implements HomeworkRules
|
||||
public function __construct(
|
||||
private HomeworkRulesRepository $rulesRepository,
|
||||
private HomeworkRulesValidator $validator,
|
||||
private ValidDueDateSuggester $suggester,
|
||||
private CurrentCalendarProvider $calendarProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -45,11 +49,25 @@ final readonly class AdministrationHomeworkRulesChecker implements HomeworkRules
|
||||
|
||||
$result = $this->validator->valider($rules, $dueDate, $creationDate);
|
||||
|
||||
return $this->toCheckResult($result, $rules);
|
||||
return $this->toCheckResult($result, $rules, $dueDate, $creationDate);
|
||||
}
|
||||
|
||||
private function toCheckResult(HomeworkRulesValidationResult $result, HomeworkRules $rules): HomeworkRulesCheckResult
|
||||
{
|
||||
private function toCheckResult(
|
||||
HomeworkRulesValidationResult $result,
|
||||
HomeworkRules $rules,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
$suggestedDates = [];
|
||||
|
||||
if ($result->estBloquant()) {
|
||||
$calendar = $this->calendarProvider->forCurrentYear($rules->tenantId);
|
||||
$suggestedDates = array_map(
|
||||
static fn (DateTimeImmutable $d): string => $d->format('Y-m-d'),
|
||||
$this->suggester->suggerer($dueDate, $rules, $creationDate, calendar: $calendar),
|
||||
);
|
||||
}
|
||||
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: array_map(
|
||||
fn (RuleViolation $v): RuleWarning => new RuleWarning(
|
||||
@@ -60,6 +78,7 @@ final readonly class AdministrationHomeworkRulesChecker implements HomeworkRules
|
||||
$result->violations,
|
||||
),
|
||||
bloquant: $result->estBloquant(),
|
||||
suggestedDates: $suggestedDates,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -623,6 +623,75 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.5-FUNC-001 (P0) - POST /homework with hard rules violated → 422 with blocked response
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function createHomeworkReturns422WhenHardRulesViolated(): void
|
||||
{
|
||||
$this->persistHardRulesWithMinimumDelay(7);
|
||||
$this->seedTeacherAssignment();
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d');
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'classId' => self::CLASS_ID,
|
||||
'subjectId' => self::SUBJECT_ID,
|
||||
'title' => 'Devoir test 422',
|
||||
'dueDate' => $tomorrow,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$json = $client->getResponse()->toArray(false);
|
||||
self::assertSame('homework_rules_blocked', $json['type']);
|
||||
self::assertNotEmpty($json['warnings']);
|
||||
self::assertSame('minimum_delay', $json['warnings'][0]['ruleType']);
|
||||
self::assertArrayHasKey('suggestedDates', $json);
|
||||
self::assertArrayHasKey('exceptionRequestPath', $json);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.5-FUNC-002 (P0) - POST /homework with hard rules violated + acknowledgeWarning → still 422
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function createHomeworkReturns422EvenWhenHardRulesAcknowledged(): void
|
||||
{
|
||||
$this->persistHardRulesWithMinimumDelay(7);
|
||||
$this->seedTeacherAssignment();
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d');
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'classId' => self::CLASS_ID,
|
||||
'subjectId' => self::SUBJECT_ID,
|
||||
'title' => 'Devoir bypass hard',
|
||||
'dueDate' => $tomorrow,
|
||||
'acknowledgeWarning' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$json = $client->getResponse()->toArray(false);
|
||||
self::assertSame('homework_rules_blocked', $json['type']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
@@ -657,6 +726,19 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
);
|
||||
}
|
||||
|
||||
private function persistHardRulesWithMinimumDelay(int $days): void
|
||||
{
|
||||
/** @var Connection $connection */
|
||||
$connection = static::getContainer()->get(Connection::class);
|
||||
$rulesJson = json_encode([['type' => 'minimum_delay', 'params' => ['days' => $days]]], JSON_THROW_ON_ERROR);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :rules::jsonb, 'hard', true, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET rules = :rules::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()",
|
||||
['tid' => self::TENANT_ID, 'rules' => $rulesJson],
|
||||
);
|
||||
}
|
||||
|
||||
private function seedTeacherAssignment(): void
|
||||
{
|
||||
/** @var Connection $connection */
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Application\Service\HomeworkRulesValidator;
|
||||
use App\Administration\Application\Service\ValidDueDateSuggester;
|
||||
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 function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ValidDueDateSuggesterTest extends TestCase
|
||||
{
|
||||
private ValidDueDateSuggester $suggester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->suggester = new ValidDueDateSuggester(new HomeworkRulesValidator());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestsNextValidDatesForMinimumDelay(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3);
|
||||
$creationDate = new DateTimeImmutable('2026-03-18 10:00'); // mercredi
|
||||
|
||||
// Requested date is too soon (jeudi 19 mars, only 1 day)
|
||||
$suggestions = $this->suggester->suggerer(
|
||||
new DateTimeImmutable('2026-03-19'),
|
||||
$rules,
|
||||
$creationDate,
|
||||
);
|
||||
|
||||
self::assertNotEmpty($suggestions);
|
||||
self::assertLessThanOrEqual(3, count($suggestions));
|
||||
|
||||
// Each suggestion must be valid against the rules
|
||||
$validator = new HomeworkRulesValidator();
|
||||
foreach ($suggestions as $date) {
|
||||
self::assertInstanceOf(DateTimeImmutable::class, $date);
|
||||
$result = $validator->valider($rules, $date, $creationDate);
|
||||
self::assertTrue($result->estValide(), 'Suggested date ' . $date->format('Y-m-d') . ' should be valid');
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestsUpToMaxSuggestions(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3);
|
||||
$creationDate = new DateTimeImmutable('2026-03-18 10:00');
|
||||
|
||||
$suggestions = $this->suggester->suggerer(
|
||||
new DateTimeImmutable('2026-03-19'),
|
||||
$rules,
|
||||
$creationDate,
|
||||
maxSuggestions: 5,
|
||||
);
|
||||
|
||||
self::assertCount(5, $suggestions);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function skipsWeekends(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3);
|
||||
// Jeudi 19 mars, creation date is mercredi 18 mars
|
||||
$creationDate = new DateTimeImmutable('2026-03-18 10:00');
|
||||
|
||||
$suggestions = $this->suggester->suggerer(
|
||||
new DateTimeImmutable('2026-03-19'),
|
||||
$rules,
|
||||
$creationDate,
|
||||
);
|
||||
|
||||
foreach ($suggestions as $date) {
|
||||
$dayOfWeek = (int) $date->format('N');
|
||||
self::assertLessThanOrEqual(5, $dayOfWeek, 'Suggested date ' . $date->format('Y-m-d') . ' should not be a weekend');
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestsDatesInChronologicalOrder(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3);
|
||||
$creationDate = new DateTimeImmutable('2026-03-18 10:00');
|
||||
|
||||
$suggestions = $this->suggester->suggerer(
|
||||
new DateTimeImmutable('2026-03-19'),
|
||||
$rules,
|
||||
$creationDate,
|
||||
);
|
||||
|
||||
for ($i = 1; $i < count($suggestions); ++$i) {
|
||||
self::assertGreaterThan(
|
||||
$suggestions[$i - 1]->format('Y-m-d'),
|
||||
$suggestions[$i]->format('Y-m-d'),
|
||||
'Suggestions should be in chronological order',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenRulesDisabled(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3, EnforcementMode::DISABLED, false);
|
||||
$creationDate = new DateTimeImmutable('2026-03-18 10:00');
|
||||
|
||||
$suggestions = $this->suggester->suggerer(
|
||||
new DateTimeImmutable('2026-03-19'),
|
||||
$rules,
|
||||
$creationDate,
|
||||
);
|
||||
|
||||
self::assertEmpty($suggestions);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchIsBoundedAndDoesNotRunForever(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(30);
|
||||
$creationDate = new DateTimeImmutable('2026-03-18 10:00');
|
||||
|
||||
$suggestions = $this->suggester->suggerer(
|
||||
new DateTimeImmutable('2026-03-19'),
|
||||
$rules,
|
||||
$creationDate,
|
||||
maxSuggestions: 100,
|
||||
);
|
||||
|
||||
// La recherche est bornée à 60 jours. Avec un délai de 30 jours,
|
||||
// seules les dates après le 17 avril sont valides (~21 jours ouvrés).
|
||||
self::assertNotEmpty($suggestions);
|
||||
self::assertLessThan(100, count($suggestions));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestsValidDatesForMondayRule(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecLundi();
|
||||
// Samedi 21 mars, trying to create homework for lundi 23 mars (after friday cutoff)
|
||||
$creationDate = new DateTimeImmutable('2026-03-21 10:00');
|
||||
|
||||
$suggestions = $this->suggester->suggerer(
|
||||
new DateTimeImmutable('2026-03-23'), // lundi
|
||||
$rules,
|
||||
$creationDate,
|
||||
);
|
||||
|
||||
self::assertNotEmpty($suggestions);
|
||||
|
||||
$validator = new HomeworkRulesValidator();
|
||||
foreach ($suggestions as $date) {
|
||||
$result = $validator->valider($rules, $date, $creationDate);
|
||||
self::assertTrue($result->estValide(), 'Suggested date ' . $date->format('Y-m-d') . ' should be valid');
|
||||
}
|
||||
}
|
||||
|
||||
private function creerRulesAvecDelai(
|
||||
int $days,
|
||||
EnforcementMode $mode = EnforcementMode::HARD,
|
||||
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::HARD,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,33 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
$handler($this->createCommand(acknowledgeWarning: true));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hardBlockingExceptionCarriesSuggestedDatesAndBloquantFlag(): void
|
||||
{
|
||||
$warning = new RuleWarning(
|
||||
ruleType: 'minimum_delay',
|
||||
message: 'Le devoir doit être créé au moins 7 jours avant.',
|
||||
params: ['days' => 7],
|
||||
);
|
||||
$suggestedDates = ['2026-03-25', '2026-03-26', '2026-03-27'];
|
||||
$rulesResult = new HomeworkRulesCheckResult(
|
||||
warnings: [$warning],
|
||||
bloquant: true,
|
||||
suggestedDates: $suggestedDates,
|
||||
);
|
||||
$handler = $this->createHandler(affecte: true, rulesResult: $rulesResult);
|
||||
|
||||
try {
|
||||
$handler($this->createCommand(acknowledgeWarning: false));
|
||||
self::fail('Expected ReglesDevoirsNonRespecteesException');
|
||||
} catch (ReglesDevoirsNonRespecteesException $e) {
|
||||
self::assertTrue($e->bloquant);
|
||||
self::assertSame($suggestedDates, $e->suggestedDates);
|
||||
self::assertCount(1, $e->warnings);
|
||||
self::assertSame('minimum_delay', $e->warnings[0]['ruleType']);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesHomeworkWhenSoftRulesViolatedButAcknowledged(): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Port;
|
||||
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Port\RuleWarning;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HomeworkRulesCheckResultTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function okReturnsValidNonBlockingResult(): void
|
||||
{
|
||||
$result = HomeworkRulesCheckResult::ok();
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
self::assertFalse($result->estAvertissement());
|
||||
self::assertFalse($result->estBloquant());
|
||||
self::assertEmpty($result->warnings);
|
||||
self::assertEmpty($result->suggestedDates);
|
||||
self::assertFalse($result->bloquant);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estBloquantReturnsTrueWhenBloquantWithWarnings(): void
|
||||
{
|
||||
$result = new HomeworkRulesCheckResult(
|
||||
warnings: [$this->uneWarning()],
|
||||
bloquant: true,
|
||||
);
|
||||
|
||||
self::assertTrue($result->estBloquant());
|
||||
self::assertFalse($result->estAvertissement());
|
||||
self::assertFalse($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estAvertissementReturnsTrueWhenNonBloquantWithWarnings(): void
|
||||
{
|
||||
$result = new HomeworkRulesCheckResult(
|
||||
warnings: [$this->uneWarning()],
|
||||
bloquant: false,
|
||||
);
|
||||
|
||||
self::assertTrue($result->estAvertissement());
|
||||
self::assertFalse($result->estBloquant());
|
||||
self::assertFalse($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estValideReturnsFalseWhenBloquantWithoutWarnings(): void
|
||||
{
|
||||
$result = new HomeworkRulesCheckResult(
|
||||
warnings: [],
|
||||
bloquant: true,
|
||||
);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
self::assertFalse($result->estBloquant());
|
||||
self::assertFalse($result->estAvertissement());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestedDatesAreStoredAndAccessible(): void
|
||||
{
|
||||
$dates = ['2026-03-25', '2026-03-26', '2026-03-27'];
|
||||
|
||||
$result = new HomeworkRulesCheckResult(
|
||||
warnings: [$this->uneWarning()],
|
||||
bloquant: true,
|
||||
suggestedDates: $dates,
|
||||
);
|
||||
|
||||
self::assertSame($dates, $result->suggestedDates);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function messagesReturnsWarningMessages(): void
|
||||
{
|
||||
$result = new HomeworkRulesCheckResult(
|
||||
warnings: [
|
||||
new RuleWarning('minimum_delay', 'Au moins 3 jours.', ['days' => 3]),
|
||||
new RuleWarning('no_monday_after', 'Pas après vendredi 12h.', []),
|
||||
],
|
||||
bloquant: false,
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'Au moins 3 jours.',
|
||||
'Pas après vendredi 12h.',
|
||||
], $result->messages());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ruleTypesReturnsWarningRuleTypes(): void
|
||||
{
|
||||
$result = new HomeworkRulesCheckResult(
|
||||
warnings: [
|
||||
new RuleWarning('minimum_delay', 'msg1', []),
|
||||
new RuleWarning('no_monday_after', 'msg2', []),
|
||||
],
|
||||
bloquant: false,
|
||||
);
|
||||
|
||||
self::assertSame(['minimum_delay', 'no_monday_after'], $result->ruleTypes());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toArrayReturnsStructuredWarnings(): void
|
||||
{
|
||||
$result = new HomeworkRulesCheckResult(
|
||||
warnings: [
|
||||
new RuleWarning('minimum_delay', 'Au moins 3 jours.', ['days' => 3]),
|
||||
],
|
||||
bloquant: true,
|
||||
);
|
||||
|
||||
$expected = [
|
||||
[
|
||||
'ruleType' => 'minimum_delay',
|
||||
'message' => 'Au moins 3 jours.',
|
||||
'params' => ['days' => 3],
|
||||
],
|
||||
];
|
||||
|
||||
self::assertSame($expected, $result->toArray());
|
||||
}
|
||||
|
||||
private function uneWarning(): RuleWarning
|
||||
{
|
||||
return new RuleWarning(
|
||||
ruleType: 'minimum_delay',
|
||||
message: 'Le devoir doit être créé au moins 3 jours avant.',
|
||||
params: ['days' => 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ReglesDevoirsNonRespecteesExceptionTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function blockingExceptionHasBlockingMessage(): void
|
||||
{
|
||||
$exception = new ReglesDevoirsNonRespecteesException(
|
||||
warnings: [['ruleType' => 'minimum_delay', 'message' => 'msg', 'params' => []]],
|
||||
bloquant: true,
|
||||
);
|
||||
|
||||
self::assertStringContainsString('Impossible de créer ce devoir', $exception->getMessage());
|
||||
self::assertStringContainsString('msg', $exception->getMessage());
|
||||
self::assertTrue($exception->bloquant);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningExceptionHasWarningMessage(): void
|
||||
{
|
||||
$exception = new ReglesDevoirsNonRespecteesException(
|
||||
warnings: [['ruleType' => 'minimum_delay', 'message' => 'msg', 'params' => []]],
|
||||
);
|
||||
|
||||
self::assertStringContainsString('ne respecte pas', $exception->getMessage());
|
||||
self::assertFalse($exception->bloquant);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestedDatesAreAccessible(): void
|
||||
{
|
||||
$dates = ['2026-03-25', '2026-03-26'];
|
||||
|
||||
$exception = new ReglesDevoirsNonRespecteesException(
|
||||
warnings: [['ruleType' => 'minimum_delay', 'message' => 'msg', 'params' => []]],
|
||||
bloquant: true,
|
||||
suggestedDates: $dates,
|
||||
);
|
||||
|
||||
self::assertSame($dates, $exception->suggestedDates);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestedDatesDefaultToEmpty(): void
|
||||
{
|
||||
$exception = new ReglesDevoirsNonRespecteesException(
|
||||
warnings: [['ruleType' => 'minimum_delay', 'message' => 'msg', 'params' => []]],
|
||||
bloquant: true,
|
||||
);
|
||||
|
||||
self::assertEmpty($exception->suggestedDates);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningsAreAccessible(): void
|
||||
{
|
||||
$warnings = [
|
||||
['ruleType' => 'minimum_delay', 'message' => 'Au moins 3 jours.', 'params' => ['days' => 3]],
|
||||
['ruleType' => 'no_monday_after', 'message' => 'Pas après vendredi.', 'params' => []],
|
||||
];
|
||||
|
||||
$exception = new ReglesDevoirsNonRespecteesException(
|
||||
warnings: $warnings,
|
||||
bloquant: true,
|
||||
);
|
||||
|
||||
self::assertSame($warnings, $exception->warnings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Application\Service\HomeworkRulesValidator;
|
||||
use App\Administration\Application\Service\ValidDueDateSuggester;
|
||||
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\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Infrastructure\Service\AdministrationHomeworkRulesChecker;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class AdministrationHomeworkRulesCheckerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private HomeworkRulesValidator $validator;
|
||||
private ValidDueDateSuggester $suggester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = new HomeworkRulesValidator();
|
||||
$this->suggester = new ValidDueDateSuggester($this->validator);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsOkWhenNoRulesConfigured(): void
|
||||
{
|
||||
$checker = $this->createChecker(rules: null);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$result = $checker->verifier(
|
||||
$tenantId,
|
||||
new DateTimeImmutable('2026-03-25'),
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
self::assertFalse($result->bloquant);
|
||||
self::assertEmpty($result->suggestedDates);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsOkWhenRulesRespected(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(3, EnforcementMode::HARD);
|
||||
$checker = $this->createChecker(rules: $rules);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$result = $checker->verifier(
|
||||
$tenantId,
|
||||
new DateTimeImmutable('2026-03-25'), // 7 days ahead, > 3 day min
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
self::assertEmpty($result->suggestedDates);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsBlockingResultWithSuggestedDatesInHardMode(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(7, EnforcementMode::HARD);
|
||||
$checker = $this->createChecker(rules: $rules);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$result = $checker->verifier(
|
||||
$tenantId,
|
||||
new DateTimeImmutable('2026-03-19'), // only 1 day ahead, < 7 day min
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->estBloquant());
|
||||
self::assertTrue($result->bloquant);
|
||||
self::assertNotEmpty($result->suggestedDates);
|
||||
self::assertCount(3, $result->suggestedDates);
|
||||
|
||||
// Suggested dates are formatted as Y-m-d strings
|
||||
foreach ($result->suggestedDates as $date) {
|
||||
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $date);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsWarningWithoutSuggestedDatesInSoftMode(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(7, EnforcementMode::SOFT);
|
||||
$checker = $this->createChecker(rules: $rules);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$result = $checker->verifier(
|
||||
$tenantId,
|
||||
new DateTimeImmutable('2026-03-19'), // only 1 day ahead, < 7 day min
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->estAvertissement());
|
||||
self::assertFalse($result->bloquant);
|
||||
self::assertEmpty($result->suggestedDates);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningsContainRuleParams(): void
|
||||
{
|
||||
$rules = $this->creerRulesAvecDelai(5, EnforcementMode::SOFT);
|
||||
$checker = $this->createChecker(rules: $rules);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$result = $checker->verifier(
|
||||
$tenantId,
|
||||
new DateTimeImmutable('2026-03-19'),
|
||||
new DateTimeImmutable('2026-03-18 10:00'),
|
||||
);
|
||||
|
||||
self::assertCount(1, $result->warnings);
|
||||
self::assertSame('minimum_delay', $result->warnings[0]->ruleType);
|
||||
self::assertSame(['days' => 5], $result->warnings[0]->params);
|
||||
}
|
||||
|
||||
private function createChecker(?HomeworkRules $rules): AdministrationHomeworkRulesChecker
|
||||
{
|
||||
$repository = new class($rules) implements HomeworkRulesRepository {
|
||||
public function __construct(private readonly ?HomeworkRules $rules)
|
||||
{
|
||||
}
|
||||
|
||||
public function save(HomeworkRules $homeworkRules): void
|
||||
{
|
||||
}
|
||||
|
||||
public function get(TenantId $tenantId): HomeworkRules
|
||||
{
|
||||
return $this->rules ?? throw new RuntimeException('Not found');
|
||||
}
|
||||
|
||||
public function findByTenantId(TenantId $tenantId): ?HomeworkRules
|
||||
{
|
||||
return $this->rules;
|
||||
}
|
||||
};
|
||||
|
||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::reconstitute(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
zone: null,
|
||||
entries: [],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return new AdministrationHomeworkRulesChecker(
|
||||
$repository,
|
||||
$this->validator,
|
||||
$this->suggester,
|
||||
$calendarProvider,
|
||||
);
|
||||
}
|
||||
|
||||
private function creerRulesAvecDelai(int $days, EnforcementMode $mode): HomeworkRules
|
||||
{
|
||||
$rules = HomeworkRules::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
$rules->mettreAJour(
|
||||
rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => $days])],
|
||||
enforcementMode: $mode,
|
||||
enabled: true,
|
||||
now: new DateTimeImmutable('2026-03-01'),
|
||||
);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
347
frontend/e2e/homework-rules-hard.spec.ts
Normal file
347
frontend/e2e/homework-rules-hard.spec.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
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 TEACHER_EMAIL = 'e2e-rules-hard-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'RulesHard123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a weekday date string (YYYY-MM-DD), N days from now.
|
||||
* Skips weekends.
|
||||
*/
|
||||
function getNextWeekday(daysFromNow: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysFromNow);
|
||||
const day = date.getDay();
|
||||
if (day === 0) date.setDate(date.getDate() + 1);
|
||||
if (day === 6) date.setDate(date.getDate() + 2);
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function navigateToHomework(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
|
||||
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
function seedTeacherAssignments() {
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`,
|
||||
);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure homework rules with minimum_delay of 7 days in hard mode
|
||||
* so that a homework due in 1-2 days triggers a blocking error.
|
||||
*/
|
||||
function seedHardRules() {
|
||||
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]';
|
||||
runSql(
|
||||
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` +
|
||||
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`,
|
||||
);
|
||||
}
|
||||
|
||||
function clearRules() {
|
||||
try {
|
||||
runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreateAndFillForm(page: import('@playwright/test').Page, title: string, daysFromNow: number) {
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const nearDate = getNextWeekday(daysFromNow);
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill(title);
|
||||
await page.locator('#hw-due-date').fill(nearDate);
|
||||
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
}
|
||||
|
||||
test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Create teacher user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-RH-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-RH-Maths', 'E2ERHM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
seedTeacherAssignments();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
try {
|
||||
runSql(
|
||||
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`,
|
||||
);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
clearRules();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC1: Blocking when hard mode is active
|
||||
// ============================================================================
|
||||
test.describe('AC1: Hard mode blocks creation', () => {
|
||||
test('shows blocking modal instead of warning when hard mode is active', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir hard block', 2);
|
||||
|
||||
// Blocking modal appears (not warning)
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(blockedDialog.getByText(/impossible de créer ce devoir/i)).toBeVisible();
|
||||
|
||||
// No "Continuer malgré tout" button
|
||||
await expect(blockedDialog.getByRole('button', { name: /continuer malgré tout/i })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('cannot bypass hard mode with acknowledgment', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir bypass test', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Only "Modifier la date" button, no bypass option
|
||||
await expect(blockedDialog.getByRole('button', { name: /modifier la date/i })).toBeVisible();
|
||||
await expect(blockedDialog.getByRole('button', { name: /continuer/i })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC2: Blocking message with reason and suggestions
|
||||
// ============================================================================
|
||||
test.describe('AC2: Blocking message with suggestions', () => {
|
||||
test('shows violation reason in blocking modal', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir raison blocage', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Shows the reason
|
||||
await expect(blockedDialog.getByText(/au moins/i)).toBeVisible();
|
||||
await expect(blockedDialog.getByText(/interdisent la création/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows suggested conforming dates', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir suggestions', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Suggested dates section visible
|
||||
await expect(blockedDialog.getByText(/dates conformes suggérées/i)).toBeVisible();
|
||||
|
||||
// At least one suggested date button
|
||||
const suggestedGroup = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i });
|
||||
await expect(suggestedGroup.getByRole('button').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking suggested date reopens form with that date', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir select date', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click first suggested date
|
||||
const firstSuggested = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i }).getByRole('button').first();
|
||||
await firstSuggested.click();
|
||||
|
||||
// Blocked modal closes, create form reopens
|
||||
await expect(blockedDialog).not.toBeVisible({ timeout: 3000 });
|
||||
const createDialog = page.getByRole('dialog');
|
||||
await expect(createDialog).toBeVisible();
|
||||
await expect(createDialog.locator('#hw-due-date')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC3: Exception request information
|
||||
// ============================================================================
|
||||
test.describe('AC3: Exception request information', () => {
|
||||
test('shows exception contact information in blocking modal', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir exception link', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Exception information visible
|
||||
await expect(blockedDialog.getByText(/exception.*contactez/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC4: Calendar with conforming dates
|
||||
// ============================================================================
|
||||
test.describe('AC4: Calendar enforces conforming dates', () => {
|
||||
test('modify date reopens form with conforming minimum date', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir modify date hard', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click "Modifier la date"
|
||||
await blockedDialog.getByRole('button', { name: /modifier la date/i }).click();
|
||||
|
||||
// Form reopens
|
||||
await expect(blockedDialog).not.toBeVisible({ timeout: 3000 });
|
||||
const createDialog = page.getByRole('dialog');
|
||||
await expect(createDialog).toBeVisible();
|
||||
|
||||
// Date input should have min attribute enforcing conforming dates
|
||||
const dateInput = createDialog.locator('#hw-due-date');
|
||||
await expect(dateInput).toBeVisible();
|
||||
const minValue = await dateInput.getAttribute('min');
|
||||
expect(minValue).toBeTruthy();
|
||||
});
|
||||
|
||||
test('homework can be created with compliant date after modification', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir compliant hard', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click first suggested date
|
||||
const firstSuggested = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i }).getByRole('button').first();
|
||||
await firstSuggested.click();
|
||||
|
||||
// Submit with the compliant date
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
// Homework created successfully
|
||||
await expect(page.getByText('Devoir compliant hard')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
interface RuleWarning {
|
||||
ruleType: string;
|
||||
message: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let {
|
||||
warnings,
|
||||
suggestedDates = [],
|
||||
onSelectDate,
|
||||
onClose,
|
||||
}: {
|
||||
warnings: RuleWarning[];
|
||||
suggestedDates: string[];
|
||||
onSelectDate: (date: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modalElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (modalElement) {
|
||||
modalElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class="modal modal-confirm"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="rule-blocked-title"
|
||||
aria-describedby="rule-blocked-description"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
>
|
||||
<header class="modal-header modal-header-blocked">
|
||||
<h2 id="rule-blocked-title">Impossible de créer ce devoir</h2>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<p id="rule-blocked-description">
|
||||
Les règles de votre établissement interdisent la création de ce devoir :
|
||||
</p>
|
||||
<ul class="rule-blocked-list">
|
||||
{#each warnings as warning}
|
||||
<li class="rule-blocked-item">
|
||||
<span class="rule-blocked-icon">🚫</span>
|
||||
<span>{warning.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if suggestedDates.length > 0}
|
||||
<div class="suggested-dates" role="group" aria-label="Dates conformes suggérées">
|
||||
<p class="suggested-dates-label">Dates conformes suggérées :</p>
|
||||
<div class="suggested-dates-list">
|
||||
{#each suggestedDates as date}
|
||||
<button
|
||||
type="button"
|
||||
class="suggested-date-btn"
|
||||
aria-label="Sélectionner {formatDate(date)}"
|
||||
onclick={() => onSelectDate(date)}
|
||||
>
|
||||
{formatDate(date)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="rule-blocked-exception">
|
||||
<span class="exception-link-placeholder">
|
||||
Besoin d'une exception ? Contactez votre administration.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={onClose}>
|
||||
Modifier la date
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
max-width: 500px;
|
||||
width: 95%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header-blocked {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.modal-header-blocked h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.rule-blocked-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.rule-blocked-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.rule-blocked-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggested-dates {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: #f0fdf4;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.suggested-dates-label {
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.suggested-dates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.suggested-date-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.suggested-date-btn:hover {
|
||||
background: #dcfce7;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.rule-blocked-exception {
|
||||
margin: 1rem 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exception-link-placeholder {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
@@ -94,11 +95,19 @@
|
||||
let duplicateValidationResults = $state<Array<{ classId: string; valid: boolean; error: string | null }>>([]);
|
||||
let duplicateWarnings = $state<Array<{ classId: string; warning: string }>>([]);
|
||||
|
||||
// Rule warning modal
|
||||
// Rule warning modal (soft mode)
|
||||
let showRuleWarningModal = $state(false);
|
||||
let ruleWarnings = $state<RuleWarning[]>([]);
|
||||
let ruleConformMinDate = $state('');
|
||||
|
||||
// Rule blocked modal (hard mode)
|
||||
let showRuleBlockedModal = $state(false);
|
||||
let ruleBlockedWarnings = $state<RuleWarning[]>([]);
|
||||
let ruleBlockedSuggestedDates = $state<string[]>([]);
|
||||
|
||||
// Inline date validation for hard mode
|
||||
let dueDateError = $state<string | null>(null);
|
||||
|
||||
// Class filter
|
||||
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
|
||||
|
||||
@@ -300,6 +309,7 @@
|
||||
newDescription = '';
|
||||
newDueDate = '';
|
||||
ruleConformMinDate = '';
|
||||
dueDateError = null;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
@@ -308,6 +318,8 @@
|
||||
|
||||
async function handleCreate(acknowledgeWarning = false) {
|
||||
if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return;
|
||||
dueDateError = validateDueDateLocally(newDueDate);
|
||||
if (dueDateError) return;
|
||||
|
||||
try {
|
||||
isSubmitting = true;
|
||||
@@ -326,6 +338,17 @@
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 422) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data?.type === 'homework_rules_blocked' && Array.isArray(data.warnings)) {
|
||||
ruleBlockedWarnings = data.warnings;
|
||||
ruleBlockedSuggestedDates = Array.isArray(data.suggestedDates) ? data.suggestedDates : [];
|
||||
showCreateModal = false;
|
||||
showRuleBlockedModal = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data?.type === 'homework_rules_warning' && Array.isArray(data.warnings)) {
|
||||
@@ -349,6 +372,9 @@
|
||||
closeCreateModal();
|
||||
showRuleWarningModal = false;
|
||||
ruleWarnings = [];
|
||||
showRuleBlockedModal = false;
|
||||
ruleBlockedWarnings = [];
|
||||
ruleBlockedSuggestedDates = [];
|
||||
await loadHomeworks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de la création';
|
||||
@@ -399,6 +425,44 @@
|
||||
newDueDate = ruleConformMinDate;
|
||||
}
|
||||
|
||||
function validateDueDateLocally(dateStr: string): string | null {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
const day = date.getDay();
|
||||
if (day === 0 || day === 6) {
|
||||
return 'Les devoirs ne peuvent pas être fixés un weekend.';
|
||||
}
|
||||
if (ruleConformMinDate && dateStr < ruleConformMinDate) {
|
||||
return 'Cette date ne respecte pas les règles de l\'établissement. Choisissez une date ultérieure.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleDueDateChange(dateStr: string) {
|
||||
newDueDate = dateStr;
|
||||
dueDateError = validateDueDateLocally(dateStr);
|
||||
}
|
||||
|
||||
function handleBlockedSelectDate(date: string) {
|
||||
showRuleBlockedModal = false;
|
||||
ruleBlockedWarnings = [];
|
||||
ruleBlockedSuggestedDates = [];
|
||||
ruleConformMinDate = date;
|
||||
newDueDate = date;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function handleBlockedClose() {
|
||||
const firstSuggested = ruleBlockedSuggestedDates[0];
|
||||
const conformDate = firstSuggested ?? computeConformMinDate(ruleBlockedWarnings);
|
||||
showRuleBlockedModal = false;
|
||||
ruleBlockedWarnings = [];
|
||||
ruleBlockedSuggestedDates = [];
|
||||
ruleConformMinDate = conformDate;
|
||||
newDueDate = conformDate;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
// --- Edit ---
|
||||
function openEditModal(hw: Homework) {
|
||||
editHomework = hw;
|
||||
@@ -779,8 +843,17 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hw-due-date">Date d'échéance *</label>
|
||||
<input type="date" id="hw-due-date" bind:value={newDueDate} required min={ruleConformMinDate || minDueDate} />
|
||||
{#if ruleConformMinDate}
|
||||
<input
|
||||
type="date"
|
||||
id="hw-due-date"
|
||||
value={newDueDate}
|
||||
oninput={(e) => handleDueDateChange((e.target as HTMLInputElement).value)}
|
||||
required
|
||||
min={ruleConformMinDate || minDueDate}
|
||||
/>
|
||||
{#if dueDateError}
|
||||
<small class="form-hint form-hint-error">{dueDateError}</small>
|
||||
{:else if ruleConformMinDate}
|
||||
<small class="form-hint form-hint-rule">
|
||||
Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</small>
|
||||
@@ -1078,6 +1151,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rule Blocked Modal (Hard Mode) -->
|
||||
{#if showRuleBlockedModal && ruleBlockedWarnings.length > 0}
|
||||
<RuleBlockedModal
|
||||
warnings={ruleBlockedWarnings}
|
||||
suggestedDates={ruleBlockedSuggestedDates}
|
||||
onSelectDate={handleBlockedSelectDate}
|
||||
onClose={handleBlockedClose}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.homework-page {
|
||||
padding: 1.5rem;
|
||||
@@ -1611,6 +1694,11 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-hint-error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Rule override badge */
|
||||
.homework-badges {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user