Loading api/src/Command/ImportPegaseCommand.php +18 −9 Changes for api/src/Command/ImportPegaseCommand.php: 18 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -42,7 +42,8 @@ final class ImportPegaseCommand extends Command * @param ManagerRegistry $doctrine réouvre le gestionnaire fermé par un échec de la transaction * @param ClockInterface $horloge date de l'import * @param bool $pegaseActive valeur de PEGASE_ENABLED * @param int $lot entités enregistrées entre deux vidages du gestionnaire, pour borner la mémoire * @param int $lot entités enregistrées entre deux vidages du gestionnaire, pour * borner la mémoire * @param LoggerInterface $logger progression de l'enregistrement */ public function __construct( Loading Loading @@ -90,10 +91,16 @@ final class ImportPegaseCommand extends Command return Command::FAILURE; } $duree = $this->horloge->now()->getTimestamp() - $journal->date->getTimestamp(); $memoire = intdiv(memory_get_peak_usage(true), 1024 * 1024); $io->success(\sprintf( 'Import terminé en %d s (mémoire : %d Mo) | Structures : %d, Programmes : %d, Cours : %d, Instances : %d', $this->horloge->now()->getTimestamp() - $journal->date->getTimestamp(), intdiv(memory_get_peak_usage(true), 1048576), $journal->nbOunits, $journal->nbProgrammes, $journal->nbCourses, $journal->nbInstances, $duree, $memoire, $journal->nbOunits, $journal->nbProgrammes, $journal->nbCourses, $journal->nbInstances, )); return Command::SUCCESS; Loading @@ -106,7 +113,7 @@ final class ImportPegaseCommand extends Command foreach (self::TABLES as $table) { $manager->getConnection()->executeStatement('DELETE FROM '.$table); } $entites = [...$resultat->ounits, ...$resultat->programmes, ...$resultat->courses, ...$resultat->instances]; $entites = $resultat->entites(); $this->logger->info(\sprintf('Enregistrement de %d entités', \count($entites))); foreach ($entites as $rang => $entite) { $manager->persist($this->rattacher($manager, $entite)); Loading @@ -127,13 +134,15 @@ final class ImportPegaseCommand extends Command */ private function rattacher(EntityManagerInterface $manager, Ounit|Programme|Course|CourseInstance $entite): object { if ($entite instanceof Programme || $entite instanceof Course) { $entite->ounit = null === $entite->ounit ? null : $manager->getReference(Ounit::class, $entite->ounit->ounitId); if (($entite instanceof Programme || $entite instanceof Course) && null !== $entite->ounit) { $entite->ounit = $manager->getReference(Ounit::class, $entite->ounit->ounitId); } if ($entite instanceof Course) { $entite->relatedProgramme = new ArrayCollection($entite->relatedProgramme->map( static fn (Programme $programme): Programme => $manager->getReference(Programme::class, $programme->code), )->getValues()); $programmes = []; foreach ($entite->relatedProgramme as $programme) { $programmes[] = $manager->getReference(Programme::class, $programme->code); } $entite->relatedProgramme = new ArrayCollection($programmes); } if ($entite instanceof CourseInstance) { $entite->course = $manager->getReference(Course::class, $entite->course->code); Loading api/src/Pegase/Configuration.php +32 −6 Changes for api/src/Pegase/Configuration.php: 32 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -17,7 +17,11 @@ final class Configuration /** @var array<string, string> type d'objet de formation => balise BCP 47 */ public readonly array $langues; /** @var array{1: array{0: string, 1: string}, 2: array{0: string, 1: string}} semestre => [début, fin] en mois-jour */ /** * Semestre => [début, fin] en mois-jour. * * @var array{1: array{0: string, 1: string}, 2: array{0: string, 1: string}} */ public readonly array $semestres; /** @var list<string> */ Loading Loading @@ -47,6 +51,18 @@ final class Configuration ]; } /** * Découpe une valeur de configuration en liste. * * @param string $valeur valeurs séparées par des virgules * * @return list<string> valeurs sans espaces autour, vides exclues */ public static function liste(string $valeur): array { return array_values(array_filter(array_map('trim', explode(',', $valeur)))); } /** * @return list<string> valeurs absentes ou invalides, liste vide si la configuration est utilisable */ Loading Loading @@ -83,8 +99,12 @@ final class Configuration return [$debutAnnee, $this->prochaine($debutAnnee, $this->semestres[1][1])]; } $debutSemestre2 = $this->prochaine($debutAnnee, $this->semestres[2][0]); $finAnnee = $this->prochaine($debutSemestre2, $this->semestres[2][1]); if (2 === $semestre) { return [$debutSemestre2, $finAnnee]; } return [2 === $semestre ? $debutSemestre2 : $debutAnnee, $this->prochaine($debutSemestre2, $this->semestres[2][1])]; return [$debutAnnee, $finAnnee]; } private function prochaine(\DateTimeImmutable $depuis, string $moisJour): \DateTimeImmutable Loading @@ -100,11 +120,14 @@ final class Configuration private function lireAnnees(string $valeur): array { $annees = []; foreach (array_filter(array_map('trim', explode(',', $valeur))) as $annee) { foreach (self::liste($valeur) as $annee) { if (preg_match('/^(\d{4})-(\d{4})$/', $annee, $m) && (int) $m[2] === (int) $m[1] + 1) { $annees[] = (int) $m[1]; } else { $this->erreurs[] = \sprintf('PEGASE_ANNEES_UNIVERSITAIRES : « %s » n\'est pas une année universitaire (2026-2027)', $annee); $this->erreurs[] = \sprintf( 'PEGASE_ANNEES_UNIVERSITAIRES : « %s » n\'est pas une année universitaire (2026-2027)', $annee, ); } } if (!$annees && !$this->erreurs) { Loading @@ -122,11 +145,14 @@ final class Configuration private function lireLangues(string $valeur): array { $langues = []; foreach (array_filter(array_map('trim', explode(',', $valeur))) as $paire) { foreach (self::liste($valeur) as $paire) { if (preg_match('/^([^:\s]+):([A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*)$/', $paire, $m)) { $langues[$m[1]] = $m[2]; } else { $this->erreurs[] = \sprintf('PEGASE_LANGUES : « %s » n\'est pas de la forme TYPE:langue (ECA:en)', $paire); $this->erreurs[] = \sprintf( 'PEGASE_LANGUES : « %s » n\'est pas de la forme TYPE:langue (ECA:en)', $paire, ); } } Loading api/src/Pegase/PegaseClient.php +127 −57 Changes for api/src/Pegase/PegaseClient.php: 127 added lines, 57 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Contracts\HttpClient\ChunkInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; Loading Loading @@ -108,7 +109,8 @@ final class PegaseClient * GET sur l'API référentiel. * * @param string $chemin chemin relatif à PEGASE_API_REF_URL * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre répété * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre * répété * * @return array<mixed> réponse JSON décodée * Loading @@ -122,8 +124,10 @@ final class PegaseClient /** * GET sur l'API ODF. * * @param string $chemin chemin relatif à /etablissement/{code}, par exemple maquette/{id} * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre répété (ids) * @param string $chemin chemin relatif à /etablissement/{code}, par exemple * maquette/{id} * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre * répété (ids) * * @return array<mixed> réponse JSON décodée * Loading Loading @@ -160,8 +164,8 @@ final class PegaseClient * GET en parallèle sur l'API ODF, au plus $concurrence requêtes à la fois. * * @param array<string|int, string> $chemins clé => chemin relatif à /etablissement/{code} * @param (\Closure(array<mixed>): array<mixed>)|null $extraire appliquée à chaque réponse dès son arrivée, pour ne garder * en mémoire que les champs utiles * @param (\Closure(array<mixed>): array<mixed>)|null $extraire appliquée à chaque réponse dès son arrivée, * pour ne garder en mémoire que les champs utiles * * @return array<string|int, array<mixed>> clé => réponse JSON décodée (puis extraite), dans l'ordre d'arrivée * Loading @@ -187,7 +191,8 @@ final class PegaseClient $reprise = 0.0; try { while ($urls || $enCours) { if (!$enCours && ($attente = $reprise - $this->maintenant()) > 0) { $attente = $reprise - $this->maintenant(); if (!$enCours && $attente > 0) { $this->horloge->sleep($attente); } while ($urls && \count($enCours) < $this->concurrence && $this->maintenant() >= $reprise) { Loading @@ -199,39 +204,21 @@ final class PegaseClient } foreach ($this->httpClient->stream($enCours) as $reponse => $morceau) { $erreur = null; try { $dernier = $morceau->isLast(); // Statut lu dès le premier morceau, sinon le flux lève lui-même une exception sur un 4xx ou 5xx $statut = $reponse->getStatusCode(); } catch (TransportExceptionInterface $e) { $reponse->cancel(); $dernier = true; $statut = 0; $erreur = $e->getMessage(); } if (!$dernier) { [$terminee, $statut, $erreur] = $this->lireMorceau($reponse, $morceau); if (!$terminee) { continue; } unset($enCours[spl_object_id($reponse)]); [$cle, $url, $generation] = $reponse->getInfo('user_data'); if (200 === $statut) { $resultats[$cle] = $extraire ? $extraire($this->decoder($reponse, $url)) : $this->decoder($reponse, $url); $donnees = $this->decoder($reponse, $url); $resultats[$cle] = $extraire ? $extraire($donnees) : $donnees; } elseif ($essais[$cle] < self::ESSAIS && (401 === $statut || $this->reessayable($statut))) { if (401 === $statut) { $this->logger->info(\sprintf('Pegase, HTTP 401 sur %s : jeton expiré, nouvelle tentative', $url)); if ($generation === $this->generationJeton) { $this->jeton = null; } } else { $delai = $this->delaiReprise($reponse, $statut); $this->logger->warning(\sprintf('Pegase, %s sur %s : nouvelle tentative dans %.0f s', $statut ? 'HTTP '.$statut : 'erreur réseau', $url, $delai)); $reprise = max($reprise, $this->maintenant() + $delai); } $reprise = max($reprise, $this->nouvelleTentative($reponse, $statut, $url, $generation)); $urls[$cle] = $url; } else { throw new PegaseException(\sprintf('Pegase, %s sur %s : %s', $statut ? 'HTTP '.$statut : 'erreur réseau', $url, $erreur ?? mb_substr($reponse->getContent(false), 0, 300))); throw $this->echec($reponse, $statut, $url, $erreur); } // Sortie du flux pour compléter le lot avec les requêtes en attente break; Loading @@ -246,6 +233,57 @@ final class PegaseClient return $resultats; } /** * @return array{0: bool, 1: int, 2: string|null} réponse terminée, statut (0 sur erreur réseau), erreur réseau */ private function lireMorceau(ResponseInterface $reponse, ChunkInterface $morceau): array { try { $terminee = $morceau->isLast(); // Lu dès le premier morceau, sinon le flux lève lui-même une exception sur 4xx ou 5xx return [$terminee, $reponse->getStatusCode(), null]; } catch (TransportExceptionInterface $e) { $reponse->cancel(); return [true, 0, $e->getMessage()]; } } /** * Prépare une nouvelle tentative : jeton à renouveler sur un 401, délai d'attente sinon. * * @return float instant avant lequel aucune requête ne doit partir, 0 sans attente */ private function nouvelleTentative(ResponseInterface $reponse, int $statut, string $url, int $generation): float { if (401 === $statut) { $this->logger->info(\sprintf('Pegase, HTTP 401 sur %s : jeton expiré, nouvelle tentative', $url)); // Le jeton a déjà été renouvelé si la requête est partie avec un plus ancien if ($generation === $this->generationJeton) { $this->jeton = null; } return 0.0; } $delai = $this->delaiReprise($reponse, $statut); $this->logger->warning(\sprintf( 'Pegase, %s sur %s : nouvelle tentative dans %.0f s', $this->libelleStatut($statut), $url, $delai, )); return $this->maintenant() + $delai; } private function echec(ResponseInterface $reponse, int $statut, string $url, ?string $erreur): PegaseException { $cause = $erreur ?? mb_substr($reponse->getContent(false), 0, 300); return new PegaseException(\sprintf('Pegase, %s sur %s : %s', $this->libelleStatut($statut), $url, $cause)); } private function requete(string $url, string|int $cle): ResponseInterface { $jeton = $this->jeton(); Loading @@ -256,7 +294,8 @@ final class PegaseClient 'user_data' => [$cle, $url, $this->generationJeton], ]); } catch (ExceptionInterface $e) { throw new PegaseException(\sprintf('Pegase, requête impossible sur %s : %s', $url, $e->getMessage()), 0, $e); $message = \sprintf('Pegase, requête impossible sur %s : %s', $url, $e->getMessage()); throw new PegaseException($message, 0, $e); } } Loading @@ -265,13 +304,21 @@ final class PegaseClient return 0 === $statut || 429 === $statut || $statut >= 500; } private function libelleStatut(int $statut): string { return 0 === $statut ? 'erreur réseau' : 'HTTP '.$statut; } private function delaiReprise(ResponseInterface $reponse, int $statut): float { $apres = $statut ? ($reponse->getHeaders(false)['retry-after'][0] ?? null) : null; $apres = null; if (0 !== $statut) { $apres = $reponse->getHeaders(false)['retry-after'][0] ?? null; } $date = null === $apres ? false : strtotime($apres); $delai = match (true) { null === $apres => self::DELAI_REPRISE, is_numeric($apres) => (float) $apres, false !== ($date = strtotime($apres)) => $date - $this->maintenant(), false !== $date => $date - $this->maintenant(), default => self::DELAI_REPRISE, }; Loading @@ -291,7 +338,8 @@ final class PegaseClient try { return $reponse->toArray(false); } catch (ExceptionInterface $e) { throw new PegaseException(\sprintf('Pegase, réponse illisible sur %s : %s', $url, $e->getMessage()), 0, $e); $message = \sprintf('Pegase, réponse illisible sur %s : %s', $url, $e->getMessage()); throw new PegaseException($message, 0, $e); } } Loading @@ -300,36 +348,46 @@ final class PegaseClient if (null !== $this->jeton) { return $this->jeton; } if ($manquantes = $this->configurationManquante()) { $manquantes = $this->configurationManquante(); if ($manquantes) { throw new PegaseException('Configuration Pegase incomplète : '.implode(', ', $manquantes)); } for ($essai = 1;; ++$essai) { $erreur = null; [$statut, $jeton, $erreur] = $this->demanderJeton(); if (\in_array($statut, [200, 201], true) && '' !== $jeton) { ++$this->generationJeton; return $this->jeton = $jeton; } if ($essai >= self::ESSAIS || !$this->reessayable($statut)) { $cause = $erreur?->getMessage() ?? 'HTTP '.$statut; throw new PegaseException('Authentification Pegase en échec : '.$cause, 0, $erreur); } $this->logger->warning(\sprintf( 'Authentification Pegase, %s : nouvelle tentative dans %.0f s', $this->libelleStatut($statut), self::DELAI_REPRISE, )); $this->horloge->sleep(self::DELAI_REPRISE); } } /** * @return array{0: int, 1: string, 2: ExceptionInterface|null} statut (0 sur erreur réseau), jeton, erreur réseau */ private function demanderJeton(): array { try { $reponse = $this->httpClient->request('POST', $this->jetonUrl, [ 'body' => ['username' => $this->utilisateur, 'password' => $this->motDePasse, 'token' => 'true'], 'headers' => ['Accept' => 'text/plain'], ]); $statut = $reponse->getStatusCode(); $jeton = trim($reponse->getContent(false)); return [$reponse->getStatusCode(), trim($reponse->getContent(false)), null]; } catch (ExceptionInterface $e) { $statut = 0; $jeton = ''; $erreur = $e; } if (\in_array($statut, [200, 201], true) && '' !== $jeton) { break; return [0, '', $e]; } if ($essai >= self::ESSAIS || !$this->reessayable($statut)) { throw new PegaseException('Authentification Pegase en échec : '.($erreur?->getMessage() ?? 'HTTP '.$statut), 0, $erreur); } $this->logger->warning(\sprintf('Authentification Pegase, %s : nouvelle tentative dans %.0f s', $statut ? 'HTTP '.$statut : 'erreur réseau', self::DELAI_REPRISE)); $this->horloge->sleep(self::DELAI_REPRISE); } ++$this->generationJeton; return $this->jeton = $jeton; } /** Loading @@ -337,7 +395,9 @@ final class PegaseClient */ private function urlOdf(string $chemin, array $params = []): string { return $this->url($this->odfUrl, 'etablissement/'.rawurlencode($this->etablissement()).'/'.ltrim($chemin, '/'), $params); $cheminComplet = 'etablissement/'.rawurlencode($this->etablissement()).'/'.ltrim($chemin, '/'); return $this->url($this->odfUrl, $cheminComplet, $params); } /** Loading @@ -350,10 +410,20 @@ final class PegaseClient $requete = []; foreach ($params as $nom => $valeurs) { foreach ((array) $valeurs as $valeur) { $requete[] = rawurlencode($nom).'='.rawurlencode(\is_bool($valeur) ? ($valeur ? 'true' : 'false') : (string) $valeur); $requete[] = rawurlencode($nom).'='.rawurlencode($this->valeurParametre($valeur)); } } $url = rtrim($base, '/').'/'.ltrim($chemin, '/'); return $requete ? $url.'?'.implode('&', $requete) : $url; } private function valeurParametre(string|int|float|bool $valeur): string { if (\is_bool($valeur)) { return $valeur ? 'true' : 'false'; } return rtrim($base, '/').'/'.ltrim($chemin, '/').($requete ? '?'.implode('&', $requete) : ''); return (string) $valeur; } } api/src/Pegase/PegaseImporter.php +195 −66 File changed.Preview size limit exceeded, changes collapsed. Show changes api/src/Pegase/ResultatImport.php +9 −0 Changes for api/src/Pegase/ResultatImport.php: 9 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -25,4 +25,13 @@ final readonly class ResultatImport public array $instances, ) { } /** * @return list<Ounit|Programme|Course|CourseInstance> toutes les entités, les entités référencées avant celles * qui les référencent */ public function entites(): array { return [...$this->ounits, ...$this->programmes, ...$this->courses, ...$this->instances]; } } Loading
api/src/Command/ImportPegaseCommand.php +18 −9 Changes for api/src/Command/ImportPegaseCommand.php: 18 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -42,7 +42,8 @@ final class ImportPegaseCommand extends Command * @param ManagerRegistry $doctrine réouvre le gestionnaire fermé par un échec de la transaction * @param ClockInterface $horloge date de l'import * @param bool $pegaseActive valeur de PEGASE_ENABLED * @param int $lot entités enregistrées entre deux vidages du gestionnaire, pour borner la mémoire * @param int $lot entités enregistrées entre deux vidages du gestionnaire, pour * borner la mémoire * @param LoggerInterface $logger progression de l'enregistrement */ public function __construct( Loading Loading @@ -90,10 +91,16 @@ final class ImportPegaseCommand extends Command return Command::FAILURE; } $duree = $this->horloge->now()->getTimestamp() - $journal->date->getTimestamp(); $memoire = intdiv(memory_get_peak_usage(true), 1024 * 1024); $io->success(\sprintf( 'Import terminé en %d s (mémoire : %d Mo) | Structures : %d, Programmes : %d, Cours : %d, Instances : %d', $this->horloge->now()->getTimestamp() - $journal->date->getTimestamp(), intdiv(memory_get_peak_usage(true), 1048576), $journal->nbOunits, $journal->nbProgrammes, $journal->nbCourses, $journal->nbInstances, $duree, $memoire, $journal->nbOunits, $journal->nbProgrammes, $journal->nbCourses, $journal->nbInstances, )); return Command::SUCCESS; Loading @@ -106,7 +113,7 @@ final class ImportPegaseCommand extends Command foreach (self::TABLES as $table) { $manager->getConnection()->executeStatement('DELETE FROM '.$table); } $entites = [...$resultat->ounits, ...$resultat->programmes, ...$resultat->courses, ...$resultat->instances]; $entites = $resultat->entites(); $this->logger->info(\sprintf('Enregistrement de %d entités', \count($entites))); foreach ($entites as $rang => $entite) { $manager->persist($this->rattacher($manager, $entite)); Loading @@ -127,13 +134,15 @@ final class ImportPegaseCommand extends Command */ private function rattacher(EntityManagerInterface $manager, Ounit|Programme|Course|CourseInstance $entite): object { if ($entite instanceof Programme || $entite instanceof Course) { $entite->ounit = null === $entite->ounit ? null : $manager->getReference(Ounit::class, $entite->ounit->ounitId); if (($entite instanceof Programme || $entite instanceof Course) && null !== $entite->ounit) { $entite->ounit = $manager->getReference(Ounit::class, $entite->ounit->ounitId); } if ($entite instanceof Course) { $entite->relatedProgramme = new ArrayCollection($entite->relatedProgramme->map( static fn (Programme $programme): Programme => $manager->getReference(Programme::class, $programme->code), )->getValues()); $programmes = []; foreach ($entite->relatedProgramme as $programme) { $programmes[] = $manager->getReference(Programme::class, $programme->code); } $entite->relatedProgramme = new ArrayCollection($programmes); } if ($entite instanceof CourseInstance) { $entite->course = $manager->getReference(Course::class, $entite->course->code); Loading
api/src/Pegase/Configuration.php +32 −6 Changes for api/src/Pegase/Configuration.php: 32 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -17,7 +17,11 @@ final class Configuration /** @var array<string, string> type d'objet de formation => balise BCP 47 */ public readonly array $langues; /** @var array{1: array{0: string, 1: string}, 2: array{0: string, 1: string}} semestre => [début, fin] en mois-jour */ /** * Semestre => [début, fin] en mois-jour. * * @var array{1: array{0: string, 1: string}, 2: array{0: string, 1: string}} */ public readonly array $semestres; /** @var list<string> */ Loading Loading @@ -47,6 +51,18 @@ final class Configuration ]; } /** * Découpe une valeur de configuration en liste. * * @param string $valeur valeurs séparées par des virgules * * @return list<string> valeurs sans espaces autour, vides exclues */ public static function liste(string $valeur): array { return array_values(array_filter(array_map('trim', explode(',', $valeur)))); } /** * @return list<string> valeurs absentes ou invalides, liste vide si la configuration est utilisable */ Loading Loading @@ -83,8 +99,12 @@ final class Configuration return [$debutAnnee, $this->prochaine($debutAnnee, $this->semestres[1][1])]; } $debutSemestre2 = $this->prochaine($debutAnnee, $this->semestres[2][0]); $finAnnee = $this->prochaine($debutSemestre2, $this->semestres[2][1]); if (2 === $semestre) { return [$debutSemestre2, $finAnnee]; } return [2 === $semestre ? $debutSemestre2 : $debutAnnee, $this->prochaine($debutSemestre2, $this->semestres[2][1])]; return [$debutAnnee, $finAnnee]; } private function prochaine(\DateTimeImmutable $depuis, string $moisJour): \DateTimeImmutable Loading @@ -100,11 +120,14 @@ final class Configuration private function lireAnnees(string $valeur): array { $annees = []; foreach (array_filter(array_map('trim', explode(',', $valeur))) as $annee) { foreach (self::liste($valeur) as $annee) { if (preg_match('/^(\d{4})-(\d{4})$/', $annee, $m) && (int) $m[2] === (int) $m[1] + 1) { $annees[] = (int) $m[1]; } else { $this->erreurs[] = \sprintf('PEGASE_ANNEES_UNIVERSITAIRES : « %s » n\'est pas une année universitaire (2026-2027)', $annee); $this->erreurs[] = \sprintf( 'PEGASE_ANNEES_UNIVERSITAIRES : « %s » n\'est pas une année universitaire (2026-2027)', $annee, ); } } if (!$annees && !$this->erreurs) { Loading @@ -122,11 +145,14 @@ final class Configuration private function lireLangues(string $valeur): array { $langues = []; foreach (array_filter(array_map('trim', explode(',', $valeur))) as $paire) { foreach (self::liste($valeur) as $paire) { if (preg_match('/^([^:\s]+):([A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*)$/', $paire, $m)) { $langues[$m[1]] = $m[2]; } else { $this->erreurs[] = \sprintf('PEGASE_LANGUES : « %s » n\'est pas de la forme TYPE:langue (ECA:en)', $paire); $this->erreurs[] = \sprintf( 'PEGASE_LANGUES : « %s » n\'est pas de la forme TYPE:langue (ECA:en)', $paire, ); } } Loading
api/src/Pegase/PegaseClient.php +127 −57 Changes for api/src/Pegase/PegaseClient.php: 127 added lines, 57 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Contracts\HttpClient\ChunkInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; Loading Loading @@ -108,7 +109,8 @@ final class PegaseClient * GET sur l'API référentiel. * * @param string $chemin chemin relatif à PEGASE_API_REF_URL * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre répété * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre * répété * * @return array<mixed> réponse JSON décodée * Loading @@ -122,8 +124,10 @@ final class PegaseClient /** * GET sur l'API ODF. * * @param string $chemin chemin relatif à /etablissement/{code}, par exemple maquette/{id} * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre répété (ids) * @param string $chemin chemin relatif à /etablissement/{code}, par exemple * maquette/{id} * @param array<string, scalar|list<scalar>> $params paramètres de requête, une liste donne un paramètre * répété (ids) * * @return array<mixed> réponse JSON décodée * Loading Loading @@ -160,8 +164,8 @@ final class PegaseClient * GET en parallèle sur l'API ODF, au plus $concurrence requêtes à la fois. * * @param array<string|int, string> $chemins clé => chemin relatif à /etablissement/{code} * @param (\Closure(array<mixed>): array<mixed>)|null $extraire appliquée à chaque réponse dès son arrivée, pour ne garder * en mémoire que les champs utiles * @param (\Closure(array<mixed>): array<mixed>)|null $extraire appliquée à chaque réponse dès son arrivée, * pour ne garder en mémoire que les champs utiles * * @return array<string|int, array<mixed>> clé => réponse JSON décodée (puis extraite), dans l'ordre d'arrivée * Loading @@ -187,7 +191,8 @@ final class PegaseClient $reprise = 0.0; try { while ($urls || $enCours) { if (!$enCours && ($attente = $reprise - $this->maintenant()) > 0) { $attente = $reprise - $this->maintenant(); if (!$enCours && $attente > 0) { $this->horloge->sleep($attente); } while ($urls && \count($enCours) < $this->concurrence && $this->maintenant() >= $reprise) { Loading @@ -199,39 +204,21 @@ final class PegaseClient } foreach ($this->httpClient->stream($enCours) as $reponse => $morceau) { $erreur = null; try { $dernier = $morceau->isLast(); // Statut lu dès le premier morceau, sinon le flux lève lui-même une exception sur un 4xx ou 5xx $statut = $reponse->getStatusCode(); } catch (TransportExceptionInterface $e) { $reponse->cancel(); $dernier = true; $statut = 0; $erreur = $e->getMessage(); } if (!$dernier) { [$terminee, $statut, $erreur] = $this->lireMorceau($reponse, $morceau); if (!$terminee) { continue; } unset($enCours[spl_object_id($reponse)]); [$cle, $url, $generation] = $reponse->getInfo('user_data'); if (200 === $statut) { $resultats[$cle] = $extraire ? $extraire($this->decoder($reponse, $url)) : $this->decoder($reponse, $url); $donnees = $this->decoder($reponse, $url); $resultats[$cle] = $extraire ? $extraire($donnees) : $donnees; } elseif ($essais[$cle] < self::ESSAIS && (401 === $statut || $this->reessayable($statut))) { if (401 === $statut) { $this->logger->info(\sprintf('Pegase, HTTP 401 sur %s : jeton expiré, nouvelle tentative', $url)); if ($generation === $this->generationJeton) { $this->jeton = null; } } else { $delai = $this->delaiReprise($reponse, $statut); $this->logger->warning(\sprintf('Pegase, %s sur %s : nouvelle tentative dans %.0f s', $statut ? 'HTTP '.$statut : 'erreur réseau', $url, $delai)); $reprise = max($reprise, $this->maintenant() + $delai); } $reprise = max($reprise, $this->nouvelleTentative($reponse, $statut, $url, $generation)); $urls[$cle] = $url; } else { throw new PegaseException(\sprintf('Pegase, %s sur %s : %s', $statut ? 'HTTP '.$statut : 'erreur réseau', $url, $erreur ?? mb_substr($reponse->getContent(false), 0, 300))); throw $this->echec($reponse, $statut, $url, $erreur); } // Sortie du flux pour compléter le lot avec les requêtes en attente break; Loading @@ -246,6 +233,57 @@ final class PegaseClient return $resultats; } /** * @return array{0: bool, 1: int, 2: string|null} réponse terminée, statut (0 sur erreur réseau), erreur réseau */ private function lireMorceau(ResponseInterface $reponse, ChunkInterface $morceau): array { try { $terminee = $morceau->isLast(); // Lu dès le premier morceau, sinon le flux lève lui-même une exception sur 4xx ou 5xx return [$terminee, $reponse->getStatusCode(), null]; } catch (TransportExceptionInterface $e) { $reponse->cancel(); return [true, 0, $e->getMessage()]; } } /** * Prépare une nouvelle tentative : jeton à renouveler sur un 401, délai d'attente sinon. * * @return float instant avant lequel aucune requête ne doit partir, 0 sans attente */ private function nouvelleTentative(ResponseInterface $reponse, int $statut, string $url, int $generation): float { if (401 === $statut) { $this->logger->info(\sprintf('Pegase, HTTP 401 sur %s : jeton expiré, nouvelle tentative', $url)); // Le jeton a déjà été renouvelé si la requête est partie avec un plus ancien if ($generation === $this->generationJeton) { $this->jeton = null; } return 0.0; } $delai = $this->delaiReprise($reponse, $statut); $this->logger->warning(\sprintf( 'Pegase, %s sur %s : nouvelle tentative dans %.0f s', $this->libelleStatut($statut), $url, $delai, )); return $this->maintenant() + $delai; } private function echec(ResponseInterface $reponse, int $statut, string $url, ?string $erreur): PegaseException { $cause = $erreur ?? mb_substr($reponse->getContent(false), 0, 300); return new PegaseException(\sprintf('Pegase, %s sur %s : %s', $this->libelleStatut($statut), $url, $cause)); } private function requete(string $url, string|int $cle): ResponseInterface { $jeton = $this->jeton(); Loading @@ -256,7 +294,8 @@ final class PegaseClient 'user_data' => [$cle, $url, $this->generationJeton], ]); } catch (ExceptionInterface $e) { throw new PegaseException(\sprintf('Pegase, requête impossible sur %s : %s', $url, $e->getMessage()), 0, $e); $message = \sprintf('Pegase, requête impossible sur %s : %s', $url, $e->getMessage()); throw new PegaseException($message, 0, $e); } } Loading @@ -265,13 +304,21 @@ final class PegaseClient return 0 === $statut || 429 === $statut || $statut >= 500; } private function libelleStatut(int $statut): string { return 0 === $statut ? 'erreur réseau' : 'HTTP '.$statut; } private function delaiReprise(ResponseInterface $reponse, int $statut): float { $apres = $statut ? ($reponse->getHeaders(false)['retry-after'][0] ?? null) : null; $apres = null; if (0 !== $statut) { $apres = $reponse->getHeaders(false)['retry-after'][0] ?? null; } $date = null === $apres ? false : strtotime($apres); $delai = match (true) { null === $apres => self::DELAI_REPRISE, is_numeric($apres) => (float) $apres, false !== ($date = strtotime($apres)) => $date - $this->maintenant(), false !== $date => $date - $this->maintenant(), default => self::DELAI_REPRISE, }; Loading @@ -291,7 +338,8 @@ final class PegaseClient try { return $reponse->toArray(false); } catch (ExceptionInterface $e) { throw new PegaseException(\sprintf('Pegase, réponse illisible sur %s : %s', $url, $e->getMessage()), 0, $e); $message = \sprintf('Pegase, réponse illisible sur %s : %s', $url, $e->getMessage()); throw new PegaseException($message, 0, $e); } } Loading @@ -300,36 +348,46 @@ final class PegaseClient if (null !== $this->jeton) { return $this->jeton; } if ($manquantes = $this->configurationManquante()) { $manquantes = $this->configurationManquante(); if ($manquantes) { throw new PegaseException('Configuration Pegase incomplète : '.implode(', ', $manquantes)); } for ($essai = 1;; ++$essai) { $erreur = null; [$statut, $jeton, $erreur] = $this->demanderJeton(); if (\in_array($statut, [200, 201], true) && '' !== $jeton) { ++$this->generationJeton; return $this->jeton = $jeton; } if ($essai >= self::ESSAIS || !$this->reessayable($statut)) { $cause = $erreur?->getMessage() ?? 'HTTP '.$statut; throw new PegaseException('Authentification Pegase en échec : '.$cause, 0, $erreur); } $this->logger->warning(\sprintf( 'Authentification Pegase, %s : nouvelle tentative dans %.0f s', $this->libelleStatut($statut), self::DELAI_REPRISE, )); $this->horloge->sleep(self::DELAI_REPRISE); } } /** * @return array{0: int, 1: string, 2: ExceptionInterface|null} statut (0 sur erreur réseau), jeton, erreur réseau */ private function demanderJeton(): array { try { $reponse = $this->httpClient->request('POST', $this->jetonUrl, [ 'body' => ['username' => $this->utilisateur, 'password' => $this->motDePasse, 'token' => 'true'], 'headers' => ['Accept' => 'text/plain'], ]); $statut = $reponse->getStatusCode(); $jeton = trim($reponse->getContent(false)); return [$reponse->getStatusCode(), trim($reponse->getContent(false)), null]; } catch (ExceptionInterface $e) { $statut = 0; $jeton = ''; $erreur = $e; } if (\in_array($statut, [200, 201], true) && '' !== $jeton) { break; return [0, '', $e]; } if ($essai >= self::ESSAIS || !$this->reessayable($statut)) { throw new PegaseException('Authentification Pegase en échec : '.($erreur?->getMessage() ?? 'HTTP '.$statut), 0, $erreur); } $this->logger->warning(\sprintf('Authentification Pegase, %s : nouvelle tentative dans %.0f s', $statut ? 'HTTP '.$statut : 'erreur réseau', self::DELAI_REPRISE)); $this->horloge->sleep(self::DELAI_REPRISE); } ++$this->generationJeton; return $this->jeton = $jeton; } /** Loading @@ -337,7 +395,9 @@ final class PegaseClient */ private function urlOdf(string $chemin, array $params = []): string { return $this->url($this->odfUrl, 'etablissement/'.rawurlencode($this->etablissement()).'/'.ltrim($chemin, '/'), $params); $cheminComplet = 'etablissement/'.rawurlencode($this->etablissement()).'/'.ltrim($chemin, '/'); return $this->url($this->odfUrl, $cheminComplet, $params); } /** Loading @@ -350,10 +410,20 @@ final class PegaseClient $requete = []; foreach ($params as $nom => $valeurs) { foreach ((array) $valeurs as $valeur) { $requete[] = rawurlencode($nom).'='.rawurlencode(\is_bool($valeur) ? ($valeur ? 'true' : 'false') : (string) $valeur); $requete[] = rawurlencode($nom).'='.rawurlencode($this->valeurParametre($valeur)); } } $url = rtrim($base, '/').'/'.ltrim($chemin, '/'); return $requete ? $url.'?'.implode('&', $requete) : $url; } private function valeurParametre(string|int|float|bool $valeur): string { if (\is_bool($valeur)) { return $valeur ? 'true' : 'false'; } return rtrim($base, '/').'/'.ltrim($chemin, '/').($requete ? '?'.implode('&', $requete) : ''); return (string) $valeur; } }
api/src/Pegase/PegaseImporter.php +195 −66 File changed.Preview size limit exceeded, changes collapsed. Show changes
api/src/Pegase/ResultatImport.php +9 −0 Changes for api/src/Pegase/ResultatImport.php: 9 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -25,4 +25,13 @@ final readonly class ResultatImport public array $instances, ) { } /** * @return list<Ounit|Programme|Course|CourseInstance> toutes les entités, les entités référencées avant celles * qui les référencent */ public function entites(): array { return [...$this->ounits, ...$this->programmes, ...$this->courses, ...$this->instances]; } }