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.
This commit is contained in:
@@ -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 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: <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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user