feat: Permettre aux enseignants de contourner les règles de devoirs avec justification
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

Akeneo permet de configurer des règles de devoirs en mode Hard qui bloquent
totalement la création. Or certains cas légitimes (sorties scolaires, événements
exceptionnels) nécessitent de passer outre ces règles. Sans mécanisme d'exception,
l'enseignant est bloqué et doit contacter manuellement la direction.

Cette implémentation ajoute un flux complet d'exception : l'enseignant justifie
sa demande (min 20 caractères), le devoir est créé immédiatement, et la direction
est notifiée par email. Le handler vérifie côté serveur que les règles sont
réellement bloquantes avant d'accepter l'exception, empêchant toute fabrication
de fausses exceptions via l'API. La direction dispose d'un rapport filtrable
par période, enseignant et type de règle.
This commit is contained in:
2026-03-19 21:58:56 +01:00
parent d34d31976f
commit 3446cbf04b
33 changed files with 3496 additions and 10 deletions

View File

@@ -0,0 +1,407 @@
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-exception-teacher@example.com';
const TEACHER_PASSWORD = 'Exception123';
const ADMIN_EMAIL = 'e2e-exception-admin@example.com';
const ADMIN_PASSWORD = 'ExceptionAdmin123';
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! };
}
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 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(/\//, { 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
}
}
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 Exception Request (Story 5.6)', () => {
test.beforeAll(async () => {
// Create teacher and admin users
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 --first-name=Jean --last-name=Exception 2>&1`,
{ encoding: 'utf-8' },
);
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 --first-name=Marie --last-name=Direction 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-EX-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-EX-Maths', 'E2EEXM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
);
} catch {
// May already exist
}
seedTeacherAssignments();
});
test.beforeEach(async () => {
try {
runSql(
`DELETE FROM homework_rule_exceptions WHERE tenant_id = '${TENANT_ID}'`,
);
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 {
// Tables may not exist
}
clearRules();
clearCache();
});
// ============================================================================
// AC1: Exception request form
// ============================================================================
test.describe('AC1: Exception request form', () => {
test('shows "Demander une exception" button in blocking modal', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir exception test', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
});
test('clicking exception button opens justification form', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir exception form', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
// Exception request dialog appears
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
await expect(exceptionDialog.getByText(/demander une exception/i)).toBeVisible();
await expect(exceptionDialog.locator('#exception-justification')).toBeVisible();
});
});
// ============================================================================
// AC2: Justification required (min 20 chars)
// ============================================================================
test.describe('AC2: Justification validation', () => {
test('submit button disabled when justification too short', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir justif short', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
// Type less than 20 characters
await exceptionDialog.locator('#exception-justification').fill('Court');
// Submit button should be disabled
await expect(exceptionDialog.getByRole('button', { name: /créer avec exception/i })).toBeDisabled();
});
test('homework created immediately after valid justification', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir exception créé', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
// Type valid justification (>= 20 chars)
await exceptionDialog
.locator('#exception-justification')
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
// Submit
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
// Homework appears in the list
await expect(page.getByText('Devoir exception créé')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC4: Exception badge
// ============================================================================
test.describe('AC4: Exception marking', () => {
test('homework with exception shows exception badge', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir avec badge', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
await exceptionDialog
.locator('#exception-justification')
.fill('Justification suffisamment longue pour être valide.');
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
// Wait for homework to appear
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
// Exception badge visible (⚠ Exception text or rule override badge)
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
await expect(card.locator('.badge-rule-exception, .badge-rule-override')).toBeVisible();
});
test('exception badge tooltip describes the exception for justification viewing', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir tooltip test', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
await exceptionDialog
.locator('#exception-justification')
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
await expect(page.getByText('Devoir tooltip test')).toBeVisible({ timeout: 10000 });
// Badge has descriptive title attribute for justification consultation
const card = page.locator('.homework-card', { hasText: 'Devoir tooltip test' });
const badge = card.locator('.badge-rule-exception');
await expect(badge).toBeVisible();
await expect(badge).toHaveAttribute('title', /exception/i);
await expect(badge).toContainText('Exception');
});
});
// ============================================================================
// AC5: Direction exceptions report
// ============================================================================
test.describe('AC5: Direction exceptions report', () => {
test('admin can view exceptions report page', async ({ page }) => {
// First create an exception as teacher
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir rapport test', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
await exceptionDialog
.locator('#exception-justification')
.fill('Justification pour le rapport de la direction.');
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
await expect(page.getByText('Devoir rapport test')).toBeVisible({ timeout: 10000 });
// Now login as admin and check the report
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/homework-exceptions`);
await expect(page.getByRole('heading', { name: /exceptions aux règles/i })).toBeVisible({ timeout: 15000 });
// Exception should be visible
await expect(page.getByText('Devoir rapport test')).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/justification pour le rapport/i)).toBeVisible();
});
});
// ============================================================================
// AC6: Soft mode - no justification needed
// ============================================================================
test.describe('AC6: Soft mode without justification', () => {
test('soft mode does not show exception request button', async ({ page }) => {
// Configure soft mode
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()`,
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir soft mode', 2);
// Warning modal appears (not blocking)
const warningModal = page.getByRole('alertdialog');
await expect(warningModal).toBeVisible({ timeout: 10000 });
await expect(warningModal.getByText(/avertissement/i)).toBeVisible();
// "Continuer malgré tout" visible (soft mode allows bypass)
await expect(warningModal.getByRole('button', { name: /continuer malgré tout/i })).toBeVisible();
// No exception request button
await expect(warningModal.getByRole('button', { name: /demander une exception/i })).not.toBeVisible();
});
});
});