Commit ea4b75d7 authored by Anthony Gautreau's avatar Anthony Gautreau
Browse files

Import Pegase : refacto

parent 8edf86c3
Loading
Loading
Loading
Loading
+18 −9
Changes for api/src/Command/ImportPegaseCommand.php: 18 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -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(
@@ -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;
@@ -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));
@@ -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);
+32 −6
Changes for api/src/Pegase/Configuration.php: 32 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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> */
@@ -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
     */
@@ -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
@@ -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) {
@@ -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,
                );
            }
        }

+127 −57
Changes for api/src/Pegase/PegaseClient.php: 127 added lines, 57 removed lines.
Original line number Diff line number Diff line
@@ -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;
@@ -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
     *
@@ -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
     *
@@ -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
     *
@@ -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) {
@@ -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;
@@ -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();
@@ -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);
        }
    }

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

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

@@ -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;
    }

    /**
@@ -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);
    }

    /**
@@ -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;
    }
}
+195 −66

File changed.

Preview size limit exceeded, changes collapsed.

+9 −0
Changes for api/src/Pegase/ResultatImport.php: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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