Repository navigation
Conversation
) Premier test local de l'exploration fédérée, sans engager la topologie ni la gouvernance (les deux restent à trancher dans l'épic). - `instance_id` sur `ccd_search.t_recherche_contenu` : toutes les clés de l'application sont des séquences locales, l'instance d'origine est donc la seule chose qui rende `id_objet` non ambigu. Elle entre dans la contrainte d'unicité. - `id_pg` devient nullable + `plan_denorm` : un document reçu d'une autre instance n'a pas de plan dans cette base ; son bandeau d'affichage est emporté avec lui. - `federation.py` : format d'échange versionné, jeton de service partagé (provisoire, cf. #514), et publication en étoile — un portail ne repropage pas ce qu'il a ingéré. - `pull_federation` : ingestion + purge par instance (l'état fait foi, pas l'événement, pour que la dépublication se propage). - `docker-compose.instance.yml` + `docs/MULTI_INSTANCE_LOCAL.md` : deux instances sur une même machine pour rejouer le scénario. 17 tests (`tests/apps/search/test_federation.py`), suite `search` verte (88). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Les sites étaient vidés à l'ingestion faute de clé stable entre instances. `id_inpn` en est une : unique et national, il désigne le même espace protégé partout. Les documents publiés portent donc désormais `site_inpn_codes`, re-résolus en identifiants locaux à la réception — même modèle que les zones ref_geo. Réserve propre aux sites : `id_inpn` est nullable, un site qui n'en porte pas n'est pas publiable. La liste transmise est partielle par construction ; elle dit « ces sites-là », jamais « seulement ceux-là ». La traduction est groupée par page (`codes_de_la_page`) : deux requêtes pour toute la page au lieu de deux par document, l'index ayant vocation à porter ~1,3 M de documents. Seuls les organismes restent vidés, faute d'identité nationale tranchée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nse) (#634) Une action de connaissance se lisait comme un intitulé et une période : ni l'espèce ou l'habitat observé, ni le protocole appliqué, ni ce qui mesure l'effet de l'action n'étaient publiés. La fiche action de l'exploration expose maintenant : - le **suivi** — intitulé, statut, objectifs de la collecte, cible, espèce (TaxRef) et habitats (HabRef), fréquence, outils de saisie et de bancarisation, commentaires ; - ses **protocoles** — nom CAMPanule ou saisie libre, descriptif, objectif, période d'échantillonnage, mois de suivi, documentation, et surtout le respect du protocole avec sa justification : un protocole appliqué avec des écarts ne se compare pas à un protocole appliqué à la lettre ; - ses **indicateurs de réponse**, avec métriques et grille de lecture ; - le détail de mise en œuvre — localisation, fréquence de l'action. Deux règles reprises de l'export de fiche action plutôt que réinventées, pour que les deux surfaces ne divergent pas : `_reponse_indicateurs` (#626) décide quels indicateurs de réponse sont ceux de l'action, et les indicateurs de réponse sont tenus hors du bloc « Cadre ». Le cadre lit désormais aussi les indicateurs atteints par les métriques, et non le seul lien direct — il restait vide sur les actions rattachées par leurs métriques, qui sont le cas courant. Le périmètre exclu ne bouge pas : ni budget, ni RH (`nb_etp_cycle` compris), ni mesures, ni réalisations. Les mnémoniques de nomenclature des suivis sont résolus en une requête pour toute la fiche, et le coût de la fiche reste indépendant du nombre d'actions (test de garde). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Retour de recette : le bouton « retirer d'ici » s'affichait VIDE, sans icône. `fi-rr-unlink` n'existe pas dans le set Uicons Rounded Regular 2.6.0 chargé par `index.html` — c'est le piège déjà documenté dans CLAUDE.md (le nom attendu ne correspond pas toujours à un glyphe réel). Le glyphe correct est `fi-rr-link-slash`, que le projet utilise d'ailleurs déjà ailleurs (`admin-users.component.html`). Trois occurrences corrigées, pas seulement celle vue en recette : le bouton des deux branches d'indicateur (#585) ET celui du résultat attendu, introduit au même endroit par c2477c6 et invisible depuis. Test : la présence du glyphe est désormais épinglée dans le test de rendu du bouton, pour que la régression ressorte au lieu de passer inaperçue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Même cause que la correction précédente, sur une entrée sans rapport : la sidebar d'administration demandait `fi-rr-unlink`, qui n'existe pas dans Uicons Rounded Regular 2.6.0. L'entrée s'affichait donc sans icône. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le jeu d'essai ne contenait un exemple d'élément partagé que pour le facteur d'influence (#552). Les trois relations arbitrées en recette sur #585 n'en avaient aucun : la recette devait donc construire son propre cas à la main avant de pouvoir tester quoi que ce soit. Deux démos ajoutées, sur le modèle de `_seed_shared_facteur_demo` : - un résultat attendu partagé entre deux objectifs opérationnels ; - un indicateur d'état partagé entre deux niveaux d'exigence, et un indicateur de pression partagé entre deux résultats attendus. Chaque branche parcourt les plans en brouillon jusqu'à en trouver un qui a la structure voulue — tous n'ont pas deux niveaux d'exigence ni deux résultats attendus dont le premier porte un indicateur. Les partages ne sont posés que sur des plans en brouillon, seuls plans où ils seraient modifiables (#248). Vérifié sur la base de développement : les deux passes créent bien un partage réel de chaque type (indicateur sous deux parents), et un second passage ne crée rien de plus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renumérote la migration de fédération en `0005_federation_instance_id` : develop a livré `0004_contenuindexe_idx_recherche_ratt_trgm` entre-temps, et il est déjà déployé. La migration multi-instances doit donc passer après, pas se disputer le numéro 0004 (le graphe avait sinon deux feuilles). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Troisième brique du banc d'essai : un back API seul, sans interface, où les instances CICADA déposeront leur index et où la recherche s'exécutera. Il vit dans `hub/` tant que le contrat d'échange bouge — un seul commit fait alors évoluer les deux côtés — et sera extrait quand le format sera figé. Deux tables, et deux seulement. Le hub ne connaît aucun modèle métier de CICADA : - `t_plan_indexe`, une ligne par plan publié. L'index de CICADA transporte le bandeau du plan dans une colonne JSON de *chaque* ligne de contenu ; à ~1,3 M de documents c'est plusieurs centaines de Mo pour une information qui ne varie que par plan, et une mise à jour de libellé proportionnelle au nombre d'objets plutôt qu'au nombre de plans. D'où une table, et une clé étrangère. - `t_recherche_contenu`, une ligne par objet explorable, avec les mêmes vecteurs générés et la même configuration `french_unaccent` que CICADA : les deux index coexistent pendant la transition et doivent répondre pareil au même mot. La fiche complète d'un plan distant est un **instantané JSON publié**, pas un modèle répliqué : les sérialiseurs de fiche produisent déjà un arbre autonome, l'instance l'envoie tel quel et le hub le ressert. Sans ça, servir la fiche imposait de recopier ici la moitié d'`apps.plans` et d'en maintenir deux versions. `instance_id` entre dans toutes les clés d'unicité. Les sites sont stockés en codes INPN plutôt que résolus en identifiants locaux — le hub n'hébergeant aucun site, il n'a rien à quoi les apparier. Les organismes gardent une colonne vide : recopier un identifiant local ferait matcher le mauvais organisme, une corruption silencieuse, là où un tableau vide produit une absence visible. `ref_geo` et les nomenclatures sont importés depuis les **fichiers source de CICADA montés en lecture seule** : les documents voyagent en codes et sont re-résolus ici, deux fichiers divergents produiraient des zones introuvables en silence. L'import échoue franchement si le montage manque, plutôt que de laisser démarrer un hub aux facettes vides. Le compose fige `name: cicada_hub`. Sans ça Compose déduit le nom du dossier, « cicada », et le service `db` de ce fichier détruit puis recrée le conteneur de la base de l'instance principale. 10 tests (`hub/tests/test_socle.py`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Une publication se fait en trois temps — ouvrir un lot, déposer N pages, basculer — parce que « l'état fait foi » et « ne rien détruire en cas de coupure » sont contradictoires si on purge au fil de l'eau. Purger ce qui n'a pas été revu suppose d'avoir tout reçu ; une coupure en milieu d'envoi viderait sinon le hub de ce qui n'était pas encore arrivé. Un lot jamais basculé ne détruit rien, il expire. L'unité d'échange est le **plan entier**, pas le document : un plan arrive avec la liste complète de ses objets et remplace intégralement ce que le hub savait de lui. L'ingestion est donc idempotente sans comparaison objet par objet — un enjeu supprimé chez l'émetteur disparaît parce qu'il n'est plus dans la liste, pas parce qu'un message l'a annoncé. L'instance émettrice est déduite du **jeton**, jamais du corps de la requête, et la purge est bornée à l'instance du lot. Sans ces deux bornes, un jeton valide suffirait à ouvrir un lot au nom d'une autre instance puis à le basculer — c'est-à-dire à purger l'index de quelqu'un d'autre. Un jeton par instance, révocable individuellement, plutôt qu'un secret unique partagé. La fiche n'est pas validée dans le détail : la valider reviendrait à recopier ici le schéma de la fiche de CICADA et à devoir le suivre à chaque évolution, exactement ce que l'instantané JSON permet d'éviter. La validation est en revanche stricte sur ce qui structure l'index — un libellé absent fait une tuile vide, un `id_pg` absent fait un plan que rien ne retrouvera jamais. Les tests ont sorti une asymétrie qui n'était pas dans le cadrage : l'unicité du contenu est **globale à l'instance**, un identifiant d'enjeu étant une séquence globale chez l'émetteur et non une numérotation par plan. Vider le contenu du plan courant ne suffit donc pas si un objet est arrivé sous un autre plan — l'insertion violerait la contrainte et ferait échouer la page entière. Ce n'est pas censé arriver, raison de plus pour ne pas le laisser bloquer une publication. 34 tests (`hub/tests/`), et un dépôt de bout en bout vérifié contre le hub réel : « DEP:13 » et « REG:93 » résolus en identifiants locaux, fiche stockée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C'est ici que le choix d'architecture se concrétise : la recherche s'exécute en un seul passage sur l'index agrégé. Le tri par pertinence est donc transverse aux instances et les compteurs d'onglets exacts — deux choses qu'une fédération qui interrogerait chaque instance puis fusionnerait les réponses ne peut pas garantir, faute de scores comparables entre index. La forme des réponses est celle de CICADA, à l'octet près : pendant la transition une même page d'interface peut être servie par l'index local ou par le hub, et le frontend ne doit pas avoir à distinguer les deux. Deux ajouts seulement — chaque résultat porte son instance d'origine, et une facette `instances` permet de s'y restreindre. CICADA n'en a pas l'usage, un index local n'ayant qu'une seule provenance. Les deux modes de recherche partagent désormais leurs facettes. Côté CICADA le mode « plan » attaque `PlanGestion` par jointures pendant que le mode « contenu » attaque l'index ; ici les plans sont eux-mêmes une table d'index, aux mêmes colonnes. Cela supprime la classe de bugs où une facette filtrait différemment selon le mode. La fiche d'un plan distant est resservie telle que publiée, avec sa date : un instantané sans date ne se distingue pas d'une donnée jointe à la volée, or c'est précisément la différence qu'il faut pouvoir voir. Un plan se désigne par `instance:slug`. Ni l'identifiant ni le slug ne sont uniques entre déploiements ; l'identifiant interne du hub le serait mais n'est pas durable — un plan dépublié puis republié en obtiendrait un nouveau, cassant les liens. Les noms de sites sont dénormalisés dans une colonne texte indexée en trigramme plutôt que cherchés dans le tableau JSON : chercher dans du JSON supposerait de le convertir à chaque requête, sans pouvoir l'indexer. Le filtre par organisme est écrit alors qu'il ne rend rien — la colonne reste vide faute d'identité nationale tranchée. C'est délibéré : le jour où la décision tombe, seule l'ingestion sera à toucher, et un test fige le comportement actuel pour qu'il se lise comme un choix et non comme un oubli. 55 tests (`hub/tests/`), et vérifié contre le hub réel : recherche « foret » qui trouve « Forêt », compteurs, fiche distante, et recherche par nom de zone via le référentiel du hub. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Referme la boucle : CICADA publie vers le hub et peut lui déléguer sa recherche. Le sens s'inverse — c'est l'instance qui va vers le hub, alors que le hub venait l'interroger. Ce n'est pas un détail de plomberie : une instance derrière un pare-feu ou sans adresse publique peut publier, alors qu'elle ne peut pas être interrogée. Un plan part avec tout ce qui le concerne, fiche rendue comprise. Le hub la range sans l'inspecter, ce qui lui évite de connaître les enjeux, les objectifs et les actions — donc de suivre le modèle métier de CICADA à chaque évolution. Une fiche mobilisant plusieurs centaines d'objets, les pages font 10 plans et non 500 comme pour un index nu. En cas d'échec le lot est abandonné, jamais basculé : un état partiel basculé dépublierait du contenu valide, là où un lot abandonné laisse en place la publication précédente, un peu vieille mais complète. Entre « incomplet » et « périmé », c'est périmé qui est récupérable. Le relais passe par le backend et non par le navigateur : le jeton reste côté serveur, il n'y a ni CORS ni second domaine à déclarer, et la bascule est invisible pour le frontend — même URL, même forme de réponse, donc retour arrière par simple réglage. Il n'y a délibérément **pas de repli** sur l'index local : servir les résultats de cette seule instance sous une interface qui promet une recherche transverse ferait conclure à l'utilisateur que les plans des autres organismes n'existent pas. Deux défauts trouvés en publiant pour de vrai, tous deux silencieux : - `CICADA_INSTANCE_ID` était lu avec un défaut de `get()`, qui ne s'applique pas à une variable *définie mais vide* — cas courant d'un .env où la ligne existe sans valeur. L'identité vide s'écrivait dans chaque ligne d'index, où plus rien ne la retrouvait : la publication déposait 22 plans et 0 document. - Les facettes étaient lues sur les lignes d'index. Un plan validé mais vide n'en a aucune : il serait parti sans zones ni types de site, disparaissant de tous les filtres alors qu'il doit rester trouvable en mode « plan de gestion ». Elles viennent maintenant de `facettes_du_plan`, la fonction qui alimente déjà l'indexation — les deux chemins ne peuvent plus diverger. La commande refuse désormais de publier quand l'index ne contient aucun document pour l'instance courante, en nommant l'identité trouvée. Sans ce garde-fou, le dépôt « réussit » en publiant des plans que l'exploration affiche mais qu'aucune recherche de contenu ne trouve. `prefetch_sites` remonte de `views` dans `serializers` : la publication en a le même besoin, et l'oublier ne casse rien visiblement — la liste des sites ressort simplement vide. 140 tests (`tests/apps/search/`), et vérifié en réel contre le hub : 22 plans et 96 documents publiés, zones et codes INPN résolus sur 22/22, puis exploration relayée (recherche, mode plan, fiche par slug nu complété en `rnf:slug`) et 502 franc quand le hub est coupé. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le document décrivait encore l'ancienne topologie : un portail qui était une instance CICADA et qui venait *tirer* l'index des autres. Le hub n'est plus une instance et le sens s'est inversé. Le scénario de bout en bout a été déroulé pour de bon, et le document rapporte ce qui a été observé plutôt que ce qui devrait l'être : - RNF publie 22 plans / 96 documents, le CEN autant ; - l'index agrégé porte 44 plans et 192 documents, compteurs d'onglets doublés ; - un enjeu ajouté côté CEN remonte à la republication, attribué à `cen` ; - le plan repassé en brouillon disparaît — `1 plan dépublié`, fiche en 404 ; - **le plan de même slug chez RNF reste intact.** C'est l'invariant à ne jamais casser : une instance ne dépublie qu'elle-même. Le banc démontre au passage ce qu'aucun test unitaire ne rend aussi tangible : seedées des mêmes fixtures, les deux instances produisent des slugs identiques pour des plans différents (`bac-a-sable-plan-initial-2010-2020` → cen id_pg=30, rnf id_pg=1864). Sans `instance_id` dans les clés, ingérer le second écraserait le premier, en silence. Deux pièges rencontrés au montage, désormais signalés dans le document : - `docker-compose.hub.yml` doit figer `name: cicada_hub`. Sans nom de projet, Compose le déduit du dossier — « cicada » — et le service `db` du hub détruit puis recrée le conteneur de la base de l'instance principale. - une base où l'ancien `0004_federation_instance_id` a déjà été appliqué demande le renommage de l'enregistrement en `0005` (rencontré sur l'instance CEN comme sur la principale). Le port externe du hub passe de 5434 à 5435 : le CEN occupait déjà 5434. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trois briques, trois invocations Docker Compose différentes, dont deux qu'on ne retape pas juste. `scripts/federation.sh` les rassemble derrière des sous-commandes, et encode surtout les pièges qui ont coûté cher au montage : - le hub DOIT être lancé avec son nom de projet, sinon Compose le déduit du dossier — « cicada » — et le service `db` du hub détruit puis recrée le conteneur de la base de l'instance principale ; - une base ayant vu la branche avant la renumérotation porte `0004_federation_instance_id` et bloque au démarrage sur « column instance_id already exists » : `up` réaligne l'enregistrement avant de lancer le backend, au lieu de laisser un conteneur en boucle de redémarrage ; - changer l'identité d'une instance périme tout son index, et la publication échoue alors — franchement, mais après coup : `reindex` fait le `--purge`. `status` répond à la question qu'on se pose vraiment en débogage : qui tourne, sous quelle identité, en mode local ou relayé, et que contient le hub. `check` va plus loin en vérifiant que la recherche est effectivement transverse, c'est-à- dire que plusieurs instances ressortent d'une même requête — un index peuplé par une seule instance est un banc d'essai qui ne teste rien. `down all` épargne volontairement RNF : c'est le stack de travail, pas une brique jetable du banc. Vérifié : `status`, `check`, `push` (avec et sans cible, options transmises), `mode local|hub` aller-retour, et `test` — 55 tests hub + 140 CICADA. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le script était référencé par la documentation mais tombait sous la règle « scripts/ », qui exclut les utilitaires locaux. Il n'est pas local : c'est le point d'entrée du banc d'essai à trois instances, et il encode les pièges de montage qu'on ne veut pas rediagnostiquer. La règle passe de `scripts/` à `scripts/*` pour que les exceptions prennent effet : Git ne sait pas ré-inclure un fichier dont le dossier parent est exclu, un simple `!scripts/federation.sh` sous `scripts/` serait resté sans effet. `sync-version.sh`, déjà suivi, est explicité au passage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le chantier vit dans trois documents (docs/MULTI_INSTANCE_LOCAL.md, hub/README.md et le code) mais rien n'y menait depuis CLAUDE.md, qui est le point d'entrée. Une session future serait repartie de zéro sur des décisions déjà prises — et sur des pièges déjà payés. La section couvre ce qui ne se déduit pas du code : - le sens des flux et pourquoi il est ce qu'il est (une instance derrière un pare-feu peut publier, pas être interrogée ; le navigateur ne parle jamais au hub) ; - le blocage central — rien n'est identifiant entre instances — et les trois traitements qui en découlent, dont la colonne organismes délibérément vide ; - pourquoi la fiche est un instantané publié et non un modèle répliqué, et pourquoi le plan est une table et non une colonne JSON ; - l'état plutôt que l'événement, le lot en trois temps, et les deux bornes de sécurité (instance déduite du jeton, purge bornée à l'instance) ; - l'absence délibérée de repli sur l'index local quand le hub tombe. Et surtout les trois pièges qui coûtent une demi-journée à rediagnostiquer : le nom de projet Compose du hub, le renommage de la migration 0004 → 0005, et la péremption de l'index après changement d'identité. Le tableau de couverture des tests intègre les 55 tests du hub, dont la suite se lance à part puisque c'est un projet distinct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…636) `federation.sh up --open`, ou `open` seul, lance les deux interfaces dans deux fenêtres **distinctes** plutôt que deux onglets : l'intérêt du banc est de comparer les deux instances côte à côte — l'une sur son index local, l'autre relayée vers le hub — et un onglet qu'on quitte ne se compare à rien. Les deux pages s'ouvrent directement sur `/exploration`, le seul écran où la fédération se voit. Le navigateur est détecté parmi google-chrome, chromium et xdg-open ; à défaut les URL sont affichées. Une interface qui ne répond pas est signalée et non ouverte, plutôt que de donner un onglet en erreur qu'on prendrait pour un bug applicatif. Documenté au passage : les sessions des deux instances ne se marchent pas dessus, les ports différents faisant des origines différentes donc des `localStorage` séparés. On peut être connecté aux deux à la fois, sous des comptes différents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#636) Une seule invocation du navigateur avec les trois URL : Chrome ouvre alors une fenêtre et y place un onglet par URL. Les lancer une par une, comme le faisait la version précédente, donnait autant de fenêtres. Le hub gagne son onglet, sur la sonde de disponibilité — la seule route qu'un navigateur puisse atteindre, les autres exigeant l'en-tête `X-Hub-Token` qu'une barre d'adresse ne sait pas poser. Il répond d'un coup d'œil à « le hub est-il debout ? », qui est la première question quand l'exploration d'une instance relayée renvoie une erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aucun des 195 tests unitaires n'a vu les bugs de cette fédération. Tous étaient
des bugs de **couture** — entre les deux projets, ou entre le code et le
déploiement :
identité d'instance vide → 22 plans publiés, 0 document
facettes lues sur l'index → un plan sans contenu part sans facettes
deux migrations 0004 → conteneur en boucle de redémarrage
nom de projet Compose → base de l'instance principale détruite
Chaque projet avait ses tests, écrits contre sa propre lecture du contrat.
Personne ne les avait fait se parler. D'où trois familles :
**Contrat** — une charge utile réellement produite par CICADA est soumise au
sérialiseur du hub, sans rien écrire. Un champ renommé d'un côté casse un test
au lieu de casser une publication.
**Scénarios** — aller-retour, isolation entre instances, dépublication,
idempotence, garde-fou d'identité, jeton invalide. C'est ce que j'avais déroulé
à la main ; ce n'est plus à redérouler. L'idempotence est ce qui rendra une
publication périodique sans danger : un second passage qui retirerait ou
dupliquerait quoi que ce soit dégraderait l'index à chaque exécution.
**Parité** — le cas le plus important. `filters.py` existe en deux exemplaires,
un par projet : 14 requêtes couvrant les facettes, les deux modes de recherche
et les tris doivent rendre le même résultat servies localement ou par le hub.
Sans ce test, les deux implémentations divergeraient en silence, et une
divergence de filtrage se constate sans pouvoir se décrire.
Vérifié par mutation plutôt que par confiance : neutraliser le filtre « statuts »
du hub fait échouer la parité en nommant les deux requêtes divergentes
(archive : 0 en local, 96 au hub). Un test qui passe du premier coup mérite
qu'on s'assure qu'il n'est pas creux.
Le banc ne suppose pas les instances configurées pour publier : il lit les
jetons dans `.env.hub` et les passe à chaque appel. Sans ça, les tests
échoueraient sur un `.env` incomplet plutôt que sur un vrai défaut — et la
première question serait « le test est-il cassé ? ». C'est d'ailleurs ce que le
premier passage a révélé : l'instance RNF n'était pas configurée.
Le cas « dépublication » repasse un plan en brouillon puis le restaure, y compris
si l'assertion échoue : le banc doit ressortir comme il est entré.
Hors CI (trois stacks Docker) et sans pytest : l'hôte n'a que `requests`, et
déclencher une publication demande un `docker exec` qu'un conteneur ne peut pas
faire.
11/11 cas passés.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…és (#636) Playwright sur l'instance **relayée** vers le hub — le seul niveau qui couvre ce que l'utilisateur voit. Les suites unitaires et le banc s'arrêtent à l'API ; ces deux défauts naissent de la construction du lien et de la forme de la réponse, côté navigateur, et aucun test d'API ne pouvait les voir. **Le lien de fiche perdait l'instance d'origine.** Les tuiles liaient par `plan.slug`. Or les deux instances du banc sont seedées des mêmes fixtures et ont des slugs identiques pour des plans différents : cliquer un résultat distant demandait `<instance-locale>:<slug>` et ouvrait donc l'homonyme local — ou rien. Une réponse fausse et silencieuse, la pire espèce. D'où `referencePlan()`, qui utilise la `reference` fédérée (« rnf:camargue ») quand elle existe et retombe sur le slug sinon : hors fédération, rien ne change. **Le hub enveloppait la fiche** dans `{fiche: …}` là où une instance la sert à plat. Le frontend recevait une enveloppe qu'il ne sait pas lire et affichait une page au titre vide. C'est la promesse « même forme de réponse, donc bascule invisible pour le frontend » qui était fausse. Le hub sert désormais la fiche à plat, métadonnées de fédération à côté. Le test de banc **masquait** ce second défaut : il déballait lui-même `['fiche']` au lieu de comparer les deux formes. Un test qui s'adapte à ce qu'il mesure ne mesure plus rien ; il compare maintenant à plat des deux côtés. Les tests E2E de fédération sont opt-in (`E2E_FEDERATION=1`) : ils demandent le banc à trois briques, que ni la CI ni un lancement ordinaire de la suite n'ont. Ils visent une autre origine, d'où une session dédiée — un `storageState` est lié à son origine, celle de l'instance principale n'y vaut rien. Ils s'exécutent dans le conteneur frontend du CEN, avec le Chromium système : l'image est Alpine, et celui fourni par Playwright est compilé pour glibc. Le script l'installe s'il manque, plutôt que d'échouer sur « executable doesn't exist ». Deux erreurs de ma part, corrigées en route : les recherches d'origine portaient sur des noms de plans (« camargue », « bac à sable ») là où la recherche par contenu attend des libellés d'enjeux, et l'attente de la réponse de fiche était posée après le clic — `waitForResponse` ne voit que ce qui arrive après son appel. 6/6 E2E, 55 hub, 11/11 banc, 13 unitaires frontend. Mode local vérifié inchangé. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le vrai sujet de #636 : qui consent à publier, et à quelle condition on lit. **Ce que le partage fait sortir n'était pas à construire.** `serializers_fiche` documentait déjà une liste d'exclusions exhaustive — budget et financement, ressources humaines, mesures et réalisations, traçabilité interne — verrouillée par `TestFichePubliqueCloisonnement`. Ce qui manquait, c'était le **choix** et sa contrepartie. **Le choix** vit dans `SiteConfiguration.federation_partage`, faux par défaut. Publier le contenu de ses plans est un engagement de la structure : une montée de version ne doit jamais le prendre à sa place, et une instance qui met à jour CICADA continue donc de ne rien publier tant que personne n'a décidé. Il se change depuis l'interface, sans redéploiement — revenir sur un engagement ne doit pas dépendre de qui tient les serveurs. **La réciprocité est appliquée par le hub**, et c'est le point qui compte. Une instance peut couper son propre relais, mais quiconque l'administre peut aussi le rallumer : une réciprocité qui ne tient qu'à la bonne volonté du lecteur n'est pas une règle, c'est un souhait. Le jeton de lecture devient donc propre à chaque instance — un secret partagé authentifie mais n'identifie pas — et le hub refuse de servir qui n'a rien déposé. Effet de bord assumé : une instance sans aucun plan validé ne peut pas lire, faute d'avoir de quoi verser. **Le retrait a sa propre commande.** `push_federation` refuse de déposer un lot vide, parce qu'un index momentanément vide — identité mal configurée, base restaurée — effacerait sinon tout le travail de la structure. Mais retirer ses données est un droit. La distinction n'est donc pas entre autorisé et interdit, mais entre **accidentel** et **voulu** : deux commandes. **Le message**, à trois endroits. La page d'exploration annonce sa portée avant même la recherche : sans ça, une exploration limitée à son organisme se lit comme une panne, et l'utilisateur conclut que les autres structures n'ont pas de plans. Les paramètres montrent deux listes — ce qui sort, ce qui ne sort jamais — parce que la question que se pose une structure est « qu'est-ce qui part de chez moi ? », et qu'une liste y répond plus vite qu'un paragraphe. Vérifié de bout en bout : partage coupé sur le CEN, l'exploration continue de répondre mais sur ses seuls plans (4 résultats au lieu de 8) ; partage rétabli, les deux instances reviennent. 59 hub, 231 CICADA, 13/13 banc (dont deux nouveaux cas consentement et réciprocité), 6/6 E2E, 59 frontend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#651, #652) Trois retours de recette, dont un dont la cause n'était pas celle qu'on croyait. **#649 — le survol annonçait un autre type.** La tuile porte un liseré gauche qui identifie le type du résultat, et le survol posait `border-color`, donc *toutes* les bordures. La couleur employée, `$secondary-orange-salmon`, est précisément celle des pressions : survoler un enjeu l'annonçait comme une pression. Le survol se signale désormais par l'ombre et un fond léger, deux variations qui ne portent aucune signification ailleurs. **#651 — ce n'était pas les rattachements, c'était la tolérance aux fautes.** L'hypothèse évidente — les objets rattachés sont dans le vecteur « titres » (#634) — était fausse, et la mesure l'a corrigée : `word_similarity('fleur', titre)` vaut **0,667** pour un seuil à 0,6, parce que « fleur » ressemble assez au mot « **leur** ». D'où « Préservation des pelouses alpines et de **leur** faune associée » remonté sur « fleur », en mode titres uniquement. Relever le seuil ne pouvait pas marcher : une vraie faute score à peine plus haut (« eutotrophes » / « eutrophes » = 0,69) et serait tombée avec le bruit. Ce qui sépare les deux cas n'est pas le score mais le **contexte** — on ne cherche un mot approchant que faute d'avoir trouvé le mot. Le trigramme redevient donc ce qu'il aurait dû rester : un repli, qui ne s'en mêle pas tant que le plein texte répond. Le repli se décide **avant** les facettes : si le mot correspond mais qu'un filtre écarte tout, la bonne réponse est « aucun résultat », pas une liste de termes approchants que personne n'a demandés. **#650, première moitié.** La réponse porte désormais `approximatif`, et l'interface l'annonce : « aucun résultat ne correspond exactement à “fleur” ». Un résultat approchant qui ne se présente pas comme tel laisse croire qu'on a trouvé ce qu'on cherchait — c'est exactement ainsi que #651 a été rapportée. **#652 — deux causes cumulées.** Le champ de recherche du filtre était le premier enfant d'un panneau qui défile : il sortait de vue dès qu'on faisait défiler, c'est-à-dire exactement quand il sert. Il est maintenant collant en haut de la zone. Et le panneau, quand il bascule au-dessus du déclencheur, glissait sous le bandeau collant : `viewportMargin` lui réserve la place. Le correctif touche `filter-option-list`, partagé — les autres filtres en profitent. Le correctif de #651 est porté dans les **deux** copies de `filters.py` ; la parité du banc l'a vérifié (14 requêtes identiques des deux côtés). 156 CICADA, 61 hub, 13/13 banc, 6/6 E2E, 85 frontend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#650) « Pour "fleur" on ne sait pas si c'est lié au mot, ou bien si une des espèces est une fleur. » Le problème est propre à cet index : les objets rattachés — espèces, habitats, protocoles standardisés — sont **interrogés mais jamais affichés**. Une tuile dont le titre n'a aucun rapport visible avec la requête paraît donc arbitraire, alors qu'elle est parfaitement pertinente. Chaque résultat porte désormais les champs qui ont répondu, et l'extrait de l'objet rattaché quand c'est lui. Le cas exemplaire, relevé sur le jeu de données : Bruit des activités nautiques correspondances : ['rattachements'] extrait : « milieu Phoenicopterus roseus Flamant rose » Un titre sans rapport apparent, désormais expliqué. La ligne n'apparaît que lorsque le titre n'a PAS répondu : quand il a répondu, le surlignage le montre déjà, et répéter l'évidence noierait le seul cas qui demande une explication. L'extrait vient de `ts_headline` : le champ est un bloc de texte sans séparateur, et rien d'autre ne sait y isoler le passage utile. Il voyage **sans balisage** — le surlignage se fait côté interface, par segments de texte, pour ne pas faire transiter du HTML depuis la base. Le surlignage ignore les accents et retient les **débuts de mot**. Les deux suivent la recherche : le plein texte est désaccentué et radicalisé, si bien qu'exiger une égalité exacte ne surlignerait presque jamais rien — l'utilisateur verrait les résultats sans savoir quel mot a répondu, c'est-à-dire le problème qu'on prétend résoudre. À l'inverse, refuser les milieux de mot évite d'allumer « leur » dans « valeur ». L'annotation est posée **après** le calcul des compteurs d'onglets : elle entrerait sinon dans leur `GROUP BY` et fausserait les totaux affichés au-dessus de la liste. Deux détails d'implémentation qui n'en sont pas : - la normalisation des accents se fait par unité UTF-16 et non par point de code, sinon les indices se décalent et le découpage tombe au milieu d'une paire de substitution ; - le mot surligné est celui des **critères**, pas du champ de saisie : le champ peut avoir été modifié sans que la recherche ait été relancée. Porté dans les deux copies de `filters.py` ; la parité du banc l'a vérifié. 156 CICADA, 61 hub, 13/13 banc, 6/6 E2E, 139 frontend (dont 8 nouveaux cas de surlignage). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le retour de recette #652 — « la recherche des organismes gestionnaires n'est pas visible car trop d'OG » — était **intestable avec le jeu d'essai** : cinq organismes tiennent à l'écran, le panneau ne défile pas, et le champ de recherche ne peut donc pas sortir de vue. On corrigeait un défaut qu'on ne pouvait pas reproduire. Le seeder pose désormais les vingt CEN du réseau, sans coordonnées véritables. C'est le volume que rencontrera une instance nationale, et c'est celui qu'il faut pouvoir mettre sous les yeux d'un testeur : 26 organismes au total, le panneau défile, le champ collant se vérifie. Leur type est déduit du préfixe « CEN » plutôt qu'énuméré dans `TYPE_ORGANISME_MAP` : vingt lignes de mapping pour une règle que le nom porte déjà seraient vingt occasions d'oublier la vingt-et-unième. Aucun test n'assortit le nombre d'organismes d'une valeur exacte (les E2E vérifient `> 0`) : l'extension ne casse rien. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ajoute le hub d'exploration — projet Django distinct, back API seul, dans `hub/` — qui agrège l'index de plusieurs instances CICADA et sert la recherche transverse. - publication en trois temps (lot → pages → bascule), l'état faisant foi, avec purge bornée à l'instance émettrice et identité déduite du jeton ; - consentement au partage par instance, faux par défaut, et réciprocité appliquée par le hub : on ne lit l'exploration nationale qu'en y versant ; - relais de l'exploration derrière un réglage, sans repli silencieux sur l'index local si le hub est injoignable ; - banc d'essai à trois briques piloté par `scripts/federation.sh`. Inclut les retours de recette #649, #650, #651 et #652 sur l'exploration, et l'extension du jeu d'essai qui rend #652 reproductible. Suites vertes : 2551 backend, 2557 frontend, 61 hub, 13/13 banc, 6/6 E2E.
#653) À l'acceptation d'un compte, le super admin devait décider seul s'il fallait promouvoir le nouvel utilisateur en administrateur d'organisme, sans jamais savoir si cet organisme en avait déjà un. La demande de validation expose désormais ce contexte (`organisme_admins`) et la fiche de détail l'affiche : liste des administrateurs existants, ou encart d'alerte quand l'organisme n'en a aucun / vient d'être créé. Le second point de l'issue — ne notifier le super admin qu'à défaut d'admin_og — était déjà le comportement de `_get_registration_validators` : deux tests le verrouillent désormais. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un super administrateur est le validateur final d'un changement de rôle admin_og : lui faire déposer une demande qu'il ira ensuite approuver lui-même est un aller-retour sans destinataire. `request_admin_promotion` et `request_admin_demotion` appliquent donc le changement immédiatement quand le demandeur est super admin, et renvoient `applied: true`. La trace n'est pas perdue : le changement crée une `ValidationRequest` déjà approuvée, au même endroit que les demandes ordinaires. Le motif devient facultatif dans ce cas — il n'y a plus personne à convaincre. Le parcours d'un admin_og (demande soumise au super admin) est inchangé. Côté interface, le modal et l'infobulle basculent sur un vocabulaire d'action (« Promouvoir ») au lieu d'un vocabulaire de demande, et la liste des utilisateurs se recharge pour que le rôle affiché suive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Un admin d'organisme voit déjà tous les plans des sites de son organisme (`plan_scope_q`), mais la liste des plans lui proposait quand même de « demander l'accès » à ces mêmes plans : une demande sans destinataire, pour un accès déjà acquis, et le plan apparaissait deux fois à l'écran. `user_manages_plan()` (apps/plans/access.py) nomme ce périmètre — super admin, rédacteur principal, admin d'un organisme gestionnaire ou rédacteur du plan — et `request_plan_access` le refuse désormais. Il reste volontairement distinct de `user_can_access_plan`, vrai pour tout compte simplement lié au plan : un utilisateur rattaché à un site garde le droit de demander à être rattaché au plan. Côté interface, la section « Demander l'accès à d'autres plans » disparaît pour ces mêmes comptes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…658) L'endpoint /api/validations/ est servi par `UsersPagination` (classe de pagination par défaut du projet), qui renvoie le total dans `pagination.count` et non à la racine. Le service typait la réponse à la forme plate DRF : `response.count` valait `undefined`, le `mat-paginator` recevait une longueur nulle et le bouton « page suivante » restait désactivé alors que l'API exposait bien 2 pages. `getValidationRequests()` normalise désormais les deux formes de réponse (plate et imbriquée) vers `PaginatedResponse`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le composant partagé `app-pagination` (Utilisateurs, Sites, Plans, Organismes, suivi des actions) n'avait plus aucun style : sa feuille vivait dans `_filters.scss`, supprimé en #592. Les boutons étaient donc rendus par défaut du navigateur — fond gris #EFEFEF, bordure `outset` noire de 2px, police système 13px — d'où des icônes très laides à côté du `mat-paginator` de la page des validations. Le style vit désormais avec le composant, conforme à la spécification « Pagination » du design system : page active bleu-vert texte blanc, pages inactives blanches bordées, chevrons primary sans bordure, « 1-20 sur N » en gris. Ajouts au passage : - libellés a11y (`aria-label` traduits, `aria-current="page"`) ; - l'ellipsis devient un `<span>` non cliquable ; - `track $index` : les deux ellipses partageaient la clé -1, ce qui levait une erreur de clés dupliquées dès qu'un jeu de pages en affichait deux ; - suppression des règles `.pagination` d'`admin-users.component.scss`, inopérantes (encapsulation de vue : le markup est dans l'enfant). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on d'un compte (#656) L'identifiant servant à se connecter (en plus de l'email) était facultatif : un compte pouvait donc exister sans, et n'être joignable que par son email. - Inscription publique : champ requis côté formulaire (étoile, message d'erreur) et côté `PublicRegistrationSerializer` (blanc ou espaces refusés). - Création par l'API (`RoleCreateSerializer`) et par le formulaire Django admin : identifiant requis, unicité insensible à la casse contrôlée aussi contre les demandes d'inscription en attente, qui deviendront des comptes. - Modification (`RoleUpdateSerializer`) : un identifiant transmis ne peut plus être vidé ni dupliquer celui d'un autre compte. Les comptes existants sans identifiant restent inchangés : la page Profil conserve son ajout facultatif, aucune migration ni blocage à la connexion.
La version de l'instance est désormais visible en pied de la sidebar d'administration, depuis n'importe quelle page et pour tous les profils ayant accès à l'administration (référent, admin organisme, super admin). La page « Mise à jour » existait déjà mais n'était liée nulle part : elle gagne son entrée de menu (super admin, comme sa route). Deux défauts découverts au passage, corrigés : - la version affichée était « 0.0.0 » en production. Le contexte de build de l'image backend est ./backend, or version.txt vit à la racine du dépôt : il n'entrait jamais dans l'image. Elle est maintenant injectée au build (ARG CICADA_VERSION → ENV CICADA_APP_VERSION, alimenté par le tag dans le workflow), la variable primant sur le fichier ; - « 0.0.0 » également en dev, version.txt n'étant pas monté. Le montage vise la racine du conteneur et non /app, qui est le bind de ./backend : y créer le point de montage déposerait un fichier vide dans le dépôt. L'endpoint /api/system/version/ reste réservé au super admin — il porte aussi l'état de mise à jour et son déclenchement. Le pied de sidebar est alimenté par /api/system/app-version/, ouvert à tout compte authentifié et limité à la version. Version non chargée → rien n'est affiché, plutôt qu'un numéro faux. Tests : 18 backend (endpoint + résolution de version), 5 Jest sur le layout admin (affichage, échec API, visibilité de l'entrée de menu). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DOCenCEN, la gestion documentaire du CEN, doit rattacher ses documents aux plans rédigés dans CICADA. Il lui faut deux choses : un identifiant de plan qui ne bouge pas, et un endroit où venir chercher régulièrement ce qu'elle n'a pas encore. Identifiant — ni `id_pg` ni `slug` ne conviennent : le premier est une séquence tirée localement (deux instances produisent couramment le même numéro), le second suit le nom et change au renommage. D'où `uuid_plan`, immuable, et une référence « cicada:<instance>:<uuid> » : le préfixe dit de quel outil vient la clé, l'instance dit de quelle base. La migration tire un UUID par plan existant en trois temps — `default=uuid.uuid4` n'est évalué qu'une fois par un `AddField`, toutes les lignes recevraient la même valeur. `build_version_plan` retire le sien : `_dup` recopie tous les champs concrets, et sans ce tirage une nouvelle version écraserait la précédente côté GED. API — servie par l'instance et non par le hub (#636), qui ne stocke ni rédacteurs, ni dates de validation, ni identifiant Doc'Gestion : les y mettre élargirait le contrat de publication pour un besoin qui est local. Ouverte, sans jeton, conformément au besoin exprimé, mais sous `SiteConfiguration.api_publique_plans` coupé par défaut — ouvrir un endpoint sans authentification est une décision de la structure, pas l'effet de bord d'une mise à jour. Coupée, tout répond 404. Périmètre : métadonnées seules, et jamais un brouillon. Une GED documente des documents, elle n'a pas besoin de leur substance ; et un brouillon est un plan dont le nom et la période changent encore. `?statut=draft` est refusé par un 400 plutôt qu'ignoré en silence. La liste est ordonnée par date de modification **croissante**, avec un filtre `modifie_depuis`. Un rattrapage pagine pendant que la base vit : en ordre croissant un plan modifié en route part vers la fin et sera vu deux fois, ce qu'un upsert absorbe ; en ordre décroissant il remonterait avant le curseur et serait purement manqué. Backend : `views_public.py`, `serializers_public.py`, `urls_public.py`, montés sur /api/public/. Frontend : section dédiée dans /administration/parametres. Doc : docs/API_PUBLIQUE_PLANS.md. Tests : 27 cas d'intégration (interrupteur, identifiant, cloisonnement, filtres, lecture seule). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…férents (#647) L'import en masse rattachait tous les sites au seul organisme de l'importateur, et faisait de lui l'unique référent : sur un import de plusieurs centaines de sites, il fallait corriger en SQL après coup. Deux colonnes facultatives sont désormais reconnues dans le fichier (GeoJSON ou CSV), auto-détectées comme les autres : - « organisme » (nom, id ou uuid ; plusieurs valeurs séparées par « ; », la première devient le gestionnaire principal) - « référent » (email ou identifiant de connexion) Résolution insensible à la casse et aux accents, en deux requêtes pour tout le lot (jamais une par ligne). Une valeur introuvable, ambiguë, désactivée ou hors périmètre ne bloque pas l'import : elle est signalée en avertissement à l'étape de vérification et la ligne retombe sur l'ancien comportement (organisme de l'importateur, importateur référent). Un admin d'organisme ne peut rattacher que ses propres utilisateurs. Les valeurs sont re-résolues côté serveur au moment de l'import : la réponse de validation transite par le navigateur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le hub ne pouvait démarrer que sur un poste de développement : son compose monte le code depuis le dépôt et, surtout, monte les référentiels nationaux depuis `backend/` — répertoire qui n'existe pas sur le serveur du hub. L'image de production les embarque donc (840 Ko), ce qui impose un contexte de build à la racine du dépôt, d'où le `.dockerignore` qui ramène ce contexte de tout le dépôt à 1,2 Mo. Ce sont les mêmes fichiers source des deux côtés : les documents publiés voyagent en codes (INSEE, mnémoniques), deux jeux divergents produiraient des zones introuvables en silence. Réglages durcis, un seul module servant au développement et à la production : - `DEBUG` par défaut à False, et refus de démarrer si la clé de développement survit (la réserve sur pytest évite que le garde-fou casse la suite au lieu de protéger la production) ; - `SECURE_PROXY_SSL_HEADER`, le hub étant servi par Apache ; - `DATA_UPLOAD_MAX_MEMORY_SIZE` à 100 Mo : Django refusait tout corps de requête au-delà de 2,5 Mo, y compris en JSON. Une page de lot porte les fiches rendues de plusieurs plans et dépasse ce seuil — c'était un 400 garanti à la première publication réelle, qu'un banc d'essai à trois plans de test ne peut pas atteindre ; - logs fichier avec rotation, `docker logs` étant tronqué à la rotation du conteneur alors que c'est l'historique des dépôts qu'on veut relire. La release produit désormais trois images sous le même tag. Un dépôt commun n'empêche pas un déploiement séparé, et le numéro partagé évite la question « quelle version du hub parle à quelle version d'instance » tant que le contrat d'échange bouge. Refs #636 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aucune variable de fédération n'existait en production : une instance installée par paquet Debian ne pouvait pas rejoindre le hub, quelle que soit la configuration du hub en face. Les huit variables `CICADA_*` sont ajoutées aux deux composes de production, sur `web` **et** `celery-worker` — c'est le worker qui exécutera la publication, et sans elles la tâche se serait terminée en « non configurée » alors même que le conteneur web parlait au hub. Toutes vides par défaut : un déploiement isolé se comporte exactement comme avant. La publication devient automatique (2h30), planifiée sur toutes les instances et retenue par trois conditions distinctes : - hub et jeton de dépôt configurés — la mécanique est branchée ; - `CICADA_HUB_PUSH_AUTO`, nouveau réglage : lire l'exploration nationale exige l'URL du hub, donc sans ce booléen, configurer le relais aurait déclenché la publication dans le dos de l'exploitant ; - le consentement de la structure, relu à chaque exécution parce qu'il peut être retiré entre deux nuits. Son retrait sort en `info` et non en erreur : une décision assumée ne doit pas se signaler comme une panne. Un dépôt porte l'état complet et non un différentiel : une nuit sautée ne perd rien. Un échec, lui, remonte — le journal des tâches est le seul endroit où l'exploitant le verra, personne n'attendant devant une tâche de 2h30. L'installeur du paquet Debian propose une section « Exploration fédérée » facultative, qui valide l'identifiant d'instance : c'est le seul moment où l'erreur se corrige sans conséquence, puisqu'il est ensuite écrit dans chaque ligne d'index. Le jeton de lecture n'est exigé que pour relayer l'exploration, publier et lire étant deux droits distincts. Note : l'installeur existe en deux copies divergentes et `build-deb.sh` ne copie jamais `packaging/installer/`. Seule celle du tree `debian/` est modifiée ici, puisque c'est elle qui est packagée. Refs #636 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Les jetons vivaient dans l'environnement, lus au démarrage. Trois choses rendaient ce montage intenable au-delà de deux instances : enrôler imposait un redémarrage du hub — donc une coupure pour toutes les autres —, révoquer ne laissait aucune trace, et les secrets voyageaient en clair dans les fichiers de déploiement. Le registre (`ccd_search.t_instance`) porte l'identité, l'état et l'empreinte des deux jetons. SHA-256 et non PBKDF2 : ces jetons sont tirés au sort par le hub sur 256 bits, jamais choisis par un humain — il n'y a ni dictionnaire à opposer ni réutilisation à craindre, et un hachage lent se paierait à chaque page d'un dépôt qui en compte des centaines. L'environnement reste accepté en amorce, mais **uniquement pour une instance absente du registre**. C'est la règle qui fait tout tenir : sans elle, une instance désactivée ou dont le jeton a été renouvelé resterait admise par une variable oubliée dans un déploiement — autrement dit, révoquer ne révoquerait rien. `enroler_instance` délivre, renouvelle, suspend et liste. Les jetons ne s'affichent qu'une fois ; l'identifiant est validé à la création, seul moment où l'erreur se corrige sans conséquence. La commande signale les instances qui publient encore par jeton d'environnement : c'est la liste de ce qu'il reste à enrôler. `GET /api/federation/instances/` rend l'état de la fédération — enrôlement, activité, dernière publication réussie, volumes. Ces informations étaient déjà toutes en base, mais réparties dans trois tables qu'il fallait interroger en SQL sur le serveur ; or la question « untel publie-t-il encore ? » se pose depuis l'extérieur, souvent dans l'urgence. Aucun jeton ni empreinte n'y figure : une empreinte permettrait de vérifier hors ligne un jeton deviné sans que le hub ne journalise rien. Suspendre n'est pas dépublier : l'index déposé reste servi, le retrait restant une décision de l'instance. 21 tests : usage croisé des jetons, renouvellement invalidant l'ancien, préséance du registre dans les trois cas (révoqué, suspendu, absent), réciprocité toujours appliquée aux instances enrôlées, non-fuite des secrets. Refs #636 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nouvelle note `docs/DEPLOIEMENT_HUB.md` : prérequis, installation, vhost Apache, enrôlement d'une instance, publication, montée de version et recette. Les quatre pièges qui coûtent le plus cher y sont nommés — ne pas réécrire `/api`, ne pas filtrer les en-têtes d'authentification, aligner `ALLOWED_HOSTS` sur le `ServerName`, et faire correspondre `LimitRequestBody` avec la limite Django. Un cinquième : ne pas lancer le compose de production sur un poste de développement, les deux fichiers pilotant le même stack. La recette après déploiement est une séquence HTTP, pas le banc d'essai : ce dernier pilote les briques par `docker exec` sur des noms de conteneurs figés et ne peut donc pas viser un hub distant. Il couvre le contrat et la parité des deux `filters.py` — à lancer avant de tagger, pas après avoir déployé. Est aussi consigné ce qui manque encore : un `push_federation` à volume réel, seul moyen d'éprouver les limites de taille et de durée. Refs #636 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'exploration fédérée mélangeait, dans une même liste triée par pertinence, des plans de plusieurs structures sans que rien ne dise lesquels. La donnée était pourtant tracée — `instance_id` est dans chaque ligne d'index et dans toutes les clés d'unicité — mais elle n'était jamais montrée. Le banc d'essai en donne l'illustration : les deux instances ont un plan `bac-a-sable-plan-initial-2010-2020`, même nom, mêmes années, même type de document. Dans la liste agrégée, rien ne les distinguait, et un plan distant se lisait comme un plan local. Ce qui change : - l'instance **se nomme en publiant** : `push_federation` transmet `CICADA_INSTANCE_LABEL` et `CICADA_PUBLIC_URL` à l'ouverture du lot. Le libellé n'autorise rien — l'émetteur reste déduit du jeton. Il est stocké sur le **lot** et non sur `Instance` : créer une ligne de registre à la publication ferait basculer l'instance du côté « enrôlée » et `identifier_porteur` refuserait alors son propre jeton d'environnement ; - le hub résout un nom lisible en cascade (`hub/apps/index/identites.py`) : registre, puis dernière déclaration, puis l'identifiant. Jamais vide ; - toute réponse de lecture porte `instance_id` et `instance_libelle` ; la fiche y ajoute `url_instance` et `date_publication` — c'est un instantané déposé, pas une lecture en direct, et son âge doit se voir ; - `GET /api/exploration/instances/`, servi par le hub ou par l'instance (une seule structure, la sienne) : même forme de réponse des deux côtés ; - `?instances=` filtre les deux modes, honoré aussi par l'index local — une URL de recherche est faite pour être partagée, et l'ignorer rendrait les plans d'ici sous un filtre qui demandait ceux d'ailleurs ; - interface : pastille de provenance sur chaque tuile, bandeau et lien vers la structure sur la fiche, filtre « Structure d'origine » (masqué en deçà de deux structures), portée chiffrée annoncée avant la recherche. Au passage, un défaut latent : les tuiles du mode « plan » étaient suivies par `id_pg`, une séquence locale à chaque instance. Deux structures ayant couramment un plan n° 42, Angular n'en aurait rendu qu'une. Suivi par référence désormais. Tests : 16 cas de provenance côté hub (98 au total), 5 côté CICADA (166 dans `tests/apps/search/`), un cas de banc et deux cas E2E — la provenance traverse la couture entre les deux projets, chaque moitié peut être juste sans que le nom n'arrive jamais. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…igatoire Obligation du financement LIFE BIODIV'FRANCE : tout outil de communication porte le bloc marque officiel (UE + LIFE) et, dès qu'il diffuse du contenu, la clause de non-responsabilité de l'UE dans un encart délimité. - composant partagé app-eu-funding-notice (bloc marque + mention obligatoire + texte facultatif du projet), en pied de page de l'accueil - remplace les deux logos séparés par le bloc marque officiel - même encart en fin de README Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Complète be2fc7c, qui ne contenait que la suppression des anciens logos. - composant partagé app-eu-funding-notice (bloc marque officiel + mention UE obligatoire + texte facultatif du projet), en pied de page de l'accueil - même encart en fin de README Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…programmation (#600) L'import des actions datait d'avant la refonte : un seul mode de ventilation (« par type de budget »), l'ancienne case « déclinaison par poste », la colonne « financé ? ». Une action importée n'était donc pas enregistrée comme par sa fiche, et ses budgets pouvaient ne pas s'y afficher. - Onglet Actions : mode de ventilation (6 modes), « déclinaison par type de coût » et « saisie automatique du coût salarial » ; déduits des onglets Budgets / RH s'ils sont laissés vides. - Onglet Budgets : budget total, enveloppes ou détail des coûts, par année ou par organisme, rangés comme par la fiche action. Une colonne hors du mode est une erreur (le montant serait invisible). - Onglet RH : cible poste / organisme / globale selon le mode, catégorie de dépense (#597) ; les anciens fichiers « financé ? » restent lisibles. - Onglets de référence Postes (nom local #632, homonymes numérotés #611, type #633, organisme #599, coût jour) et Organismes (gestionnaires des sites). - Actions ventilées par organisme rattachées aux sites correspondants. - Les actions rattachées à l'indicateur par leur seule métrique sont exportées et comptent pour le refus « plan déjà rempli ». Tests : aller-retour export → import pour les 6 modes, règles miroir de operation-budget.ts, endpoints HTTP de l'import des actions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Plan construit pour le test (arborescence, postes, 3 actions dans 3 modes de ventilation) → export du classeur → téléversement refusé tant que le plan a ses actions → réimport par la page Paramètres → actions identiques (paramétrage, montants par année / organisme, lignes RH). Ajoute les data-testid du bloc d'import des actions et deux helpers binaires (apiGetBuffer, apiPostFile). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Version 2.0 en cours de relecture : les passages nouveaux ou modifiés depuis la v1.0 (juin 2026) sont en violet et la note de relecture est à retirer avant diffusion. L'outillage de génération (docs/notice/) reste hors dépôt tant qu'il n'est pas finalisé. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Workflow déclenché à la publication d'une release (et à la main) : backend, frontend (dépendances de production), hub et tracking-api, en version « prod ». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Les merges de branches dans develop suivent la convention « merge: <sujet> dans develop », que la règle type-enum rejette et qui bloque la PR develop → main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… la fiche action (#668) La grille mensuelle était posée sur les colonnes des années du plan et tronquée à la dernière : un plan de 5 ans n'affichait que Janv → Mai. Elle est désormais repliée (trimestres, semestres, année) selon la place disponible, sans élargir la feuille. Closes #668 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ease Les releases GitHub ne sont plus publiées depuis v0.1.39 : seuls les tags v* marquent une mise en production, comme pour docker-publish et build-deb. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intègre dans
mainles 46 commits dedevelopdepuis la 0.1.48.Contenu
Exploration fédérée multi-instances (#636)
Exploration des données : pourquoi un résultat répond (#650), survol, repli de recherche et champ de filtre (#649, #651, #652)
API ouverte des métadonnées des plans (#645)
Corrections
Divers
sbom.ymlà chaque release publiée (et lancement manuel). Il doit être surmainpour que GitHub le propose au lancement manuel.Avant de lancer le workflow SBOM
Les secrets
DT_URLetDT_API_KEY(cléci-upload) doivent être accessibles au dépôt : ils ne sont pas au niveau du dépôt, à vérifier au niveau de l'organisation.🤖 Generated with Claude Code