[EF-09] Migration recommandation : cycle de vie (statut, resolue_a, action) #164

Closed
opened 2026-09-07 08:11:37 +00:00 by gabriel · 1 comment
Member

Exigence couverte

EF-09

Épreuve servie

EC05 · Data, ETL et BI

Charge estimée

0,5 j.h

Ce qu'on veut obtenir

Persister le cycle de vie d'une recommandation (active → résolue) dans la zone or, décidé sur #153 entre Olivier et moi le 2026-09-07 : ajout de colonnes plutôt que réinterprétation du rejeu idempotent actuel de recommandation (0014). cycle.rapprocher (#154, #39) est déjà écrit pour ce modèle et attend de savoir où écrire.

Critères d'acceptation

  • La migration 0017_zone_or_recommandation_cycle.sql ajoute à recommandation les colonnes statut (active/resolue, défaut active), resolue_a (timestamptz, nullable) et action (text not null), sans retoucher 0014.
  • La contrainte d'unicité (site_id, horodatage, regle_id, regle_version) est retirée et remplacée par un index unique partiel (site_id, regle_id) where statut = 'active'.
  • La migration se rejoue sans erreur aussi bien sur une base neuve (0014 puis 0017) que sur une base déjà en 0014 seul (0017 seul), et porte son sens de retour arrière.
  • 0017 figure dans la liste figée NOUVEAUX de tests/unit/db/test_migrations_zone_or.py.
  • Le comportement de action text not null face à des lignes déjà présentes en préprod est vérifié et tranché (default '' puis drop default, ou nullable).

Comment on le vérifie

Tests tests/unit/db/test_migrations_zone_or.py
Commande pytest tests/unit/db/test_migrations_zone_or.py -v puis python db/migrate.py et \d recommandation dans psql
Preuve sortie pytest + sortie \d recommandation, collées à la fermeture

Hors périmètre

Brancher cycle.rapprocher à cette persistance (lecture/écriture depuis le service recommendations) — ticket séparé une fois le schéma posé. Pas de changement API ni dashboard.

### Exigence couverte EF-09 ### Épreuve servie EC05 · Data, ETL et BI ### Charge estimée 0,5 j.h ### Ce qu'on veut obtenir Persister le cycle de vie d'une recommandation (active → résolue) dans la zone or, décidé sur #153 entre Olivier et moi le 2026-09-07 : ajout de colonnes plutôt que réinterprétation du rejeu idempotent actuel de `recommandation` (0014). `cycle.rapprocher` (#154, #39) est déjà écrit pour ce modèle et attend de savoir où écrire. ### Critères d'acceptation - [ ] La migration `0017_zone_or_recommandation_cycle.sql` ajoute à `recommandation` les colonnes `statut` (`active`/`resolue`, défaut `active`), `resolue_a` (`timestamptz`, nullable) et `action` (`text not null`), sans retoucher 0014. - [ ] La contrainte d'unicité `(site_id, horodatage, regle_id, regle_version)` est retirée et remplacée par un index unique partiel `(site_id, regle_id) where statut = 'active'`. - [ ] La migration se rejoue sans erreur aussi bien sur une base neuve (0014 puis 0017) que sur une base déjà en 0014 seul (0017 seul), et porte son sens de retour arrière. - [ ] `0017` figure dans la liste figée `NOUVEAUX` de `tests/unit/db/test_migrations_zone_or.py`. - [ ] Le comportement de `action text not null` face à des lignes déjà présentes en préprod est vérifié et tranché (`default ''` puis `drop default`, ou nullable). ### Comment on le vérifie Tests tests/unit/db/test_migrations_zone_or.py Commande pytest tests/unit/db/test_migrations_zone_or.py -v puis python db/migrate.py et \d recommandation dans psql Preuve sortie pytest + sortie \d recommandation, collées à la fermeture ### Hors périmètre Brancher `cycle.rapprocher` à cette persistance (lecture/écriture depuis le service `recommendations`) — ticket séparé une fois le schéma posé. Pas de changement API ni dashboard.
Member

