diff --git a/README.md b/README.md
index 9143893..bc2e22d 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,13 @@ La configuration associee est deja prete dans :
- `deploy/vps/generate-env.sh`
- `deploy/vps/Caddyfile`
- `deploy/vps/generate-jwt.sh`
+- `deploy/vps/generate-demo-data.sh`
+
+Pour remplir rapidement une instance de demo une fois le VPS lance :
+
+```bash
+./deploy/vps/generate-demo-data.sh
+```
### Commandes utiles
diff --git a/backend/src/Administration/Infrastructure/Console/GenerateDemoDataCommand.php b/backend/src/Administration/Infrastructure/Console/GenerateDemoDataCommand.php
new file mode 100644
index 0000000..5ebb651
--- /dev/null
+++ b/backend/src/Administration/Infrastructure/Console/GenerateDemoDataCommand.php
@@ -0,0 +1,277 @@
+addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Sous-domaine du tenant cible')
+ ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Mot de passe partagé pour tous les comptes de démo', 'DemoPassword123!')
+ ->addOption('school', null, InputOption::VALUE_OPTIONAL, 'Nom de l’établissement affiché sur les comptes générés')
+ ->addOption('zone', null, InputOption::VALUE_OPTIONAL, 'Zone scolaire (A, B ou C)', 'B')
+ ->addOption('period-type', null, InputOption::VALUE_OPTIONAL, 'Découpage des périodes (trimester ou semester)', PeriodType::TRIMESTER->value)
+ ->addOption('internal-run', null, InputOption::VALUE_NONE, 'Option interne pour exécuter la génération dans la base tenant');
+ }
+
+ #[Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ /** @var string|null $tenantOption */
+ $tenantOption = $input->getOption('tenant');
+ /** @var string $password */
+ $password = $input->getOption('password');
+ /** @var string|null $schoolOption */
+ $schoolOption = $input->getOption('school');
+ /** @var string $zoneOption */
+ $zoneOption = $input->getOption('zone');
+ /** @var string $periodTypeOption */
+ $periodTypeOption = $input->getOption('period-type');
+ $internalRun = $input->getOption('internal-run');
+
+ $tenantConfig = $this->resolveTenantConfig($tenantOption, $io);
+ if ($tenantConfig === null) {
+ return Command::FAILURE;
+ }
+
+ $zone = SchoolZone::tryFrom(strtoupper(trim($zoneOption)));
+ if ($zone === null) {
+ $io->error('Zone invalide. Valeurs attendues: A, B, C.');
+
+ return Command::FAILURE;
+ }
+
+ $periodType = PeriodType::tryFrom(strtolower(trim($periodTypeOption)));
+ if ($periodType === null) {
+ $io->error('Type de période invalide. Valeurs attendues: trimester, semester.');
+
+ return Command::FAILURE;
+ }
+
+ $schoolName = trim((string) $schoolOption);
+ if ($schoolName === '') {
+ $schoolName = $this->demoDataGenerator->defaultSchoolNameForSubdomain($tenantConfig->subdomain);
+ }
+
+ if ($internalRun) {
+ return $this->runInTenantProcess($tenantConfig, $password, $schoolName, $zone, $periodType, $io);
+ }
+
+ return $this->relaunchAgainstTenantDatabase($tenantConfig, $password, $schoolName, $zone, $periodType, $io);
+ }
+
+ private function relaunchAgainstTenantDatabase(
+ TenantConfig $tenantConfig,
+ string $password,
+ string $schoolName,
+ SchoolZone $zone,
+ PeriodType $periodType,
+ SymfonyStyle $io,
+ ): int {
+ $io->section(sprintf(
+ 'Génération du jeu de démo pour le tenant "%s" sur sa base dédiée',
+ $tenantConfig->subdomain,
+ ));
+
+ $process = new Process(
+ command: [
+ 'php',
+ 'bin/console',
+ 'app:dev:generate-demo-data',
+ '--tenant=' . $tenantConfig->subdomain,
+ '--password=' . $password,
+ '--school=' . $schoolName,
+ '--zone=' . $zone->value,
+ '--period-type=' . $periodType->value,
+ '--internal-run',
+ ],
+ cwd: $this->projectDir,
+ env: [
+ ...getenv(),
+ 'DATABASE_URL' => $tenantConfig->databaseUrl,
+ ],
+ timeout: 300,
+ );
+
+ $process->run(static function (string $type, string $buffer) use ($io): void {
+ $io->write($buffer);
+ });
+
+ if ($process->isSuccessful()) {
+ return Command::SUCCESS;
+ }
+
+ $io->error(sprintf(
+ 'La génération a échoué pour le tenant "%s".',
+ $tenantConfig->subdomain,
+ ));
+
+ return Command::FAILURE;
+ }
+
+ private function runInTenantProcess(
+ TenantConfig $tenantConfig,
+ string $password,
+ string $schoolName,
+ SchoolZone $zone,
+ PeriodType $periodType,
+ SymfonyStyle $io,
+ ): int {
+ try {
+ $result = $this->demoDataGenerator->generate(
+ tenantConfig: $tenantConfig,
+ password: $password,
+ schoolName: $schoolName,
+ zone: $zone,
+ periodType: $periodType,
+ );
+ } catch (Throwable $e) {
+ $io->error([
+ sprintf('Impossible de générer les données de démo pour "%s".', $tenantConfig->subdomain),
+ $e->getMessage(),
+ ]);
+
+ return Command::FAILURE;
+ }
+
+ $this->renderResult($result, $io);
+
+ return Command::SUCCESS;
+ }
+
+ private function renderResult(DemoDataGenerationResult $result, SymfonyStyle $io): void
+ {
+ $io->success(sprintf(
+ 'Jeu de démo prêt pour le tenant "%s" (%s).',
+ $result->tenantSubdomain,
+ $result->academicYearLabel,
+ ));
+
+ $io->table(
+ ['Élément', 'Résultat'],
+ [
+ ['Établissement', $result->schoolName],
+ ['Utilisateurs créés', (string) $result->createdUsers],
+ ['Matières créées', (string) $result->createdSubjects],
+ ['Classes créées', (string) $result->createdClasses],
+ ['Affectations élèves', (string) $result->createdClassAssignments],
+ ['Affectations enseignants', (string) $result->createdTeacherAssignments],
+ ['Liens parent-enfant', (string) $result->createdGuardianLinks],
+ ['Créneaux d’emploi du temps', (string) $result->createdScheduleSlots],
+ ['Périodes créées', $result->periodConfigurationCreated ? 'oui' : 'non'],
+ ['Calendrier créé', $result->schoolCalendarCreated ? 'oui' : 'non'],
+ ['Mode de notation créé', $result->gradingConfigurationCreated ? 'oui' : 'non'],
+ ],
+ );
+
+ $io->writeln(sprintf('Mot de passe commun: %s', $result->sharedPassword));
+
+ $io->table(
+ ['Rôle', 'Nom', 'Email'],
+ array_map(
+ static fn (array $account): array => [
+ $account['role'],
+ $account['name'],
+ $account['email'],
+ ],
+ $result->accounts,
+ ),
+ );
+
+ if ($result->warnings !== []) {
+ $io->warning($result->warnings);
+ }
+ }
+
+ private function resolveTenantConfig(?string $tenantOption, SymfonyStyle $io): ?TenantConfig
+ {
+ $tenant = trim((string) $tenantOption);
+
+ if ($tenant !== '') {
+ try {
+ return $this->tenantRegistry->getBySubdomain($tenant);
+ } catch (TenantNotFoundException) {
+ $io->error(sprintf(
+ 'Tenant "%s" introuvable. Disponibles: %s',
+ $tenant,
+ implode(', ', $this->availableSubdomains()),
+ ));
+
+ return null;
+ }
+ }
+
+ $configs = $this->tenantRegistry->getAllConfigs();
+ if (count($configs) === 1) {
+ return $configs[0];
+ }
+
+ $io->error(sprintf(
+ 'Plusieurs tenants sont configurés. Précise --tenant parmi: %s',
+ implode(', ', $this->availableSubdomains()),
+ ));
+
+ return null;
+ }
+
+ /**
+ * @return list
+ */
+ private function availableSubdomains(): array
+ {
+ return array_values(array_map(
+ static fn (TenantConfig $config): string => $config->subdomain,
+ $this->tenantRegistry->getAllConfigs(),
+ ));
+ }
+}
diff --git a/backend/src/Administration/Infrastructure/Service/DemoDataGenerationResult.php b/backend/src/Administration/Infrastructure/Service/DemoDataGenerationResult.php
new file mode 100644
index 0000000..8e0cb84
--- /dev/null
+++ b/backend/src/Administration/Infrastructure/Service/DemoDataGenerationResult.php
@@ -0,0 +1,47 @@
+ */
+ public array $accounts = [];
+
+ /** @var list */
+ public array $warnings = [];
+
+ public function __construct(
+ public readonly string $tenantSubdomain,
+ public readonly string $schoolName,
+ public readonly string $academicYearLabel,
+ public readonly string $sharedPassword,
+ ) {
+ }
+
+ public function addAccount(string $role, string $name, string $email): void
+ {
+ $this->accounts[] = [
+ 'role' => $role,
+ 'name' => $name,
+ 'email' => $email,
+ ];
+ }
+
+ public function addWarning(string $warning): void
+ {
+ $this->warnings[] = $warning;
+ }
+}
diff --git a/backend/src/Administration/Infrastructure/Service/DemoDataGenerator.php b/backend/src/Administration/Infrastructure/Service/DemoDataGenerator.php
new file mode 100644
index 0000000..225f02b
--- /dev/null
+++ b/backend/src/Administration/Infrastructure/Service/DemoDataGenerator.php
@@ -0,0 +1,957 @@
+tenantContext->setCurrentTenant($tenantConfig);
+
+ try {
+ $tenantId = $tenantConfig->tenantId;
+ $academicYearId = $this->resolveAcademicYearId();
+ $academicYearStartYear = $this->resolveAcademicYearStartYear();
+ $academicYearLabel = sprintf('%d-%d', $academicYearStartYear, $academicYearStartYear + 1);
+ $schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId));
+ $now = $this->clock->now();
+ $hashedPassword = $this->passwordHasher->hash($password);
+
+ $result = new DemoDataGenerationResult(
+ tenantSubdomain: $tenantConfig->subdomain,
+ schoolName: $schoolName,
+ academicYearLabel: $academicYearLabel,
+ sharedPassword: $password,
+ );
+
+ $this->ensurePeriodConfiguration($tenantId, $academicYearId, $academicYearStartYear, $periodType, $result);
+ $this->ensureSchoolCalendar($tenantId, $academicYearId, $academicYearLabel, $zone, $now, $result);
+ $this->ensureGradingConfiguration($tenantId, $schoolId, $academicYearId, $now, $result);
+
+ $users = $this->seedUsers($tenantConfig, $schoolName, $hashedPassword, $now, $result);
+ $subjects = $this->seedSubjects($tenantId, $schoolId, $now, $result);
+ $classes = $this->seedClasses($tenantId, $schoolId, $academicYearId, $now, $result);
+
+ $this->seedClassAssignments($tenantId, $academicYearId, $users, $classes, $now, $result);
+ $this->seedTeacherAssignments($tenantId, $academicYearId, $users, $classes, $subjects, $now, $result);
+ $this->seedStudentGuardianLinks($tenantId, $users, $now, $result);
+ $this->seedScheduleSlots($tenantId, $classes, $subjects, $users, $academicYearStartYear, $now, $result);
+
+ return $result;
+ } finally {
+ $this->tenantContext->clear();
+ }
+ }
+
+ private function ensurePeriodConfiguration(
+ TenantId $tenantId,
+ AcademicYearId $academicYearId,
+ int $academicYearStartYear,
+ PeriodType $periodType,
+ DemoDataGenerationResult $result,
+ ): void {
+ if ($this->periodConfigurationRepository->findByAcademicYear($tenantId, $academicYearId) !== null) {
+ return;
+ }
+
+ $configuration = DefaultPeriods::forType($periodType, $academicYearStartYear);
+ $this->periodConfigurationRepository->save($tenantId, $academicYearId, $configuration);
+ $result->periodConfigurationCreated = true;
+ }
+
+ private function ensureSchoolCalendar(
+ TenantId $tenantId,
+ AcademicYearId $academicYearId,
+ string $academicYearLabel,
+ SchoolZone $zone,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): void {
+ if ($this->schoolCalendarRepository->findByTenantAndYear($tenantId, $academicYearId) !== null) {
+ return;
+ }
+
+ $calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
+ $calendar->configurer(
+ $zone,
+ $this->officialCalendarProvider->toutesEntreesOfficielles($zone, $academicYearLabel),
+ $now,
+ );
+
+ $this->schoolCalendarRepository->save($calendar);
+ $result->schoolCalendarCreated = true;
+ }
+
+ private function ensureGradingConfiguration(
+ TenantId $tenantId,
+ SchoolId $schoolId,
+ AcademicYearId $academicYearId,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): void {
+ if ($this->gradingConfigurationRepository->findBySchoolAndYear($tenantId, $schoolId, $academicYearId) !== null) {
+ return;
+ }
+
+ $configuration = SchoolGradingConfiguration::configurer(
+ tenantId: $tenantId,
+ schoolId: $schoolId,
+ academicYearId: $academicYearId,
+ mode: SchoolGradingConfiguration::DEFAULT_MODE,
+ hasExistingGrades: false,
+ configuredAt: $now,
+ );
+
+ $this->gradingConfigurationRepository->save($configuration);
+ $result->gradingConfigurationCreated = true;
+ }
+
+ /**
+ * @return array
+ */
+ private function seedUsers(
+ TenantConfig $tenantConfig,
+ string $schoolName,
+ string $hashedPassword,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): array {
+ $users = [];
+
+ foreach ($this->staffBlueprints() as $blueprint) {
+ $users[$blueprint['key']] = $this->ensureActiveUser(
+ tenantConfig: $tenantConfig,
+ schoolName: $schoolName,
+ hashedPassword: $hashedPassword,
+ role: $blueprint['role'],
+ emailSlug: $blueprint['emailSlug'],
+ firstName: $blueprint['firstName'],
+ lastName: $blueprint['lastName'],
+ now: $now,
+ result: $result,
+ );
+ }
+
+ foreach ($this->teacherBlueprints() as $blueprint) {
+ $users[$blueprint['key']] = $this->ensureActiveUser(
+ tenantConfig: $tenantConfig,
+ schoolName: $schoolName,
+ hashedPassword: $hashedPassword,
+ role: Role::PROF,
+ emailSlug: $blueprint['emailSlug'],
+ firstName: $blueprint['firstName'],
+ lastName: $blueprint['lastName'],
+ now: $now,
+ result: $result,
+ );
+ }
+
+ foreach ($this->parentBlueprints() as $blueprint) {
+ $users[$blueprint['key']] = $this->ensureActiveUser(
+ tenantConfig: $tenantConfig,
+ schoolName: $schoolName,
+ hashedPassword: $hashedPassword,
+ role: Role::PARENT,
+ emailSlug: $blueprint['emailSlug'],
+ firstName: $blueprint['firstName'],
+ lastName: $blueprint['lastName'],
+ now: $now,
+ result: $result,
+ );
+ }
+
+ foreach ($this->studentBlueprints() as $blueprint) {
+ $users[$blueprint['key']] = $this->ensureActiveUser(
+ tenantConfig: $tenantConfig,
+ schoolName: $schoolName,
+ hashedPassword: $hashedPassword,
+ role: Role::ELEVE,
+ emailSlug: $blueprint['emailSlug'],
+ firstName: $blueprint['firstName'],
+ lastName: $blueprint['lastName'],
+ now: $now,
+ result: $result,
+ dateOfBirth: $blueprint['dateOfBirth'],
+ studentNumber: $blueprint['studentNumber'],
+ );
+ }
+
+ return $users;
+ }
+
+ /**
+ * @return array
+ */
+ private function seedSubjects(
+ TenantId $tenantId,
+ SchoolId $schoolId,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): array {
+ $subjects = [];
+
+ foreach ($this->subjectBlueprints() as $blueprint) {
+ $code = new SubjectCode($blueprint['code']);
+ $subject = $this->subjectRepository->findByCode($code, $tenantId, $schoolId);
+
+ if ($subject === null) {
+ $subject = Subject::creer(
+ tenantId: $tenantId,
+ schoolId: $schoolId,
+ name: new SubjectName($blueprint['name']),
+ code: $code,
+ color: new SubjectColor($blueprint['color']),
+ createdAt: $now,
+ );
+ $subject->decrire($blueprint['description'], $now);
+ $this->subjectRepository->save($subject);
+ ++$result->createdSubjects;
+ }
+
+ $subjects[$blueprint['code']] = $subject;
+ }
+
+ return $subjects;
+ }
+
+ /**
+ * @return array
+ */
+ private function seedClasses(
+ TenantId $tenantId,
+ SchoolId $schoolId,
+ AcademicYearId $academicYearId,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): array {
+ $classes = [];
+
+ foreach ($this->classBlueprints() as $blueprint) {
+ $name = new ClassName($blueprint['name']);
+ $class = $this->classRepository->findByName($name, $tenantId, $academicYearId);
+
+ if ($class === null) {
+ $class = SchoolClass::creer(
+ tenantId: $tenantId,
+ schoolId: $schoolId,
+ academicYearId: $academicYearId,
+ name: $name,
+ level: $blueprint['level'],
+ capacity: $blueprint['capacity'],
+ createdAt: $now,
+ );
+ $class->decrire($blueprint['description'], $now);
+ $this->classRepository->save($class);
+ ++$result->createdClasses;
+ }
+
+ $classes[$blueprint['name']] = $class;
+ }
+
+ return $classes;
+ }
+
+ /**
+ * @param array $users
+ * @param array $classes
+ */
+ private function seedClassAssignments(
+ TenantId $tenantId,
+ AcademicYearId $academicYearId,
+ array $users,
+ array $classes,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): void {
+ foreach ($this->studentBlueprints() as $blueprint) {
+ $student = $users[$blueprint['key']];
+ $class = $classes[$blueprint['className']];
+ $assignment = $this->classAssignmentRepository->findByStudent($student->id, $academicYearId, $tenantId);
+
+ if ($assignment === null) {
+ $assignment = ClassAssignment::affecter(
+ tenantId: $tenantId,
+ studentId: $student->id,
+ classId: $class->id,
+ academicYearId: $academicYearId,
+ assignedAt: $now,
+ );
+ $this->classAssignmentRepository->save($assignment);
+ ++$result->createdClassAssignments;
+
+ continue;
+ }
+
+ if (!$assignment->classId->equals($class->id)) {
+ $assignment->changerClasse($class->id, $now);
+ $this->classAssignmentRepository->save($assignment);
+ }
+ }
+ }
+
+ /**
+ * @param array $users
+ * @param array $classes
+ * @param array $subjects
+ */
+ private function seedTeacherAssignments(
+ TenantId $tenantId,
+ AcademicYearId $academicYearId,
+ array $users,
+ array $classes,
+ array $subjects,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): void {
+ $teacherKeysBySubject = $this->teacherKeysBySubject();
+
+ foreach ($classes as $className => $class) {
+ foreach ($subjects as $subjectCode => $subject) {
+ $teacher = $users[$teacherKeysBySubject[$subjectCode]];
+ $assignment = $this->teacherAssignmentRepository->findByTeacherClassSubject(
+ teacherId: $teacher->id,
+ classId: $class->id,
+ subjectId: $subject->id,
+ academicYearId: $academicYearId,
+ tenantId: $tenantId,
+ );
+
+ if ($assignment !== null) {
+ continue;
+ }
+
+ $removedAssignment = $this->teacherAssignmentRepository->findRemovedByTeacherClassSubject(
+ teacherId: $teacher->id,
+ classId: $class->id,
+ subjectId: $subject->id,
+ academicYearId: $academicYearId,
+ tenantId: $tenantId,
+ );
+
+ if ($removedAssignment !== null) {
+ $removedAssignment->reactiver($now);
+ $this->teacherAssignmentRepository->save($removedAssignment);
+
+ continue;
+ }
+
+ $createdAssignment = TeacherAssignment::creer(
+ tenantId: $tenantId,
+ teacherId: $teacher->id,
+ classId: $class->id,
+ subjectId: $subject->id,
+ academicYearId: $academicYearId,
+ createdAt: $now,
+ );
+ $this->teacherAssignmentRepository->save($createdAssignment);
+ ++$result->createdTeacherAssignments;
+ }
+ }
+ }
+
+ /**
+ * @param array $users
+ */
+ private function seedStudentGuardianLinks(
+ TenantId $tenantId,
+ array $users,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): void {
+ $createdBy = $users['admin']->id;
+
+ foreach ($this->guardianLinkBlueprints() as $blueprint) {
+ $student = $users[$blueprint['studentKey']];
+ $guardian = $users[$blueprint['guardianKey']];
+ $existingLink = $this->studentGuardianRepository->findByStudentAndGuardian(
+ studentId: $student->id,
+ guardianId: $guardian->id,
+ tenantId: $tenantId,
+ );
+
+ if ($existingLink !== null) {
+ continue;
+ }
+
+ $link = StudentGuardian::lier(
+ studentId: $student->id,
+ guardianId: $guardian->id,
+ relationshipType: $blueprint['relationshipType'],
+ tenantId: $tenantId,
+ createdAt: $now,
+ createdBy: $createdBy,
+ );
+ $this->studentGuardianRepository->save($link);
+ ++$result->createdGuardianLinks;
+ }
+ }
+
+ /**
+ * @param array $classes
+ * @param array $subjects
+ * @param array $users
+ */
+ private function seedScheduleSlots(
+ TenantId $tenantId,
+ array $classes,
+ array $subjects,
+ array $users,
+ int $academicYearStartYear,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ): void {
+ $teacherKeysBySubject = $this->teacherKeysBySubject();
+ $recurrenceStart = new DateTimeImmutable(sprintf('%d-09-01', $academicYearStartYear));
+ $recurrenceEnd = new DateTimeImmutable(sprintf('%d-06-30', $academicYearStartYear + 1));
+
+ foreach ($this->scheduleBlueprints() as $blueprint) {
+ foreach ($blueprint['assignments'] as $assignmentBlueprint) {
+ $class = $classes[$assignmentBlueprint['className']];
+ $subject = $subjects[$assignmentBlueprint['subjectCode']];
+ $teacher = $users[$teacherKeysBySubject[$assignmentBlueprint['subjectCode']]];
+
+ if ($this->scheduleSlotExists(
+ tenantId: $tenantId,
+ classId: $class->id,
+ subjectId: $subject->id,
+ teacherId: $teacher->id,
+ dayOfWeek: $blueprint['dayOfWeek'],
+ startTime: $blueprint['startTime'],
+ endTime: $blueprint['endTime'],
+ room: $assignmentBlueprint['room'],
+ recurrenceStart: $recurrenceStart,
+ recurrenceEnd: $recurrenceEnd,
+ )) {
+ continue;
+ }
+
+ $slot = ScheduleSlot::creer(
+ tenantId: $tenantId,
+ classId: $class->id,
+ subjectId: $subject->id,
+ teacherId: $teacher->id,
+ dayOfWeek: $blueprint['dayOfWeek'],
+ timeSlot: new TimeSlot($blueprint['startTime'], $blueprint['endTime']),
+ room: $assignmentBlueprint['room'],
+ isRecurring: true,
+ now: $now,
+ recurrenceStart: $recurrenceStart,
+ recurrenceEnd: $recurrenceEnd,
+ );
+ $this->scheduleSlotRepository->save($slot);
+ ++$result->createdScheduleSlots;
+ }
+ }
+ }
+
+ private function scheduleSlotExists(
+ TenantId $tenantId,
+ \App\Administration\Domain\Model\SchoolClass\ClassId $classId,
+ \App\Administration\Domain\Model\Subject\SubjectId $subjectId,
+ UserId $teacherId,
+ DayOfWeek $dayOfWeek,
+ string $startTime,
+ string $endTime,
+ ?string $room,
+ DateTimeImmutable $recurrenceStart,
+ DateTimeImmutable $recurrenceEnd,
+ ): bool {
+ foreach ($this->scheduleSlotRepository->findRecurringByClass($classId, $tenantId) as $existingSlot) {
+ if (!$existingSlot->subjectId->equals($subjectId)) {
+ continue;
+ }
+
+ if (!$existingSlot->teacherId->equals($teacherId)) {
+ continue;
+ }
+
+ if ($existingSlot->dayOfWeek !== $dayOfWeek) {
+ continue;
+ }
+
+ if ($existingSlot->timeSlot->startTime !== $startTime || $existingSlot->timeSlot->endTime !== $endTime) {
+ continue;
+ }
+
+ if ($existingSlot->room !== $room) {
+ continue;
+ }
+
+ if ($existingSlot->recurrenceStart?->format('Y-m-d') !== $recurrenceStart->format('Y-m-d')) {
+ continue;
+ }
+
+ if ($existingSlot->recurrenceEnd?->format('Y-m-d') !== $recurrenceEnd->format('Y-m-d')) {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function ensureActiveUser(
+ TenantConfig $tenantConfig,
+ string $schoolName,
+ string $hashedPassword,
+ Role $role,
+ string $emailSlug,
+ string $firstName,
+ string $lastName,
+ DateTimeImmutable $now,
+ DemoDataGenerationResult $result,
+ ?DateTimeImmutable $dateOfBirth = null,
+ ?string $studentNumber = null,
+ ): User {
+ $email = new Email($this->emailFor($emailSlug, $tenantConfig->subdomain));
+ $user = $this->userRepository->findByEmail($email, $tenantConfig->tenantId);
+
+ if ($user === null) {
+ $user = User::reconstitute(
+ id: UserId::generate(),
+ email: $email,
+ roles: [$role],
+ tenantId: $tenantConfig->tenantId,
+ schoolName: $schoolName,
+ statut: StatutCompte::ACTIF,
+ dateNaissance: $dateOfBirth,
+ createdAt: $now,
+ hashedPassword: $hashedPassword,
+ activatedAt: $now,
+ consentementParental: null,
+ firstName: $firstName,
+ lastName: $lastName,
+ studentNumber: $studentNumber,
+ );
+ $this->userRepository->save($user);
+ ++$result->createdUsers;
+ } else {
+ $userWasChanged = false;
+
+ if ($user->firstName !== $firstName || $user->lastName !== $lastName) {
+ $user->mettreAJourInfos($firstName, $lastName);
+ $userWasChanged = true;
+ }
+
+ if (!$user->aLeRole($role)) {
+ $user->attribuerRole($role, $now);
+ $userWasChanged = true;
+ }
+
+ if ($user->statut === StatutCompte::SUSPENDU) {
+ $user->debloquer($now);
+ $userWasChanged = true;
+ }
+
+ if ($user->statut === StatutCompte::ACTIF) {
+ $user->changerMotDePasse($hashedPassword, $now);
+ $userWasChanged = true;
+ } else {
+ $result->addWarning(sprintf(
+ 'Le compte "%s" existe déjà avec le statut "%s". Le mot de passe commun n\'a pas pu être réinitialisé.',
+ (string) $email,
+ $user->statut->value,
+ ));
+ }
+
+ if ($userWasChanged) {
+ $this->userRepository->save($user);
+ }
+ }
+
+ $result->addAccount(
+ role: $role->label(),
+ name: trim($firstName . ' ' . $lastName),
+ email: (string) $email,
+ );
+
+ return $user;
+ }
+
+ private function resolveAcademicYearId(): AcademicYearId
+ {
+ $resolved = $this->currentAcademicYearResolver->resolve('current');
+ if ($resolved === null) {
+ throw new RuntimeException('Impossible de résoudre l\'année scolaire courante.');
+ }
+
+ return AcademicYearId::fromString($resolved);
+ }
+
+ private function resolveAcademicYearStartYear(): int
+ {
+ $startYear = $this->currentAcademicYearResolver->resolveStartYear('current');
+ if ($startYear === null) {
+ throw new RuntimeException('Impossible de résoudre le millésime de l\'année scolaire courante.');
+ }
+
+ return $startYear;
+ }
+
+ private function emailFor(string $emailSlug, string $subdomain): string
+ {
+ return sprintf('%s.%s@classeo.test', strtolower($emailSlug), strtolower($subdomain));
+ }
+
+ /**
+ * @return list
+ */
+ private function staffBlueprints(): array
+ {
+ return [
+ [
+ 'key' => 'admin',
+ 'role' => Role::ADMIN,
+ 'emailSlug' => 'admin.direction',
+ 'firstName' => 'Camille',
+ 'lastName' => 'Martin',
+ ],
+ [
+ 'key' => 'vie-scolaire',
+ 'role' => Role::VIE_SCOLAIRE,
+ 'emailSlug' => 'vie.scolaire',
+ 'firstName' => 'Nora',
+ 'lastName' => 'Petit',
+ ],
+ [
+ 'key' => 'secretariat',
+ 'role' => Role::SECRETARIAT,
+ 'emailSlug' => 'secretariat',
+ 'firstName' => 'Lucie',
+ 'lastName' => 'Bernard',
+ ],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function teacherBlueprints(): array
+ {
+ return [
+ [
+ 'key' => 'teacher-math',
+ 'subjectCode' => 'MATH',
+ 'emailSlug' => 'prof.amina.benali',
+ 'firstName' => 'Amina',
+ 'lastName' => 'Benali',
+ ],
+ [
+ 'key' => 'teacher-fr',
+ 'subjectCode' => 'FR',
+ 'emailSlug' => 'prof.julie.caron',
+ 'firstName' => 'Julie',
+ 'lastName' => 'Caron',
+ ],
+ [
+ 'key' => 'teacher-hg',
+ 'subjectCode' => 'HG',
+ 'emailSlug' => 'prof.marc.garcia',
+ 'firstName' => 'Marc',
+ 'lastName' => 'Garcia',
+ ],
+ [
+ 'key' => 'teacher-svt',
+ 'subjectCode' => 'SVT',
+ 'emailSlug' => 'prof.sophie.lambert',
+ 'firstName' => 'Sophie',
+ 'lastName' => 'Lambert',
+ ],
+ [
+ 'key' => 'teacher-ang',
+ 'subjectCode' => 'ANG',
+ 'emailSlug' => 'prof.david.nguyen',
+ 'firstName' => 'David',
+ 'lastName' => 'Nguyen',
+ ],
+ [
+ 'key' => 'teacher-eps',
+ 'subjectCode' => 'EPS',
+ 'emailSlug' => 'prof.clara.petit',
+ 'firstName' => 'Clara',
+ 'lastName' => 'Petit',
+ ],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function parentBlueprints(): array
+ {
+ return [
+ ['key' => 'parent-nadia-martin', 'emailSlug' => 'parent.nadia.martin', 'firstName' => 'Nadia', 'lastName' => 'Martin'],
+ ['key' => 'parent-karim-martin', 'emailSlug' => 'parent.karim.martin', 'firstName' => 'Karim', 'lastName' => 'Martin'],
+ ['key' => 'parent-ines-lopez', 'emailSlug' => 'parent.ines.lopez', 'firstName' => 'Ines', 'lastName' => 'Lopez'],
+ ['key' => 'parent-raul-lopez', 'emailSlug' => 'parent.raul.lopez', 'firstName' => 'Raul', 'lastName' => 'Lopez'],
+ ['key' => 'parent-claire-bernard', 'emailSlug' => 'parent.claire.bernard', 'firstName' => 'Claire', 'lastName' => 'Bernard'],
+ ['key' => 'parent-mehdi-petit', 'emailSlug' => 'parent.mehdi.petit', 'firstName' => 'Mehdi', 'lastName' => 'Petit'],
+ ['key' => 'parent-laura-moreau', 'emailSlug' => 'parent.laura.moreau', 'firstName' => 'Laura', 'lastName' => 'Moreau'],
+ ['key' => 'parent-olivier-moreau', 'emailSlug' => 'parent.olivier.moreau', 'firstName' => 'Olivier', 'lastName' => 'Moreau'],
+ ['key' => 'parent-celine-dupont', 'emailSlug' => 'parent.celine.dupont', 'firstName' => 'Celine', 'lastName' => 'Dupont'],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function studentBlueprints(): array
+ {
+ return [
+ ['key' => 'student-lina-martin', 'emailSlug' => 'eleve.lina.martin', 'firstName' => 'Lina', 'lastName' => 'Martin', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-02-14'), 'studentNumber' => 'DEMO-001'],
+ ['key' => 'student-chloe-lopez', 'emailSlug' => 'eleve.chloe.lopez', 'firstName' => 'Chloe', 'lastName' => 'Lopez', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-05-02'), 'studentNumber' => 'DEMO-002'],
+ ['key' => 'student-hugo-lopez', 'emailSlug' => 'eleve.hugo.lopez', 'firstName' => 'Hugo', 'lastName' => 'Lopez', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-07-22'), 'studentNumber' => 'DEMO-003'],
+ ['key' => 'student-zoe-moreau', 'emailSlug' => 'eleve.zoe.moreau', 'firstName' => 'Zoe', 'lastName' => 'Moreau', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-11-09'), 'studentNumber' => 'DEMO-004'],
+ ['key' => 'student-jade-bernard', 'emailSlug' => 'eleve.jade.bernard', 'firstName' => 'Jade', 'lastName' => 'Bernard', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-01-18'), 'studentNumber' => 'DEMO-005'],
+ ['key' => 'student-ethan-bernard', 'emailSlug' => 'eleve.ethan.bernard', 'firstName' => 'Ethan', 'lastName' => 'Bernard', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-03-27'), 'studentNumber' => 'DEMO-006'],
+ ['key' => 'student-nino-moreau', 'emailSlug' => 'eleve.nino.moreau', 'firstName' => 'Nino', 'lastName' => 'Moreau', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-09-04'), 'studentNumber' => 'DEMO-007'],
+ ['key' => 'student-sarah-dupont', 'emailSlug' => 'eleve.sarah.dupont', 'firstName' => 'Sarah', 'lastName' => 'Dupont', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-12-30'), 'studentNumber' => 'DEMO-008'],
+ ['key' => 'student-noah-martin', 'emailSlug' => 'eleve.noah.martin', 'firstName' => 'Noah', 'lastName' => 'Martin', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-02-11'), 'studentNumber' => 'DEMO-009'],
+ ['key' => 'student-adam-petit', 'emailSlug' => 'eleve.adam.petit', 'firstName' => 'Adam', 'lastName' => 'Petit', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-06-19'), 'studentNumber' => 'DEMO-010'],
+ ['key' => 'student-yasmine-petit', 'emailSlug' => 'eleve.yasmine.petit', 'firstName' => 'Yasmine', 'lastName' => 'Petit', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-08-25'), 'studentNumber' => 'DEMO-011'],
+ ['key' => 'student-mael-dupont', 'emailSlug' => 'eleve.mael.dupont', 'firstName' => 'Mael', 'lastName' => 'Dupont', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-10-03'), 'studentNumber' => 'DEMO-012'],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function subjectBlueprints(): array
+ {
+ return [
+ ['code' => 'MATH', 'name' => 'Mathématiques', 'color' => '#2563EB', 'description' => 'Cours de mathématiques pour la démonstration.'],
+ ['code' => 'FR', 'name' => 'Français', 'color' => '#DC2626', 'description' => 'Lecture, grammaire et expression écrite.'],
+ ['code' => 'HG', 'name' => 'Histoire-Géographie', 'color' => '#D97706', 'description' => 'Repères historiques et géographiques.'],
+ ['code' => 'SVT', 'name' => 'SVT', 'color' => '#16A34A', 'description' => 'Sciences de la vie et de la Terre.'],
+ ['code' => 'ANG', 'name' => 'Anglais', 'color' => '#7C3AED', 'description' => 'Cours d’anglais pour tous les niveaux.'],
+ ['code' => 'EPS', 'name' => 'EPS', 'color' => '#0891B2', 'description' => 'Éducation physique et sportive.'],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function classBlueprints(): array
+ {
+ return [
+ ['name' => '6A', 'level' => SchoolLevel::SIXIEME, 'capacity' => 28, 'description' => 'Classe de sixième utilisée pour les démonstrations.'],
+ ['name' => '5A', 'level' => SchoolLevel::CINQUIEME, 'capacity' => 28, 'description' => 'Classe de cinquième utilisée pour les démonstrations.'],
+ ['name' => '4A', 'level' => SchoolLevel::QUATRIEME, 'capacity' => 28, 'description' => 'Classe de quatrième utilisée pour les démonstrations.'],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function guardianLinkBlueprints(): array
+ {
+ return [
+ ['studentKey' => 'student-lina-martin', 'guardianKey' => 'parent-nadia-martin', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-lina-martin', 'guardianKey' => 'parent-karim-martin', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-noah-martin', 'guardianKey' => 'parent-nadia-martin', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-noah-martin', 'guardianKey' => 'parent-karim-martin', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-chloe-lopez', 'guardianKey' => 'parent-ines-lopez', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-chloe-lopez', 'guardianKey' => 'parent-raul-lopez', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-hugo-lopez', 'guardianKey' => 'parent-ines-lopez', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-hugo-lopez', 'guardianKey' => 'parent-raul-lopez', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-jade-bernard', 'guardianKey' => 'parent-claire-bernard', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-ethan-bernard', 'guardianKey' => 'parent-claire-bernard', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-adam-petit', 'guardianKey' => 'parent-mehdi-petit', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-yasmine-petit', 'guardianKey' => 'parent-mehdi-petit', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-zoe-moreau', 'guardianKey' => 'parent-laura-moreau', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-zoe-moreau', 'guardianKey' => 'parent-olivier-moreau', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-nino-moreau', 'guardianKey' => 'parent-laura-moreau', 'relationshipType' => RelationshipType::MOTHER],
+ ['studentKey' => 'student-nino-moreau', 'guardianKey' => 'parent-olivier-moreau', 'relationshipType' => RelationshipType::FATHER],
+ ['studentKey' => 'student-sarah-dupont', 'guardianKey' => 'parent-celine-dupont', 'relationshipType' => RelationshipType::TUTOR_F],
+ ['studentKey' => 'student-mael-dupont', 'guardianKey' => 'parent-celine-dupont', 'relationshipType' => RelationshipType::TUTOR_F],
+ ];
+ }
+
+ /**
+ * @return list
+ * }>
+ */
+ private function scheduleBlueprints(): array
+ {
+ return [
+ [
+ 'dayOfWeek' => DayOfWeek::MONDAY,
+ 'startTime' => '08:00',
+ 'endTime' => '09:00',
+ 'assignments' => [
+ ['className' => '6A', 'subjectCode' => 'MATH', 'room' => 'B12'],
+ ['className' => '5A', 'subjectCode' => 'FR', 'room' => 'B14'],
+ ['className' => '4A', 'subjectCode' => 'HG', 'room' => 'B16'],
+ ],
+ ],
+ [
+ 'dayOfWeek' => DayOfWeek::MONDAY,
+ 'startTime' => '09:00',
+ 'endTime' => '10:00',
+ 'assignments' => [
+ ['className' => '6A', 'subjectCode' => 'SVT', 'room' => 'LAB1'],
+ ['className' => '5A', 'subjectCode' => 'ANG', 'room' => 'B15'],
+ ['className' => '4A', 'subjectCode' => 'EPS', 'room' => 'GYM1'],
+ ],
+ ],
+ [
+ 'dayOfWeek' => DayOfWeek::TUESDAY,
+ 'startTime' => '08:00',
+ 'endTime' => '09:00',
+ 'assignments' => [
+ ['className' => '6A', 'subjectCode' => 'FR', 'room' => 'B13'],
+ ['className' => '5A', 'subjectCode' => 'HG', 'room' => 'B16'],
+ ['className' => '4A', 'subjectCode' => 'MATH', 'room' => 'B12'],
+ ],
+ ],
+ [
+ 'dayOfWeek' => DayOfWeek::TUESDAY,
+ 'startTime' => '09:00',
+ 'endTime' => '10:00',
+ 'assignments' => [
+ ['className' => '6A', 'subjectCode' => 'ANG', 'room' => 'B15'],
+ ['className' => '5A', 'subjectCode' => 'EPS', 'room' => 'GYM1'],
+ ['className' => '4A', 'subjectCode' => 'SVT', 'room' => 'LAB1'],
+ ],
+ ],
+ [
+ 'dayOfWeek' => DayOfWeek::THURSDAY,
+ 'startTime' => '13:00',
+ 'endTime' => '14:00',
+ 'assignments' => [
+ ['className' => '6A', 'subjectCode' => 'HG', 'room' => 'B16'],
+ ['className' => '5A', 'subjectCode' => 'MATH', 'room' => 'B12'],
+ ['className' => '4A', 'subjectCode' => 'FR', 'room' => 'B14'],
+ ],
+ ],
+ [
+ 'dayOfWeek' => DayOfWeek::FRIDAY,
+ 'startTime' => '14:00',
+ 'endTime' => '15:00',
+ 'assignments' => [
+ ['className' => '6A', 'subjectCode' => 'EPS', 'room' => 'GYM1'],
+ ['className' => '5A', 'subjectCode' => 'SVT', 'room' => 'LAB1'],
+ ['className' => '4A', 'subjectCode' => 'ANG', 'room' => 'B15'],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function teacherKeysBySubject(): array
+ {
+ $mapping = [];
+
+ foreach ($this->teacherBlueprints() as $blueprint) {
+ $mapping[$blueprint['subjectCode']] = $blueprint['key'];
+ }
+
+ return $mapping;
+ }
+
+ public function defaultSchoolNameForSubdomain(string $subdomain): string
+ {
+ return sprintf(
+ 'Établissement de démo %s',
+ ucwords(str_replace('-', ' ', trim($subdomain))),
+ );
+ }
+}
diff --git a/backend/tests/Unit/Administration/Infrastructure/Service/DemoDataGeneratorTest.php b/backend/tests/Unit/Administration/Infrastructure/Service/DemoDataGeneratorTest.php
new file mode 100644
index 0000000..9c6515b
--- /dev/null
+++ b/backend/tests/Unit/Administration/Infrastructure/Service/DemoDataGeneratorTest.php
@@ -0,0 +1,275 @@
+userRepository = new InMemoryUserRepository();
+ $this->classRepository = new InMemoryClassRepository();
+ $this->subjectRepository = new InMemorySubjectRepository();
+ $this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
+ $this->teacherAssignmentRepository = new InMemoryTeacherAssignmentRepository();
+ $this->studentGuardianRepository = new InMemoryStudentGuardianRepository();
+ $this->scheduleSlotRepository = new InMemoryScheduleSlotRepository();
+ $this->periodConfigurationRepository = new InMemoryPeriodConfigurationRepository();
+ $this->schoolCalendarRepository = new InMemorySchoolCalendarRepository();
+ $this->gradingConfigurationRepository = new InMemoryGradingConfigurationRepository();
+
+ $clock = new class implements Clock {
+ public function now(): DateTimeImmutable
+ {
+ return new DateTimeImmutable('2026-03-10 10:00:00');
+ }
+ };
+
+ $tenantContext = new TenantContext();
+ $currentAcademicYearResolver = new CurrentAcademicYearResolver($tenantContext, $clock);
+
+ $passwordHasher = new class implements PasswordHasher {
+ public function hash(string $plainPassword): string
+ {
+ return 'hashed_' . $plainPassword;
+ }
+
+ public function verify(string $hashedPassword, string $plainPassword): bool
+ {
+ return $hashedPassword === 'hashed_' . $plainPassword;
+ }
+ };
+
+ $officialCalendarProvider = new class implements OfficialCalendarProvider {
+ public function joursFeries(string $academicYear): array
+ {
+ return [
+ new CalendarEntry(
+ id: CalendarEntryId::generate(),
+ type: CalendarEntryType::HOLIDAY,
+ startDate: new DateTimeImmutable('2025-11-11'),
+ endDate: new DateTimeImmutable('2025-11-11'),
+ label: 'Armistice',
+ ),
+ ];
+ }
+
+ public function vacancesParZone(SchoolZone $zone, string $academicYear): array
+ {
+ return [
+ new CalendarEntry(
+ id: CalendarEntryId::generate(),
+ type: CalendarEntryType::VACATION,
+ startDate: new DateTimeImmutable('2025-12-20'),
+ endDate: new DateTimeImmutable('2026-01-04'),
+ label: 'Vacances de Noël',
+ ),
+ ];
+ }
+
+ public function toutesEntreesOfficielles(SchoolZone $zone, string $academicYear): array
+ {
+ return [
+ ...$this->joursFeries($academicYear),
+ ...$this->vacancesParZone($zone, $academicYear),
+ ];
+ }
+ };
+
+ $this->generator = new DemoDataGenerator(
+ userRepository: $this->userRepository,
+ classRepository: $this->classRepository,
+ subjectRepository: $this->subjectRepository,
+ classAssignmentRepository: $this->classAssignmentRepository,
+ teacherAssignmentRepository: $this->teacherAssignmentRepository,
+ studentGuardianRepository: $this->studentGuardianRepository,
+ scheduleSlotRepository: $this->scheduleSlotRepository,
+ periodConfigurationRepository: $this->periodConfigurationRepository,
+ schoolCalendarRepository: $this->schoolCalendarRepository,
+ gradingConfigurationRepository: $this->gradingConfigurationRepository,
+ passwordHasher: $passwordHasher,
+ clock: $clock,
+ tenantContext: $tenantContext,
+ currentAcademicYearResolver: $currentAcademicYearResolver,
+ schoolIdResolver: new SchoolIdResolver(),
+ officialCalendarProvider: $officialCalendarProvider,
+ );
+
+ $this->tenantConfig = new TenantConfig(
+ tenantId: TenantId::fromString(self::TENANT_ID),
+ subdomain: self::TENANT_SUBDOMAIN,
+ databaseUrl: 'postgresql://localhost/demo',
+ );
+ }
+
+ #[Test]
+ public function itGeneratesACompleteDemoDataset(): void
+ {
+ $result = $this->generator->generate(
+ tenantConfig: $this->tenantConfig,
+ password: 'DemoPassword123!',
+ schoolName: 'Établissement Démo',
+ zone: SchoolZone::B,
+ periodType: PeriodType::TRIMESTER,
+ );
+
+ self::assertSame(30, $result->createdUsers);
+ self::assertSame(6, $result->createdSubjects);
+ self::assertSame(3, $result->createdClasses);
+ self::assertSame(12, $result->createdClassAssignments);
+ self::assertSame(18, $result->createdTeacherAssignments);
+ self::assertSame(18, $result->createdGuardianLinks);
+ self::assertSame(18, $result->createdScheduleSlots);
+ self::assertTrue($result->periodConfigurationCreated);
+ self::assertTrue($result->schoolCalendarCreated);
+ self::assertTrue($result->gradingConfigurationCreated);
+ self::assertCount(30, $result->accounts);
+ self::assertSame('2025-2026', $result->academicYearLabel);
+
+ self::assertCount(30, $this->userRepository->findAllByTenant($this->tenantConfig->tenantId));
+ self::assertCount(12, $this->userRepository->findStudentsByTenant($this->tenantConfig->tenantId));
+
+ $student = $this->userRepository->findByEmail(
+ new Email('eleve.lina.martin.demo@classeo.test'),
+ $this->tenantConfig->tenantId,
+ );
+ self::assertNotNull($student);
+ self::assertSame('hashed_DemoPassword123!', $student->hashedPassword);
+ self::assertSame('DEMO-001', $student->studentNumber);
+
+ $mathTeacher = $this->userRepository->findByEmail(
+ new Email('prof.amina.benali.demo@classeo.test'),
+ $this->tenantConfig->tenantId,
+ );
+ self::assertNotNull($mathTeacher);
+
+ $currentYearId = $this->resolveCurrentAcademicYearId();
+ self::assertCount(
+ 3,
+ $this->classRepository->findActiveByTenantAndYear($this->tenantConfig->tenantId, $currentYearId),
+ );
+ self::assertCount(6, $this->subjectRepository->findActiveByTenantAndSchool(
+ $this->tenantConfig->tenantId,
+ \App\Administration\Domain\Model\SchoolClass\SchoolId::fromString(
+ (new SchoolIdResolver())->resolveForTenant(self::TENANT_ID),
+ ),
+ ));
+ self::assertCount(
+ 18,
+ $this->teacherAssignmentRepository->findAllActiveByTenant($this->tenantConfig->tenantId),
+ );
+ self::assertCount(
+ 2,
+ $this->studentGuardianRepository->findGuardiansForStudent($student->id, $this->tenantConfig->tenantId),
+ );
+
+ $calendar = $this->schoolCalendarRepository->findByTenantAndYear($this->tenantConfig->tenantId, $currentYearId);
+ self::assertNotNull($calendar);
+ self::assertCount(2, $calendar->entries());
+
+ $periodConfiguration = $this->periodConfigurationRepository->findByAcademicYear(
+ $this->tenantConfig->tenantId,
+ $currentYearId,
+ );
+ self::assertNotNull($periodConfiguration);
+ self::assertSame(PeriodType::TRIMESTER, $periodConfiguration->type);
+ }
+
+ #[Test]
+ public function itIsIdempotentWhenRunTwice(): void
+ {
+ $this->generator->generate(
+ tenantConfig: $this->tenantConfig,
+ password: 'DemoPassword123!',
+ schoolName: 'Établissement Démo',
+ zone: SchoolZone::B,
+ periodType: PeriodType::TRIMESTER,
+ );
+
+ $result = $this->generator->generate(
+ tenantConfig: $this->tenantConfig,
+ password: 'DemoPassword123!',
+ schoolName: 'Établissement Démo',
+ zone: SchoolZone::B,
+ periodType: PeriodType::TRIMESTER,
+ );
+
+ self::assertSame(0, $result->createdUsers);
+ self::assertSame(0, $result->createdSubjects);
+ self::assertSame(0, $result->createdClasses);
+ self::assertSame(0, $result->createdClassAssignments);
+ self::assertSame(0, $result->createdTeacherAssignments);
+ self::assertSame(0, $result->createdGuardianLinks);
+ self::assertSame(0, $result->createdScheduleSlots);
+ self::assertFalse($result->periodConfigurationCreated);
+ self::assertFalse($result->schoolCalendarCreated);
+ self::assertFalse($result->gradingConfigurationCreated);
+ self::assertCount(30, $result->accounts);
+ }
+
+ private function resolveCurrentAcademicYearId(): \App\Administration\Domain\Model\SchoolClass\AcademicYearId
+ {
+ $tenantContext = new TenantContext();
+ $tenantContext->setCurrentTenant($this->tenantConfig);
+ $clock = new class implements Clock {
+ public function now(): DateTimeImmutable
+ {
+ return new DateTimeImmutable('2026-03-10 10:00:00');
+ }
+ };
+
+ $resolver = new CurrentAcademicYearResolver($tenantContext, $clock);
+
+ return \App\Administration\Domain\Model\SchoolClass\AcademicYearId::fromString(
+ $resolver->resolve('current') ?? throw new RuntimeException('Missing academic year'),
+ );
+ }
+}
diff --git a/deploy/vps/generate-demo-data.sh b/deploy/vps/generate-demo-data.sh
new file mode 100755
index 0000000..0f7e5ac
--- /dev/null
+++ b/deploy/vps/generate-demo-data.sh
@@ -0,0 +1,200 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+ROOT_DIR=$(cd -- "${SCRIPT_DIR}/../.." && pwd)
+ENV_FILE="${CLASSEO_ENV_FILE:-${SCRIPT_DIR}/.env}"
+COMPOSE_FILE="${CLASSEO_COMPOSE_FILE:-${ROOT_DIR}/compose.prod.yaml}"
+PHP_SERVICE="${CLASSEO_PHP_SERVICE:-php}"
+
+usage() {
+ cat <&2
+ else
+ printf "%s: " "${label}" >&2
+ fi
+
+ IFS= read -r answer
+ printf '%s\n' "${answer:-${default_value}}"
+}
+
+require_file() {
+ local path="${1}"
+ local label="${2}"
+
+ if [ ! -f "${path}" ]; then
+ echo "Missing ${label}: ${path}" >&2
+ exit 1
+ fi
+}
+
+ensure_command() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Required command not found: $1" >&2
+ exit 1
+ fi
+}
+
+TENANT=""
+PASSWORD="DemoPassword123!"
+SCHOOL=""
+ZONE="B"
+PERIOD_TYPE="trimester"
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --tenant)
+ TENANT="${2:-}"
+ shift 2
+ ;;
+ --tenant=*)
+ TENANT="${1#*=}"
+ shift
+ ;;
+ --password)
+ PASSWORD="${2:-}"
+ shift 2
+ ;;
+ --password=*)
+ PASSWORD="${1#*=}"
+ shift
+ ;;
+ --school)
+ SCHOOL="${2:-}"
+ shift 2
+ ;;
+ --school=*)
+ SCHOOL="${1#*=}"
+ shift
+ ;;
+ --zone)
+ ZONE="${2:-}"
+ shift 2
+ ;;
+ --zone=*)
+ ZONE="${1#*=}"
+ shift
+ ;;
+ --period-type)
+ PERIOD_TYPE="${2:-}"
+ shift 2
+ ;;
+ --period-type=*)
+ PERIOD_TYPE="${1#*=}"
+ shift
+ ;;
+ --env-file)
+ ENV_FILE="${2:-}"
+ shift 2
+ ;;
+ --env-file=*)
+ ENV_FILE="${1#*=}"
+ shift
+ ;;
+ --compose-file)
+ COMPOSE_FILE="${2:-}"
+ shift 2
+ ;;
+ --compose-file=*)
+ COMPOSE_FILE="${1#*=}"
+ shift
+ ;;
+ --service)
+ PHP_SERVICE="${2:-}"
+ shift 2
+ ;;
+ --service=*)
+ PHP_SERVICE="${1#*=}"
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ echo >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+ensure_command docker
+require_file "${ENV_FILE}" "env file"
+require_file "${COMPOSE_FILE}" "compose file"
+
+set -a
+# shellcheck disable=SC1090
+. "${ENV_FILE}"
+set +a
+
+if [ -z "${TENANT}" ]; then
+ TENANT="${TENANT_SUBDOMAIN:-}"
+fi
+
+if [ -z "${TENANT}" ] && [ -t 0 ]; then
+ TENANT=$(prompt_with_default "Tenant subdomain" "demo")
+fi
+
+if [ -z "${TENANT}" ]; then
+ echo "Tenant subdomain is required. Pass --tenant or define TENANT_SUBDOMAIN in ${ENV_FILE}." >&2
+ exit 1
+fi
+
+if [ -z "${SCHOOL}" ] && [ -t 0 ]; then
+ SCHOOL=$(prompt_with_default "School name (optional)" "")
+fi
+
+COMMAND=(
+ docker compose
+ --env-file "${ENV_FILE}"
+ -f "${COMPOSE_FILE}"
+ exec "${PHP_SERVICE}"
+ php bin/console app:dev:generate-demo-data
+ "--tenant=${TENANT}"
+ "--password=${PASSWORD}"
+ "--zone=${ZONE}"
+ "--period-type=${PERIOD_TYPE}"
+)
+
+if [ -n "${SCHOOL}" ]; then
+ COMMAND+=("--school=${SCHOOL}")
+fi
+
+echo "Running demo data generator for tenant: ${TENANT}"
+echo "Compose file: ${COMPOSE_FILE}"
+echo "Env file: ${ENV_FILE}"
+echo
+
+"${COMMAND[@]}"
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index d78c20a..623e8c2 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -7,6 +7,9 @@ Pour un deploiement mono-serveur de validation ou de demo, voir :
- `compose.prod.yaml`
- `deploy/vps/`
+Le dossier `deploy/vps/` contient aussi un wrapper pour peupler une demo :
+- `deploy/vps/generate-demo-data.sh`
+
## Architecture Multi-tenant
Classeo utilise une architecture multi-tenant où chaque école a son propre sous-domaine :
diff --git a/docs/DEPLOYMENT_VPS1.md b/docs/DEPLOYMENT_VPS1.md
index b87b2e1..92db8ba 100644
--- a/docs/DEPLOYMENT_VPS1.md
+++ b/docs/DEPLOYMENT_VPS1.md
@@ -19,6 +19,7 @@ Le dossier `deploy/vps/` contient deja les fichiers necessaires :
- `deploy/vps/Caddyfile`
- `deploy/vps/generate-jwt.sh`
- `deploy/vps/postgres/01-create-tenant-db.sh`
+- `deploy/vps/generate-demo-data.sh`
## 1. Prerequis
@@ -288,9 +289,47 @@ docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
Le tenant PostgreSQL est cree automatiquement au premier demarrage via `deploy/vps/postgres/01-create-tenant-db.sh`.
-## 13. Creer un utilisateur de demo
+## 13. Generer un jeu de donnees de demo
-Optionnel, mais pratique pour une verification complete.
+Pour peupler rapidement l'application avec des comptes et des donnees realistes :
+- direction
+- vie scolaire
+- secretariat
+- professeurs
+- eleves
+- parents
+- matieres
+- classes
+- affectations
+- emploi du temps
+
+Le plus simple est d'utiliser le wrapper VPS :
+
+```bash
+./deploy/vps/generate-demo-data.sh
+```
+
+Le script lit `deploy/vps/.env`, reprend `TENANT_SUBDOMAIN` par defaut, puis execute la commande Symfony dans le conteneur `php`.
+
+Exemples :
+
+```bash
+./deploy/vps/generate-demo-data.sh --password 'Demo2026!'
+./deploy/vps/generate-demo-data.sh --school 'College de demo'
+./deploy/vps/generate-demo-data.sh --tenant demo --zone B --period-type trimester
+```
+
+La commande utilise un mot de passe commun pour tous les comptes, avec une valeur par defaut si tu n'en fournis pas, et affiche tous les comptes crees.
+Elle est relancable sans dupliquer les donnees.
+
+Alternative sans wrapper :
+
+```bash
+docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
+ php bin/console app:dev:generate-demo-data --tenant="${TENANT_SUBDOMAIN}"
+```
+
+Si tu veux juste creer un compte unique de verification, la commande unitaire existe toujours :
```bash
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
@@ -339,6 +378,9 @@ docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
php bin/console tenant:migrate "${TENANT_SUBDOMAIN}"
+
+# Optionnel: remettre un jeu de demo complet a jour
+./deploy/vps/generate-demo-data.sh
```
## 16. Reinstallation sur une nouvelle machine