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

@@ -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(),
));
}
}