Fait, dans la demande de fusion #180 — branche olivier/164-recommandation-cycle.

Un écart à signaler : le fichier est la 0019, pas la 0017

Le critère 1 nomme 0017_zone_or_recommandation_cycle.sql. Entre l'écriture du ticket et maintenant, 0017 a été prise par le #36 (référence de comparaison des prévisions) et 0018 par le #168 (libellé et état des alertes) — les deux fusionnées dans develop. Le fichier s'appelle donc 0019_zone_or_recommandation_cycle.sql, contenu et intention inchangés.

Rien n'est perdu au passage : la 0018 avait posé le rendez-vous en toutes lettres — « resolue_a vient avec le cycle de vie des recommandations au #164 ».

Les cinq critères

  • 1 — la 0019 ajoute statut (active/resolue, défaut active), resolue_a (timestamptz nullable) et action (text not null). La 0014 n'est pas retouchée.
  • 2unique (site_id, horodatage, regle_id, regle_version) retirée, remplacée par l'index unique partiel (site_id, regle_id) where statut = 'active'.
  • 3 — rejoué sur deux bases d'essai jetables de ev-postgres (supprimées à la fin, ni la préprod ni la prod touchées) : base neuve 0001 → 0019 d'affilée, base arrêtée à 0018 puis 0019 seule, et la 0019 une seconde fois sur les deux. Le retour arrière écrit en fin de fichier a été joué : la table revient aux huit colonnes de la 0014 et retrouve son ancienne contrainte sous le nom exact écrit dans le drop constraint.
  • 40019_zone_or_recommandation_cycle.sql figure dans NOUVEAUX de tests/unit/db/test_migrations_zone_or.py.
  • 5 — tranché : default '' puis drop default, et non le nullable. Motif ci-dessous.

Le critère 5, et ce qui a été mesuré

La table est vide partout aujourd'hui : rien n'y écrit. Le chargement de la zone or ne remplit que mesure, qualite_jour et alerte, et /api/v1/sites/{id}/recommandations sert des fixtures. Un add column ... not null sans défaut serait donc passé.

Il n'a pas été retenu, parce qu'il ne marche que tant que c'est vrai : un not null sans défaut échoue dès la première ligne présente, et les migrations s'appliquent au démarrage du serveur — à un moment que personne ne choisit. Le jour où le job du #39 écrit sur la préprod avant que la 0019 n'y passe, le serveur ne redémarre plus.

Le retrait du défaut compte autant que le défaut : sans lui, not null ne garderait plus rien, la contrainte étant toujours satisfaite par une chaîne vide que personne n'a voulue. Le nullable a été écarté pour la raison qui vaut déjà côté moteur, où regle.py refuse d'émettre : « une recommandation qui ne dit pas quoi faire n'est pas une recommandation ».

Vérifié sur une base portant déjà une ligne écrite avant la migration :

  -> 0019 SEULE, sur cette base qui porte déjà une ligne
-[ RECORD 1 ]-----
statut    | active
resolue_a |
action    | ''

Une contrainte de plus que le ticket ne demandait

check ((statut = 'resolue') = (resolue_a is not null)). L'état et son instant ne peuvent pas se contredire — c'est l'invariant pour lequel resolue_a existe. Signalé parce que les critères ne l'énuméraient pas ; à retirer si le groupe préfère.

Les gardes, poussées et non supposées

