From ee62beea8cdbad1ca31ecf793fc9a55d9f344c14 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 10 Mar 2026 22:44:39 +0100 Subject: [PATCH] feat(demo): add tenant demo data generator Add a relaunchable demo seed flow so a tenant can be populated quickly on a VPS or demo environment without manual setup. --- README.md | 7 + .../Console/GenerateDemoDataCommand.php | 277 +++++ .../Service/DemoDataGenerationResult.php | 47 + .../Service/DemoDataGenerator.php | 957 ++++++++++++++++++ .../Service/DemoDataGeneratorTest.php | 275 +++++ deploy/vps/generate-demo-data.sh | 200 ++++ docs/DEPLOYMENT.md | 3 + docs/DEPLOYMENT_VPS1.md | 46 +- 8 files changed, 1810 insertions(+), 2 deletions(-) create mode 100644 backend/src/Administration/Infrastructure/Console/GenerateDemoDataCommand.php create mode 100644 backend/src/Administration/Infrastructure/Service/DemoDataGenerationResult.php create mode 100644 backend/src/Administration/Infrastructure/Service/DemoDataGenerator.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Service/DemoDataGeneratorTest.php create mode 100755 deploy/vps/generate-demo-data.sh 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