feat: Avertir l'enseignant quand un devoir ne respecte pas les règles (mode soft)
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Quand un établissement configure des règles de devoirs en mode "soft",
l'enseignant est maintenant averti avant la création si la date d'échéance
ne respecte pas les contraintes (délai minimum, pas de lundi après un
certain créneau). Il peut alors choisir de continuer (avec traçabilité)
ou de modifier la date vers une date conforme.

Le mode "hard" (blocage) reste protégé : acknowledgeWarning ne permet
pas de contourner les règles bloquantes, préparant la story 5.5.
This commit is contained in:
2026-03-18 16:37:16 +01:00
parent 706ec43473
commit c46d053db7
17 changed files with 1223 additions and 11 deletions

View File

@@ -0,0 +1,361 @@
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-warn-teacher@example.com';
const TEACHER_PASSWORD = 'RulesWarn123';
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 soft mode
* so that a homework due in 1-2 days triggers a warning.
*/
function seedSoftRules() {
// Use escaped quotes for JSON inside double-quoted shell command
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, 'soft', true, NOW(), NOW()) ` +
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()`,
);
}
function clearRules() {
try {
runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
}
test.describe('Homework Rules - Soft Warning (Story 5.4)', () => {
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-RW-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-RW-Maths', 'E2ERWM', '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 + AC2: Warning displayed with rule description
// ============================================================================
test.describe('AC1 + AC2: Warning display', () => {
test('shows warning modal when due date violates soft rule', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Open create modal
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
// Fill form with near due date (2 days → violates 7-day minimum_delay)
const nearDate = getNextWeekday(2);
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('Devoir test warning');
await page.locator('#hw-due-date').fill(nearDate);
// Submit
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Warning modal appears
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
await expect(warningDialog.getByText(/ne respecte pas les règles/i)).toBeVisible();
await expect(warningDialog.getByText(/au moins/i)).toBeVisible();
await expect(warningDialog.getByText(/votre choix sera enregistré/i)).toBeVisible();
});
test('no warning when rules not configured', async ({ page }) => {
clearRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
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('Devoir sans rules');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
// No warning, homework created directly
await expect(page.getByRole('alertdialog')).not.toBeVisible({ timeout: 3000 });
await expect(page.getByText('Devoir sans rules')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC3: Continue despite warning → homework created, event traced
// ============================================================================
test.describe('AC3: Continue despite warning', () => {
test('creates homework when choosing to continue', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
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('Devoir continue warning');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Warning modal appears
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
// Click continue
await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click();
// Warning modal closes, homework appears in list
await expect(warningDialog).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('Devoir continue warning')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC4: Modify date → calendar reopens
// ============================================================================
test.describe('AC4: Modify date', () => {
test('reopens create form when choosing to modify date', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
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('Devoir modifier date');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Warning modal appears
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
// Click modify date
await warningDialog.getByRole('button', { name: /modifier la date/i }).click();
// Warning closes, create form reopens
await expect(warningDialog).not.toBeVisible({ timeout: 3000 });
const createDialog = page.getByRole('dialog');
await expect(createDialog).toBeVisible();
await expect(createDialog.locator('#hw-due-date')).toBeVisible();
// Change to a compliant date (15 days from now)
const farDate = getNextWeekday(15);
await page.locator('#hw-due-date').fill(farDate);
// Submit again — should succeed without warning
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByText('Devoir modifier date')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC5: History badge for overridden homeworks
// ============================================================================
test.describe('AC5: Discreet badge in history', () => {
test('shows warning badge on homework created with override', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Create homework with override
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
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('Devoir avec badge');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click();
// Homework card should have the warning badge
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
await expect(card.locator('.badge-rule-override')).toBeVisible();
});
test('no badge on homework created without override', async ({ page }) => {
clearRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const farDate = getNextWeekday(15);
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('Devoir normal');
await page.locator('#hw-due-date').fill(farDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByText('Devoir normal')).toBeVisible({ timeout: 10000 });
const card = page.locator('.homework-card', { hasText: 'Devoir normal' });
await expect(card.locator('.badge-rule-override')).not.toBeVisible();
});
});
});

View File

@@ -18,10 +18,17 @@
status: string;
className: string | null;
subjectName: string | null;
hasRuleOverride: boolean;
createdAt: string;
updatedAt: string;
}
interface RuleWarning {
ruleType: string;
message: string;
params: Record<string, unknown>;
}
interface TeacherAssignment {
id: string;
classId: string;
@@ -87,6 +94,11 @@
let duplicateValidationResults = $state<Array<{ classId: string; valid: boolean; error: string | null }>>([]);
let duplicateWarnings = $state<Array<{ classId: string; warning: string }>>([]);
// Rule warning modal
let showRuleWarningModal = $state(false);
let ruleWarnings = $state<RuleWarning[]>([]);
let ruleConformMinDate = $state('');
// Class filter
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
@@ -287,13 +299,14 @@
newTitle = '';
newDescription = '';
newDueDate = '';
ruleConformMinDate = '';
}
function closeCreateModal() {
showCreateModal = false;
}
async function handleCreate() {
async function handleCreate(acknowledgeWarning = false) {
if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return;
try {
@@ -309,9 +322,20 @@
title: newTitle.trim(),
description: newDescription.trim() || null,
dueDate: newDueDate,
acknowledgeWarning,
}),
});
if (response.status === 409) {
const data = await response.json().catch(() => null);
if (data?.type === 'homework_rules_warning' && Array.isArray(data.warnings)) {
ruleWarnings = data.warnings;
showCreateModal = false;
showRuleWarningModal = true;
return;
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const msg =
@@ -323,6 +347,8 @@
}
closeCreateModal();
showRuleWarningModal = false;
ruleWarnings = [];
await loadHomeworks();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
@@ -331,6 +357,48 @@
}
}
function handleContinueDespiteWarning() {
showRuleWarningModal = false;
handleCreate(true);
}
function computeConformMinDate(warnings: RuleWarning[]): string {
let minDate = new Date();
minDate.setDate(minDate.getDate() + 1); // au moins demain
for (const w of warnings) {
if (w.ruleType === 'minimum_delay' && typeof w.params['days'] === 'number') {
const ruleMin = new Date();
ruleMin.setDate(ruleMin.getDate() + (w.params['days'] as number));
if (ruleMin > minDate) minDate = ruleMin;
}
if (w.ruleType === 'no_monday_after') {
// Si le lundi est interdit (deadline dépassée), proposer mardi
// car le problème ne concerne que les devoirs pour lundi
const nextTuesday = new Date();
nextTuesday.setDate(nextTuesday.getDate() + ((9 - nextTuesday.getDay()) % 7 || 7));
if (nextTuesday > minDate) minDate = nextTuesday;
}
}
// Sauter les weekends
const day = minDate.getDay();
if (day === 0) minDate.setDate(minDate.getDate() + 1);
if (day === 6) minDate.setDate(minDate.getDate() + 2);
const y = minDate.getFullYear();
const m = String(minDate.getMonth() + 1).padStart(2, '0');
const d = String(minDate.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function handleModifyDate() {
ruleConformMinDate = computeConformMinDate(ruleWarnings);
showRuleWarningModal = false;
showCreateModal = true;
newDueDate = ruleConformMinDate;
}
// --- Edit ---
function openEditModal(hw: Homework) {
editHomework = hw;
@@ -584,9 +652,14 @@
<div class="homework-card" class:overdue={isOverdue(hw.dueDate)}>
<div class="homework-header">
<h3 class="homework-title">{hw.title}</h3>
<span class="homework-status" class:status-published={hw.status === 'published'} class:status-deleted={hw.status === 'deleted'}>
{hw.status === 'published' ? 'Publié' : 'Supprimé'}
</span>
<div class="homework-badges">
{#if hw.hasRuleOverride}
<span class="badge-rule-override" title="Créé malgré un avertissement de règle">&#9888;</span>
{/if}
<span class="homework-status" class:status-published={hw.status === 'published'} class:status-deleted={hw.status === 'deleted'}>
{hw.status === 'published' ? 'Publié' : 'Supprimé'}
</span>
</div>
</div>
<div class="homework-meta">
@@ -706,8 +779,14 @@
<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={minDueDate} />
<small class="form-hint">La date doit être au minimum demain, hors jours fériés et vacances</small>
<input type="date" id="hw-due-date" bind:value={newDueDate} required min={ruleConformMinDate || minDueDate} />
{#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>
{:else}
<small class="form-hint">La date doit être au minimum demain, hors jours fériés et vacances</small>
{/if}
</div>
<div class="modal-actions">
@@ -950,6 +1029,55 @@
</div>
{/if}
<!-- Rule Warning Modal -->
{#if showRuleWarningModal && ruleWarnings.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" role="presentation">
<div
class="modal modal-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="rule-warning-title"
aria-describedby="rule-warning-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') handleModifyDate(); }}
>
<header class="modal-header modal-header-warning">
<h2 id="rule-warning-title">Avertissement</h2>
</header>
<div class="modal-body">
<p id="rule-warning-description">
Ce devoir ne respecte pas les règles configurées par votre établissement :
</p>
<ul class="rule-warning-list">
{#each ruleWarnings as warning}
<li class="rule-warning-item">
<span class="rule-warning-icon">&#9888;</span>
<span>{warning.message}</span>
</li>
{/each}
</ul>
<p class="rule-warning-notice">Votre choix sera enregistré.</p>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={handleModifyDate} disabled={isSubmitting}>
Modifier la date
</button>
<button type="button" class="btn-primary" onclick={handleContinueDespiteWarning} disabled={isSubmitting}>
{#if isSubmitting}
Création...
{:else}
Continuer malgré tout
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.homework-page {
padding: 1.5rem;
@@ -1476,4 +1604,63 @@
color: #6b7280;
padding: 2rem 0;
}
/* Rule conforming date hint */
.form-hint-rule {
color: #d97706;
font-weight: 500;
}
/* Rule override badge */
.homework-badges {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge-rule-override {
font-size: 0.75rem;
color: #d97706;
opacity: 0.7;
cursor: help;
}
/* Rule Warning Modal */
.modal-header-warning {
border-bottom: 3px solid #f59e0b;
}
.modal-header-warning h2 {
color: #d97706;
}
.rule-warning-list {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.rule-warning-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
color: #92400e;
}
.rule-warning-icon {
color: #d97706;
flex-shrink: 0;
}
.rule-warning-notice {
font-size: 0.875rem;
color: #6b7280;
font-style: italic;
margin-top: 1rem;
}
</style>