Insertion Verdict
une première active acceptée
une seconde active, même site + même règle refuséerecommandation_active
même site + même règle, version différente refusée — recaler un seuil ne rouvre rien (#153)
une active sur une autre règle acceptée
sans action refuséenot null
statut inconnu refuséerecommandation_statut
résolue sans resolue_a, ou active avec un resolue_a refuséesrecommandation_resolue_a
deux résolues, même site + même règle acceptées — c'est l'historique

Preuve — \d recommandation

                                        Table « public.recommandation »
        Colonne        |           Type           | Collationnement | NULL-able |          Par défaut          
-----------------------+--------------------------+-----------------+-----------+------------------------------
 reco_id               | bigint                   |                 | not null  | generated always as identity
 site_id               | text                     |                 | not null  | 
 horodatage            | timestamp with time zone |                 | not null  | 
 regle_id              | text                     |                 | not null  | 
 regle_version         | text                     |                 | not null  | 
 valeurs_declenchantes | jsonb                    |                 | not null  | 
 libelle               | text                     |                 | not null  | 
 alerte_id             | bigint                   |                 |           | 
 statut                | text                     |                 | not null  | 'active'::text
 resolue_a             | timestamp with time zone |                 |           | 
 action                | text                     |                 | not null  | 
Index :
    "recommandation_pkey" PRIMARY KEY, btree (reco_id)
    "recommandation_active" UNIQUE, btree (site_id, regle_id) WHERE statut = 'active'::text
Contraintes de vérification :
    "recommandation_resolue_a" CHECK ((statut = 'resolue'::text) = (resolue_a IS NOT NULL))
    "recommandation_statut" CHECK (statut = ANY (ARRAY['active'::text, 'resolue'::text]))
Contraintes de clés étrangères :
    "recommandation_alerte_id_fkey" FOREIGN KEY (alerte_id) REFERENCES alerte(alerte_id) ON DELETE SET NULL
    "recommandation_site_id_fkey" FOREIGN KEY (site_id) REFERENCES site(site_id)

Preuve — pytest

collecting ... collected 27 items

tests/unit/db/test_migrations_zone_or.py::test_les_sept_tables_sont_declarees PASSED [  3%]
tests/unit/db/test_migrations_zone_or.py::test_acces_site_reste_hors_de_public PASSED [  7%]
tests/unit/db/test_migrations_zone_or.py::test_la_cle_de_mesure_est_celle_des_zones_bronze_et_argent PASSED [ 11%]
tests/unit/db/test_migrations_zone_or.py::test_mesure_porte_la_valeur_retenue_et_la_valeur_brute PASSED [ 14%]
tests/unit/db/test_migrations_zone_or.py::test_mesure_porte_le_lignage_vers_la_zone_bronze PASSED [ 18%]
tests/unit/db/test_migrations_zone_or.py::test_recommandation_porte_la_regle_versionnee_et_ses_valeurs PASSED [ 22%]
tests/unit/db/test_migrations_zone_or.py::test_le_referentiel_suit_le_glossaire PASSED [ 25%]
tests/unit/db/test_migrations_zone_or.py::test_les_cinq_types_du_glossaire_sont_ceux_de_la_contrainte PASSED [ 29%]
tests/unit/db/test_migrations_zone_or.py::test_les_deux_chemins_de_creation_donnent_le_meme_schema PASSED [ 33%]
tests/unit/db/test_migrations_zone_or.py::test_aucune_migration_ne_reecrit_de_ligne_dans_une_hypertable PASSED [ 37%]
tests/unit/db/test_migrations_zone_or.py::test_chaque_migration_ecrit_son_sens_de_retour PASSED [ 40%]
tests/unit/db/test_migrations_zone_or.py::test_les_migrations_se_rejouent_sans_effet_de_bord PASSED [ 44%]
tests/unit/db/test_migrations_zone_or.py::test_la_serie_des_migrations_ne_porte_ni_doublon_ni_desordre PASSED [ 48%]
tests/unit/db/test_migration_recommandation_cycle.py::test_les_trois_colonnes_du_cycle_sont_ajoutees PASSED [ 51%]
tests/unit/db/test_migration_recommandation_cycle.py::test_le_statut_par_defaut_est_active PASSED [ 55%]
tests/unit/db/test_migration_recommandation_cycle.py::test_le_statut_est_contraint_aux_deux_valeurs_du_cycle PASSED [ 59%]
tests/unit/db/test_migration_recommandation_cycle.py::test_l_action_ne_peut_pas_rester_vide_pour_les_lignes_a_venir PASSED [ 62%]
tests/unit/db/test_migration_recommandation_cycle.py::test_l_etat_et_son_instant_ne_peuvent_pas_se_contredire PASSED [ 66%]
tests/unit/db/test_migration_recommandation_cycle.py::test_les_contraintes_sont_posees_hors_des_add_column PASSED [ 70%]
tests/unit/db/test_migration_recommandation_cycle.py::test_l_unicite_de_la_0014_est_retiree_sous_son_nom_postgresql PASSED [ 74%]
tests/unit/db/test_migration_recommandation_cycle.py::test_une_seule_recommandation_active_par_site_et_par_regle PASSED [ 77%]
tests/unit/db/test_migration_recommandation_cycle.py::test_la_0014_n_est_pas_retouchee PASSED [ 81%]
tests/unit/db/test_migration_recommandation_cycle.py::test_les_colonnes_portent_les_champs_que_le_moteur_produit PASSED [ 85%]
tests/unit/db/test_migration_recommandation_cycle.py::test_la_migration_se_rejoue_sans_effet_de_bord PASSED [ 88%]
tests/unit/db/test_migration_recommandation_cycle.py::test_la_migration_ne_reecrit_aucune_ligne PASSED [ 92%]
tests/unit/db/test_migration_recommandation_cycle.py::test_la_migration_ecrit_son_sens_de_retour PASSED [ 96%]
tests/unit/db/test_migration_recommandation_cycle.py::test_le_numero_passe_apres_celui_de_la_table_completee PASSED [100%]

============================== 27 passed in 0.02s ==============================

pytest tests/unit sur l'ensemble du dépôt : 932 passés, 1 sauté. ruff check et ruff format --check propres.

Ce qui reste, comme le ticket le pose en hors périmètre

Brancher cycle.rapprocher sur cette persistance — lire les actives, écrire les ouvertures et les résolutions depuis le service recommendations — appartient au ticket séparé annoncé ici. Le schéma est posé, il attend son job.

Fait, dans la demande de fusion **#180** — branche `olivier/164-recommandation-cycle`. ## Un écart à signaler : le fichier est la 0019, pas la 0017 Le critère 1 nomme `0017_zone_or_recommandation_cycle.sql`. Entre l'écriture du ticket et maintenant, **0017 a été prise par le #36** (référence de comparaison des prévisions) et **0018 par le #168** (libellé et état des alertes) — les deux fusionnées dans `develop`. Le fichier s'appelle donc `0019_zone_or_recommandation_cycle.sql`, contenu et intention inchangés. Rien n'est perdu au passage : la 0018 avait posé le rendez-vous en toutes lettres — « `resolue_a` vient avec le cycle de vie des recommandations au #164 ». ## Les cinq critères - [x] **1** — la 0019 ajoute `statut` (`active`/`resolue`, défaut `active`), `resolue_a` (`timestamptz` nullable) et `action` (`text not null`). La 0014 n'est pas retouchée. - [x] **2** — `unique (site_id, horodatage, regle_id, regle_version)` retirée, remplacée par l'index unique partiel `(site_id, regle_id) where statut = 'active'`. - [x] **3** — rejoué sur deux bases d'essai jetables de `ev-postgres` (supprimées à la fin, ni la préprod ni la prod touchées) : base neuve 0001 → 0019 d'affilée, base arrêtée à 0018 puis 0019 seule, et la 0019 une seconde fois sur les deux. Le retour arrière écrit en fin de fichier a été joué : la table revient aux huit colonnes de la 0014 et **retrouve son ancienne contrainte sous le nom exact** écrit dans le `drop constraint`. - [x] **4** — `0019_zone_or_recommandation_cycle.sql` figure dans `NOUVEAUX` de `tests/unit/db/test_migrations_zone_or.py`. - [x] **5** — tranché : **`default ''` puis `drop default`**, et non le nullable. Motif ci-dessous. ## Le critère 5, et ce qui a été mesuré La table est **vide partout aujourd'hui** : rien n'y écrit. Le chargement de la zone or ne remplit que `mesure`, `qualite_jour` et `alerte`, et `/api/v1/sites/{id}/recommandations` sert des fixtures. Un `add column ... not null` sans défaut serait donc passé. Il n'a pas été retenu, parce qu'il ne marche que tant que c'est vrai : un `not null` sans défaut **échoue dès la première ligne présente**, et les migrations s'appliquent au démarrage du serveur — à un moment que personne ne choisit. Le jour où le job du #39 écrit sur la préprod avant que la 0019 n'y passe, le serveur ne redémarre plus. Le retrait du défaut compte autant que le défaut : sans lui, `not null` ne garderait plus rien, la contrainte étant toujours satisfaite par une chaîne vide que personne n'a voulue. Le nullable a été écarté pour la raison qui vaut déjà côté moteur, où `regle.py` refuse d'émettre : « une recommandation qui ne dit pas quoi faire n'est pas une recommandation ». **Vérifié sur une base portant déjà une ligne** écrite avant la migration : ``` -> 0019 SEULE, sur cette base qui porte déjà une ligne -[ RECORD 1 ]----- statut | active resolue_a | action | '' ``` ## Une contrainte de plus que le ticket ne demandait `check ((statut = 'resolue') = (resolue_a is not null))`. L'état et son instant ne peuvent pas se contredire — c'est l'invariant pour lequel `resolue_a` existe. Signalé parce que les critères ne l'énuméraient pas ; à retirer si le groupe préfère. ## Les gardes, poussées et non supposées | Insertion | Verdict | |---|---| | une première active | acceptée | | une seconde active, même site + même règle | **refusée** — `recommandation_active` | | même site + même règle, **version différente** | **refusée** — recaler un seuil ne rouvre rien (#153) | | une active sur une autre règle | acceptée | | sans `action` | **refusée** — `not null` | | `statut` inconnu | **refusée** — `recommandation_statut` | | résolue sans `resolue_a`, ou active **avec** un `resolue_a` | **refusées** — `recommandation_resolue_a` | | deux résolues, même site + même règle | acceptées — c'est l'historique | ## Preuve — `\d recommandation` ``` Table « public.recommandation » Colonne | Type | Collationnement | NULL-able | Par défaut -----------------------+--------------------------+-----------------+-----------+------------------------------ reco_id | bigint | | not null | generated always as identity site_id | text | | not null | horodatage | timestamp with time zone | | not null | regle_id | text | | not null | regle_version | text | | not null | valeurs_declenchantes | jsonb | | not null | libelle | text | | not null | alerte_id | bigint | | | statut | text | | not null | 'active'::text resolue_a | timestamp with time zone | | | action | text | | not null | Index : "recommandation_pkey" PRIMARY KEY, btree (reco_id) "recommandation_active" UNIQUE, btree (site_id, regle_id) WHERE statut = 'active'::text Contraintes de vérification : "recommandation_resolue_a" CHECK ((statut = 'resolue'::text) = (resolue_a IS NOT NULL)) "recommandation_statut" CHECK (statut = ANY (ARRAY['active'::text, 'resolue'::text])) Contraintes de clés étrangères : "recommandation_alerte_id_fkey" FOREIGN KEY (alerte_id) REFERENCES alerte(alerte_id) ON DELETE SET NULL "recommandation_site_id_fkey" FOREIGN KEY (site_id) REFERENCES site(site_id) ``` ## Preuve — pytest ``` collecting ... collected 27 items tests/unit/db/test_migrations_zone_or.py::test_les_sept_tables_sont_declarees PASSED [ 3%] tests/unit/db/test_migrations_zone_or.py::test_acces_site_reste_hors_de_public PASSED [ 7%] tests/unit/db/test_migrations_zone_or.py::test_la_cle_de_mesure_est_celle_des_zones_bronze_et_argent PASSED [ 11%] tests/unit/db/test_migrations_zone_or.py::test_mesure_porte_la_valeur_retenue_et_la_valeur_brute PASSED [ 14%] tests/unit/db/test_migrations_zone_or.py::test_mesure_porte_le_lignage_vers_la_zone_bronze PASSED [ 18%] tests/unit/db/test_migrations_zone_or.py::test_recommandation_porte_la_regle_versionnee_et_ses_valeurs PASSED [ 22%] tests/unit/db/test_migrations_zone_or.py::test_le_referentiel_suit_le_glossaire PASSED [ 25%] tests/unit/db/test_migrations_zone_or.py::test_les_cinq_types_du_glossaire_sont_ceux_de_la_contrainte PASSED [ 29%] tests/unit/db/test_migrations_zone_or.py::test_les_deux_chemins_de_creation_donnent_le_meme_schema PASSED [ 33%] tests/unit/db/test_migrations_zone_or.py::test_aucune_migration_ne_reecrit_de_ligne_dans_une_hypertable PASSED [ 37%] tests/unit/db/test_migrations_zone_or.py::test_chaque_migration_ecrit_son_sens_de_retour PASSED [ 40%] tests/unit/db/test_migrations_zone_or.py::test_les_migrations_se_rejouent_sans_effet_de_bord PASSED [ 44%] tests/unit/db/test_migrations_zone_or.py::test_la_serie_des_migrations_ne_porte_ni_doublon_ni_desordre PASSED [ 48%] tests/unit/db/test_migration_recommandation_cycle.py::test_les_trois_colonnes_du_cycle_sont_ajoutees PASSED [ 51%] tests/unit/db/test_migration_recommandation_cycle.py::test_le_statut_par_defaut_est_active PASSED [ 55%] tests/unit/db/test_migration_recommandation_cycle.py::test_le_statut_est_contraint_aux_deux_valeurs_du_cycle PASSED [ 59%] tests/unit/db/test_migration_recommandation_cycle.py::test_l_action_ne_peut_pas_rester_vide_pour_les_lignes_a_venir PASSED [ 62%] tests/unit/db/test_migration_recommandation_cycle.py::test_l_etat_et_son_instant_ne_peuvent_pas_se_contredire PASSED [ 66%] tests/unit/db/test_migration_recommandation_cycle.py::test_les_contraintes_sont_posees_hors_des_add_column PASSED [ 70%] tests/unit/db/test_migration_recommandation_cycle.py::test_l_unicite_de_la_0014_est_retiree_sous_son_nom_postgresql PASSED [ 74%] tests/unit/db/test_migration_recommandation_cycle.py::test_une_seule_recommandation_active_par_site_et_par_regle PASSED [ 77%] tests/unit/db/test_migration_recommandation_cycle.py::test_la_0014_n_est_pas_retouchee PASSED [ 81%] tests/unit/db/test_migration_recommandation_cycle.py::test_les_colonnes_portent_les_champs_que_le_moteur_produit PASSED [ 85%] tests/unit/db/test_migration_recommandation_cycle.py::test_la_migration_se_rejoue_sans_effet_de_bord PASSED [ 88%] tests/unit/db/test_migration_recommandation_cycle.py::test_la_migration_ne_reecrit_aucune_ligne PASSED [ 92%] tests/unit/db/test_migration_recommandation_cycle.py::test_la_migration_ecrit_son_sens_de_retour PASSED [ 96%] tests/unit/db/test_migration_recommandation_cycle.py::test_le_numero_passe_apres_celui_de_la_table_completee PASSED [100%] ============================== 27 passed in 0.02s ============================== ``` `pytest tests/unit` sur l'ensemble du dépôt : **932 passés, 1 sauté**. `ruff check` et `ruff format --check` propres. ## Ce qui reste, comme le ticket le pose en hors périmètre Brancher `cycle.rapprocher` sur cette persistance — lire les actives, écrire les ouvertures et les résolutions depuis le service `recommendations` — appartient au ticket séparé annoncé ici. Le schéma est posé, il attend son job.
Sign in to join this conversation.
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision#164
No description provided.