feat(demo): add tenant demo data generator
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Add a relaunchable demo seed flow so a tenant can be populated quickly on a VPS or demo environment without manual setup.
This commit is contained in:
2026-03-10 22:44:39 +01:00
parent 8a3262faf9
commit ee62beea8c
8 changed files with 1810 additions and 2 deletions

View File

@@ -42,6 +42,13 @@ La configuration associee est deja prete dans :
- `deploy/vps/generate-env.sh` - `deploy/vps/generate-env.sh`
- `deploy/vps/Caddyfile` - `deploy/vps/Caddyfile`
- `deploy/vps/generate-jwt.sh` - `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 ### Commandes utiles

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Infrastructure\Service\DemoDataGenerationResult;
use App\Administration\Infrastructure\Service\DemoDataGenerator;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function array_map;
use function count;
use function getenv;
use function implode;
use Override;
use function sprintf;
use function strtolower;
use function strtoupper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
use Throwable;
use function trim;
#[AsCommand(
name: 'app:dev:generate-demo-data',
description: 'Génère un jeu de données complet pour un tenant de démonstration',
)]
final class GenerateDemoDataCommand extends Command
{
public function __construct(
private readonly TenantRegistry $tenantRegistry,
private readonly DemoDataGenerator $demoDataGenerator,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
#[Override]
protected function configure(): void
{
$this
->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 demploi 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: <info>%s</info>', $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<string>
*/
private function availableSubdomains(): array
{
return array_values(array_map(
static fn (TenantConfig $config): string => $config->subdomain,
$this->tenantRegistry->getAllConfigs(),
));
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
final class DemoDataGenerationResult
{
public int $createdUsers = 0;
public int $createdSubjects = 0;
public int $createdClasses = 0;
public int $createdClassAssignments = 0;
public int $createdTeacherAssignments = 0;
public int $createdGuardianLinks = 0;
public int $createdScheduleSlots = 0;
public bool $periodConfigurationCreated = false;
public bool $schoolCalendarCreated = false;
public bool $gradingConfigurationCreated = false;
/** @var list<array{role: string, name: string, email: string}> */
public array $accounts = [];
/** @var list<string> */
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;
}
}

View File

@@ -0,0 +1,957 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectColor;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use DateTimeImmutable;
use RuntimeException;
use function sprintf;
use function str_replace;
use function strtolower;
use function trim;
use function ucwords;
final readonly class DemoDataGenerator
{
public function __construct(
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private ClassAssignmentRepository $classAssignmentRepository,
private TeacherAssignmentRepository $teacherAssignmentRepository,
private StudentGuardianRepository $studentGuardianRepository,
private ScheduleSlotRepository $scheduleSlotRepository,
private PeriodConfigurationRepository $periodConfigurationRepository,
private \App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository $schoolCalendarRepository,
private GradingConfigurationRepository $gradingConfigurationRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
private TenantContext $tenantContext,
private CurrentAcademicYearResolver $currentAcademicYearResolver,
private SchoolIdResolver $schoolIdResolver,
private OfficialCalendarProvider $officialCalendarProvider,
) {
}
public function generate(
TenantConfig $tenantConfig,
string $password,
string $schoolName,
SchoolZone $zone,
PeriodType $periodType,
): DemoDataGenerationResult {
$this->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<string, User>
*/
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<string, Subject>
*/
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<string, SchoolClass>
*/
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<string, User> $users
* @param array<string, SchoolClass> $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<string, User> $users
* @param array<string, SchoolClass> $classes
* @param array<string, Subject> $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<string, User> $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<string, SchoolClass> $classes
* @param array<string, Subject> $subjects
* @param array<string, User> $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<array{key: string, role: Role, emailSlug: string, firstName: string, lastName: string}>
*/
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<array{key: string, subjectCode: string, emailSlug: string, firstName: string, lastName: string}>
*/
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<array{key: string, emailSlug: string, firstName: string, lastName: string}>
*/
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<array{
* key: string,
* emailSlug: string,
* firstName: string,
* lastName: string,
* className: string,
* dateOfBirth: DateTimeImmutable,
* studentNumber: string
* }>
*/
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<array{
* code: non-empty-string,
* name: non-empty-string,
* color: non-empty-string,
* description: non-empty-string
* }>
*/
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 danglais pour tous les niveaux.'],
['code' => 'EPS', 'name' => 'EPS', 'color' => '#0891B2', 'description' => 'Éducation physique et sportive.'],
];
}
/**
* @return list<array{name: string, level: SchoolLevel, capacity: int, description: string}>
*/
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<array{studentKey: string, guardianKey: string, relationshipType: RelationshipType}>
*/
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<array{
* dayOfWeek: DayOfWeek,
* startTime: string,
* endTime: string,
* assignments: list<array{className: string, subjectCode: string, room: string}>
* }>
*/
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<string, string>
*/
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))),
);
}
}

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryGradingConfigurationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Administration\Infrastructure\Service\DemoDataGenerator;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class DemoDataGeneratorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440123';
private const string TENANT_SUBDOMAIN = 'demo';
private InMemoryUserRepository $userRepository;
private InMemoryClassRepository $classRepository;
private InMemorySubjectRepository $subjectRepository;
private InMemoryClassAssignmentRepository $classAssignmentRepository;
private InMemoryTeacherAssignmentRepository $teacherAssignmentRepository;
private InMemoryStudentGuardianRepository $studentGuardianRepository;
private InMemoryScheduleSlotRepository $scheduleSlotRepository;
private InMemoryPeriodConfigurationRepository $periodConfigurationRepository;
private InMemorySchoolCalendarRepository $schoolCalendarRepository;
private GradingConfigurationRepository $gradingConfigurationRepository;
private DemoDataGenerator $generator;
private TenantConfig $tenantConfig;
protected function setUp(): void
{
$this->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'),
);
}
}

200
deploy/vps/generate-demo-data.sh Executable file
View File

@@ -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 <<EOF
Usage: ./deploy/vps/generate-demo-data.sh [options]
Generate a full demo dataset inside the tenant database used by the VPS deployment.
Options:
--tenant SUBDOMAIN Tenant subdomain. Defaults to TENANT_SUBDOMAIN from ${ENV_FILE}.
--password PASSWORD Shared password for all generated accounts.
Default: DemoPassword123!
--school NAME School name displayed on generated accounts.
--zone ZONE School zone: A, B or C. Default: B
--period-type TYPE Academic period type: trimester or semester.
Default: trimester
--env-file PATH Override env file path. Default: ${ENV_FILE}
--compose-file PATH Override compose file path. Default: ${COMPOSE_FILE}
--service NAME Override PHP service name. Default: ${PHP_SERVICE}
-h, --help Show this help.
Examples:
./deploy/vps/generate-demo-data.sh
./deploy/vps/generate-demo-data.sh --password 'Demo2026!'
./deploy/vps/generate-demo-data.sh --tenant demo --school 'College de demo'
EOF
}
prompt_with_default() {
local label="${1}"
local default_value="${2:-}"
local answer
if [ -n "${default_value}" ]; then
printf "%s [%s]: " "${label}" "${default_value}" >&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[@]}"

View File

@@ -7,6 +7,9 @@ Pour un deploiement mono-serveur de validation ou de demo, voir :
- `compose.prod.yaml` - `compose.prod.yaml`
- `deploy/vps/` - `deploy/vps/`
Le dossier `deploy/vps/` contient aussi un wrapper pour peupler une demo :
- `deploy/vps/generate-demo-data.sh`
## Architecture Multi-tenant ## Architecture Multi-tenant
Classeo utilise une architecture multi-tenant où chaque école a son propre sous-domaine : Classeo utilise une architecture multi-tenant où chaque école a son propre sous-domaine :

View File

@@ -19,6 +19,7 @@ Le dossier `deploy/vps/` contient deja les fichiers necessaires :
- `deploy/vps/Caddyfile` - `deploy/vps/Caddyfile`
- `deploy/vps/generate-jwt.sh` - `deploy/vps/generate-jwt.sh`
- `deploy/vps/postgres/01-create-tenant-db.sh` - `deploy/vps/postgres/01-create-tenant-db.sh`
- `deploy/vps/generate-demo-data.sh`
## 1. Prerequis ## 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`. 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 ```bash
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 \
@@ -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 \ docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
php bin/console tenant:migrate "${TENANT_SUBDOMAIN}" 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 ## 16. Reinstallation sur une nouvelle machine