feat: Permettre l'import d'élèves via fichier CSV ou XLSX
L'import manuel élève par élève est fastidieux pour les établissements qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes (upload → mapping → preview → confirmation) permet de traiter un fichier complet en une seule opération, avec détection automatique du format (Pronote, École Directe) et validation avant import. L'import est traité de manière asynchrone via Messenger pour ne pas bloquer l'interface, avec suivi de progression en temps réel et réutilisation des mappings entre imports successifs.
This commit is contained in:
493
frontend/e2e/student-import.spec.ts
Normal file
493
frontend/e2e/student-import.spec.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const ADMIN_EMAIL = 'e2e-import-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'ImportTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runCommand(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
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, academicYearId };
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
// Create CSV fixture file for tests
|
||||
function createCsvFixture(filename: string, content: string): string {
|
||||
const tmpDir = join(__dirname, 'fixtures');
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const filePath = join(tmpDir, filename);
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
test.describe('Student Import via CSV', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let classId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Clean up auto-created class from previous runs (FK: assignments first)
|
||||
try {
|
||||
runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`);
|
||||
runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Create a class for valid import rows
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
const suffix = Date.now().toString().slice(-8);
|
||||
classId = `00000100-e2e0-4000-8000-${suffix}0001`;
|
||||
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Import A', NULL, NULL, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// Class may already exist
|
||||
}
|
||||
});
|
||||
|
||||
test('displays the import wizard page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Verify stepper is visible with 4 steps
|
||||
await expect(page.locator('.stepper .step')).toHaveCount(4);
|
||||
|
||||
// Verify dropzone is visible
|
||||
await expect(page.locator('.dropzone')).toBeVisible();
|
||||
await expect(page.getByText(/glissez votre fichier/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows format help cards', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
await expect(page.getByText(/formats supportés/i)).toBeVisible();
|
||||
await expect(page.getByText('Pronote', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('EcoleDirecte', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/personnalisé/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('uploads a CSV file and shows mapping step', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\nMartin;Marie;E2E Import A\n';
|
||||
const csvPath = createCsvFixture('e2e-import-test.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Upload via file input
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
// Should transition to mapping step
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// File info should be visible
|
||||
await expect(page.getByText(/e2e-import-test\.csv/i)).toBeVisible();
|
||||
await expect(page.getByText(/2 lignes/i)).toBeVisible();
|
||||
|
||||
// Column names should appear in mapping
|
||||
await expect(page.locator('.column-name').filter({ hasText: /^Nom$/ })).toBeVisible();
|
||||
await expect(page.locator('.column-name').filter({ hasText: /^Prénom$/ })).toBeVisible();
|
||||
await expect(page.locator('.column-name').filter({ hasText: /^Classe$/ })).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('validates required fields in mapping', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n';
|
||||
const csvPath = createCsvFixture('e2e-import-required.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// The mapping should be auto-suggested, so the "Valider le mapping" button should be enabled
|
||||
const validateButton = page.getByRole('button', { name: /valider le mapping/i });
|
||||
await expect(validateButton).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('navigates back from mapping to upload', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n';
|
||||
const csvPath = createCsvFixture('e2e-import-back.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click back button
|
||||
await page.getByRole('button', { name: /retour/i }).click();
|
||||
|
||||
// Should be back on upload step
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Clean up
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('rejects non-CSV files', async ({ page }) => {
|
||||
const txtPath = createCsvFixture('e2e-import-bad.pdf', 'not a csv file');
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(txtPath);
|
||||
|
||||
// Should show error
|
||||
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Clean up
|
||||
try { unlinkSync(txtPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('shows preview step with valid/error counts', async ({ page }) => {
|
||||
const csvContent =
|
||||
'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n';
|
||||
const csvPath = createCsvFixture('e2e-import-preview.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
// Wait for mapping step
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Submit mapping
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Wait for preview step
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should show valid and error counts
|
||||
await expect(page.locator('.summary-card.valid')).toBeVisible();
|
||||
await expect(page.locator('.summary-card.error')).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('navigable from students page via import button', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /gestion des élèves/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click import link
|
||||
const importLink = page.getByRole('link', { name: /importer.*csv/i });
|
||||
await expect(importLink).toBeVisible();
|
||||
await importLink.click();
|
||||
|
||||
// Should navigate to import page
|
||||
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
});
|
||||
|
||||
test('[P0] completes full import flow with progress and report', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Classe\nTestImport;Alice;E2E Import A\nTestImport;Bob;E2E Import A\n';
|
||||
const csvPath = createCsvFixture('e2e-import-full-flow.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
// Step 1: Upload
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Step 2: Mapping
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Step 3: Preview
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
||||
|
||||
// Step 4: Confirmation — wait for completion (import may be too fast for progressbar to be visible)
|
||||
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// Verify report stats
|
||||
const stats = page.locator('.report-stats .stat');
|
||||
const importedStat = stats.filter({ hasText: /importés/ });
|
||||
await expect(importedStat.locator('.stat-value')).toHaveText('2');
|
||||
const errorStat = stats.filter({ hasText: /erreurs/ });
|
||||
await expect(errorStat.locator('.stat-value')).toHaveText('0');
|
||||
|
||||
// Verify action buttons
|
||||
await expect(page.getByRole('button', { name: /télécharger le rapport/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /voir les élèves/i })).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] imports only valid rows when errors exist', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Classe\nDurand;Sophie;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n';
|
||||
const csvPath = createCsvFixture('e2e-import-valid-only.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
// Upload
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Mapping
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Preview
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify error count
|
||||
await expect(page.locator('.summary-card.error .summary-number')).toHaveText('2');
|
||||
|
||||
// Verify error detail rows are visible
|
||||
await expect(page.locator('.error-detail').first()).toBeVisible();
|
||||
|
||||
// "Import valid only" radio should be selected by default
|
||||
const validOnlyRadio = page.locator('input[type="radio"][name="importMode"][value="true"]');
|
||||
await expect(validOnlyRadio).toBeChecked();
|
||||
|
||||
// Launch import (should only import 1 valid row)
|
||||
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
||||
|
||||
// Wait for completion
|
||||
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// Verify only 1 student imported
|
||||
const stats = page.locator('.report-stats .stat');
|
||||
const importedStat = stats.filter({ hasText: /importés/ });
|
||||
await expect(importedStat.locator('.stat-value')).toHaveText('1');
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] shows unknown classes and allows auto-creation', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Classe\nLemaire;Paul;E2E NewAutoClass\n';
|
||||
const csvPath = createCsvFixture('e2e-import-auto-class.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
// Upload
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Mapping
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Preview
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify unknown classes section
|
||||
await expect(page.locator('.unknown-classes')).toBeVisible();
|
||||
await expect(page.locator('.class-tag')).toContainText('E2E NewAutoClass');
|
||||
|
||||
// Check auto-create checkbox
|
||||
await page.locator('.unknown-classes input[type="checkbox"]').check();
|
||||
|
||||
// Select "import all rows" since unknown class makes row invalid (validCount=0)
|
||||
await page.locator('input[type="radio"][name="importMode"][value="false"]').check();
|
||||
|
||||
// Launch import
|
||||
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
||||
|
||||
// Wait for completion
|
||||
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// Verify student imported
|
||||
const stats = page.locator('.report-stats .stat');
|
||||
const importedStat = stats.filter({ hasText: /importés/ });
|
||||
await expect(importedStat.locator('.stat-value')).toHaveText('1');
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup: delete assignments then class (FK constraint)
|
||||
try {
|
||||
runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`);
|
||||
runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] detects Pronote format and pre-fills mapping', async ({ page }) => {
|
||||
// Pronote format needs 3+ matching columns: Élèves, Né(e) le, Sexe, Classe de rattachement
|
||||
const csvContent = 'Élèves;Né(e) le;Sexe;Classe de rattachement\nDUPONT Jean;15/03/2010;M;E2E Import A\n';
|
||||
const csvPath = createCsvFixture('e2e-import-pronote.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Wait for mapping step
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify format detection badge
|
||||
await expect(page.locator('.format-badge')).toBeVisible();
|
||||
await expect(page.locator('.format-badge')).toContainText('Pronote');
|
||||
|
||||
// Verify pre-filled mapping: Élèves → Nom complet (fullName)
|
||||
const elevesRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Élèves$/ }) });
|
||||
await expect(elevesRow.locator('select')).toHaveValue('fullName');
|
||||
|
||||
// Verify pre-filled mapping: Classe de rattachement → Classe (className)
|
||||
const classeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Classe de rattachement$/ }) });
|
||||
await expect(classeRow.locator('select')).toHaveValue('className');
|
||||
|
||||
// Verify pre-filled mapping: Né(e) le → Date de naissance (birthDate)
|
||||
const dateRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Né\(e\) le$/ }) });
|
||||
await expect(dateRow.locator('select')).toHaveValue('birthDate');
|
||||
|
||||
// Verify pre-filled mapping: Sexe → Genre (gender)
|
||||
const sexeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Sexe$/ }) });
|
||||
await expect(sexeRow.locator('select')).toHaveValue('gender');
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => {
|
||||
// Create CSV with 8 data rows (more than the 5-line preview limit)
|
||||
const csvContent = [
|
||||
'Nom;Prénom;Classe',
|
||||
'Alpha;Un;E2E Import A',
|
||||
'Bravo;Deux;E2E Import A',
|
||||
'Charlie;Trois;E2E Import A',
|
||||
'Delta;Quatre;E2E Import A',
|
||||
'Echo;Cinq;E2E Import A',
|
||||
'Foxtrot;Six;E2E Import A',
|
||||
'Golf;Sept;E2E Import A',
|
||||
'Hotel;Huit;E2E Import A'
|
||||
].join('\n') + '\n';
|
||||
const csvPath = createCsvFixture('e2e-import-preview-5.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Wait for mapping step
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify preview section exists
|
||||
await expect(page.locator('.preview-section')).toBeVisible();
|
||||
|
||||
// Verify heading shows 5 premières lignes
|
||||
await expect(page.locator('.preview-section h3')).toContainText('5 premières lignes');
|
||||
|
||||
// Verify exactly 5 rows in the preview table (not 8)
|
||||
await expect(page.locator('.preview-table tbody tr')).toHaveCount(5);
|
||||
|
||||
// Verify total row count in file info
|
||||
await expect(page.getByText(/8 lignes/i)).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P2] rejects files exceeding 10 MB limit', async ({ page }) => {
|
||||
// Create a CSV file that exceeds 10 MB
|
||||
const header = 'Nom;Prénom;Classe\n';
|
||||
const line = 'Dupont;Jean;E2E Import A\n';
|
||||
const targetSize = 10 * 1024 * 1024 + 100; // just over 10 MB
|
||||
const repeats = Math.ceil((targetSize - header.length) / line.length);
|
||||
const content = header + line.repeat(repeats);
|
||||
const csvPath = createCsvFixture('e2e-import-too-large.csv', content);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Should show error about file size
|
||||
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(/dépasse la taille maximale de 10 Mo/i)).toBeVisible();
|
||||
|
||||
// Should stay on upload step (not transition to mapping)
|
||||
await expect(page.locator('.dropzone')).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ export default tseslint.config(
|
||||
fetch: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
HTMLDivElement: 'readonly',
|
||||
HTMLSelectElement: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
URL: 'readonly',
|
||||
@@ -88,7 +89,10 @@ export default tseslint.config(
|
||||
AbortController: 'readonly',
|
||||
DOMException: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly'
|
||||
clearTimeout: 'readonly',
|
||||
DragEvent: 'readonly',
|
||||
File: 'readonly',
|
||||
Blob: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
|
||||
186
frontend/src/lib/features/import/api/studentImport.ts
Normal file
186
frontend/src/lib/features/import/api/studentImport.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface UploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
totalRows: number;
|
||||
columns: string[];
|
||||
detectedFormat: string;
|
||||
suggestedMapping: Record<string, string>;
|
||||
preview: PreviewRow[];
|
||||
}
|
||||
|
||||
export interface PreviewRow {
|
||||
line: number;
|
||||
data: Record<string, string>;
|
||||
valid: boolean;
|
||||
errors: RowError[];
|
||||
}
|
||||
|
||||
export interface RowError {
|
||||
column: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MappingResult {
|
||||
id: string;
|
||||
mapping: Record<string, string>;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
id: string;
|
||||
totalRows: number;
|
||||
validCount: number;
|
||||
errorCount: number;
|
||||
rows: PreviewRow[];
|
||||
unknownClasses: string[];
|
||||
}
|
||||
|
||||
export interface ConfirmResult {
|
||||
id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ImportStatus {
|
||||
id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
totalRows: number;
|
||||
importedCount: number;
|
||||
errorCount: number;
|
||||
progression: number;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ImportReport {
|
||||
id: string;
|
||||
status: string;
|
||||
totalRows: number;
|
||||
importedCount: number;
|
||||
errorCount: number;
|
||||
report: string[];
|
||||
errors: { line: number; errors: RowError[] }[];
|
||||
}
|
||||
|
||||
// === API Functions ===
|
||||
|
||||
/**
|
||||
* Upload un fichier CSV ou XLSX pour l'import d'élèves.
|
||||
*/
|
||||
export async function uploadFile(file: File): Promise<UploadResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/students/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors de l\'upload'
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le mapping des colonnes.
|
||||
*/
|
||||
export async function applyMapping(
|
||||
batchId: string,
|
||||
mapping: Record<string, string>,
|
||||
format: string
|
||||
): Promise<MappingResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/mapping`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mapping, format })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors du mapping'
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la preview avec validation.
|
||||
*/
|
||||
export async function fetchPreview(batchId: string): Promise<PreviewResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/preview`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la validation');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirme et lance l'import.
|
||||
*/
|
||||
export async function confirmImport(
|
||||
batchId: string,
|
||||
options: { createMissingClasses: boolean; importValidOnly: boolean }
|
||||
): Promise<ConfirmResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
data?.['hydra:description'] ??
|
||||
data?.message ??
|
||||
data?.detail ??
|
||||
'Erreur lors de la confirmation'
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le statut et la progression de l'import.
|
||||
*/
|
||||
export async function fetchImportStatus(batchId: string): Promise<ImportStatus> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/status`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération du statut');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le rapport détaillé de l'import.
|
||||
*/
|
||||
export async function fetchImportReport(batchId: string): Promise<ImportReport> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/report`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération du rapport');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
1726
frontend/src/routes/admin/import/students/+page.svelte
Normal file
1726
frontend/src/routes/admin/import/students/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -389,10 +389,15 @@
|
||||
<h1>Gestion des élèves</h1>
|
||||
<p class="subtitle">Créez et gérez les élèves de votre établissement</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick={openCreateModal}>
|
||||
<span class="btn-icon">+</span>
|
||||
Nouvel élève
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<a href="/admin/import/students" class="btn-secondary">
|
||||
Importer (CSV)
|
||||
</a>
|
||||
<button class="btn-primary" onclick={openCreateModal}>
|
||||
<span class="btn-icon">+</span>
|
||||
Nouvel élève
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
@@ -730,6 +735,12 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
|
||||
Reference in New Issue
Block a user