Compare commits

...

456 Commits

Author SHA1 Message Date
Colin Maudry 2a1b43a6fa fix(carte): restaurer les points quand dash-ag-grid écrase le global L (#121)
Le bundle dash-ag-grid (async-community.js) fuit un helper esbuild sous le nom
global `L`, écrasant le window.L de Leaflet. Nos fonctions clientside
pointToLayer/clusterToLayer lisaient ce global → `L.point`/`L.circleMarker is
not a function` → l'exception cassait tout le calque GeoJSON → carte vide.
Course entre le chunk AG Grid et le rendu : d'où le pattern « surtout avec
beaucoup de marchés ».

Une IIFE capture en privé le vrai Leaflet (celui exposant circleMarker) en
écoutant les affectations de window.L, sans changer ce que window.L renvoie
(AG Grid garde son helper). pointToLayer/clusterToLayer utilisent
window.leafletReal(). Nettoie aussi des console.log oubliés et un `color`
inutilisé dans clusterToLayer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:53:38 +02:00
Colin Maudry 03bf5686e0 fix(figures): éviter le crash quand une colonne considérations est absente/vide
compute_considerations_stats initialisait les défauts `{key}_renseignees` en
2-uplets `(0, 0)` mais get_considerations_card_content les dépaquette en
3-uplets. Quand une colonne (considerationsSociales/Environnementales) est
absente du schéma filtré (fréquent avec peu de marchés sélectionnés), le défaut
survivait → ValueError: not enough values to unpack (expected 3, got 2).

Défaut porté à `(0, 0, 0)` : la barre dégrade à 0 % au lieu de planter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:53:37 +02:00
Colin Maudry dadb11a164 Rédactionnel 2026-07-19 01:09:45 +02:00
Colin Maudry 6374ae1ec0 Rédactionnel lié au livechat chatwoot #120 2026-07-15 18:25:36 +02:00
Colin Maudry 6252b65671 Aligne la hauteur du bouton de recherche sur l'input (débordement en bas)
Le bouton utilisait height:auto, dépendant du padding/line-height par
défaut de .btn (~38px), plus haut que l'input fixé à 34px. Fixe la
hauteur du bouton à 34px pour qu'il s'aligne avec l'input.

Inclut aussi les changements en cours sur recherche.py (tagline
dynamique avec stats, home_intro, layout en fonction).
2026-07-15 16:08:56 +02:00
Colin Maudry 85b0dd0b39 Ajoute le widget de chat Chatwoot (essai) — issue #120
Injecte le script d'intégration Chatwoot dans app.index_string, activé
via CHATWOOT_WEBSITE_TOKEN. Vide par défaut, donc désactivé en test/CI.
2026-07-15 15:50:17 +02:00
Colin Maudry 986600d3ef Ajoute spec pour le widget de chat Chatwoot (issue #120) 2026-07-15 15:41:09 +02:00
Colin Maudry 30375a262f Rédactionnel abonnement et page d'accueil 2026-07-15 10:57:21 +02:00
Colin Maudry f571627a6b Jetons MCP chiffrés au repos + bouton « Copier le jeton » sur /compte/mcp
Les jetons MCP sont désormais chiffrés (Fernet dérivée de SECRET_KEY) en plus
du hash conservé pour l'auth, permettant leur ré-affichage/copie à tout moment.
Getter scopé au propriétaire, dégradation propre si clé absente/changée.
Migration 0013 + colonne token_enc. UI alignée sur « Copier le lien » des vues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:55:01 +02:00
Colin Maudry 43d8ba5098 Ordre des instructions MCP 2026-07-14 23:33:12 +02:00
Colin Maudry 17e9c33934 ILIKE généralisé dans API/MCP pour contains 2026-07-14 21:23:07 +02:00
Colin Maudry d920e1ca8e Support dans l'API et le MCP de l'opérateur startswith, utile pour les codes CPV 2026-07-14 21:16:08 +02:00
Colin Maudry ca320bdc1f Amélioration instructions config MCP et prompts 2026-07-14 21:15:17 +02:00
Colin Maudry 2efb5e067c Précisions config claude.ai/Desktop 2026-07-14 19:48:19 +02:00
Colin Maudry b077baf0a3 fix(mcp): dedoublonne les colonnes demandees (evite SELECT dupliqué) (#114) 2026-07-14 18:12:50 +02:00
Colin Maudry b4930537a1 feat(mcp): parametre colonnes (enum) pour rechercher_marches (#114) 2026-07-14 18:06:02 +02:00
Colin Maudry f668db1419 feat(mcp): colonnes configurables + lien dans rechercher_marches (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:58:19 +02:00
Colin Maudry 1636db68ea docs(mcp): plan colonnes configurables rechercher_marches (#114) 2026-07-14 17:55:19 +02:00
Colin Maudry 9961bb19df docs(mcp): coherence union colonnes dans le design (#114) 2026-07-14 17:49:38 +02:00
Colin Maudry 8905f083ea docs(mcp): design colonnes configurables pour rechercher_marches (#114) 2026-07-14 17:49:20 +02:00
Colin Maudry 70ca65e962 Adaptation de l'effet de TOUS_ABONNES 2026-07-14 12:53:01 +02:00
Colin Maudry ad293acd88 Message d'info pour les users en trial dans /compte/roadmap 2026-07-14 11:48:29 +02:00
Colin Maudry ea95431eae docs: spec de refonte du système de votes de la roadmap
Championnat mensuel (fenêtre glissante sur created_at), recharge des
votes le lundi (Europe/Paris) sans report, pas de cap par feature, et
précondition de déploiement gatée sur le nombre de votants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:44:32 +02:00
Colin Maudry 906c6a7f15 Changement font en-tetes colonnes (akshar) 2026-07-13 23:48:54 +02:00
Colin Maudry f84ca73b04 fix(abonnement): erreurs console JS et re-render en boucle sur la page mes-infos
Le label du checklist CGU mélangeait des chaînes brutes et un composant
html.A dans une liste, ce que le rendu compound-label de dcc.Checklist
ne supporte pas (chaque item passe par ExternalWrapper qui suppose un
vrai composant Dash). Ça crashait en boucle et déclenchait des dizaines
de re-render, ce qui faisait aussi clignoter le lien Abonnement dans le
menu latéral. Fix : tout regrouper dans un seul html.Span.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:38:29 +02:00
Colin Maudry a225dbb0ef Normalisation du style des boutons 2026-07-13 23:24:27 +02:00
Colin Maudry 34f10f2283 Tooltips abonnement sur Sauvegarder vue et Mes vues 2026-07-13 23:04:48 +02:00
Colin Maudry 8b9433b7f2 Amélioration de la modale de renommage des vues 2026-07-13 22:50:23 +02:00
Colin Maudry c0d8a40c92 Couleur hover dans les dropdowns 2026-07-13 22:49:56 +02:00
Colin Maudry bee401430b feat(tableau): lien « Gérer mes vues » en tête du dropdown + fix hover dropdown (#112)
- Dropdown « Mes vues » : ajoute en tête un lien « Gérer mes vues » (couleur
  primary) vers /compte/vues, séparé de la liste des vues.
- Corrige l'override de --bs-dropdown-link-hover-bg : Bootstrap 5.3 scope cette
  variable sur .dropdown-menu, donc un override sur :root est masqué. Déplacé
  sur .dropdown-menu.
2026-07-13 22:38:57 +02:00
Colin Maudry 31427a8b3b feat(vues): jeton de partage en minuscules (base36, #112)
Alphabet du jeton passé de base62 (a-z A-Z 0-9) à base36 (a-z 0-9), longueur
inchangée (6). Lien plus lisible/dictable et cohérent avec le slug déjà en
minuscules. Les jetons déjà générés (à casse mixte) restent valides (lookup par
match exact).
2026-07-13 22:17:26 +02:00
Colin Maudry e0828a7e13 feat(vues): bouton « Copier le lien » + lien de partage en texte stylé (#112)
- Bouton de copie explicite (icône copy.svg + « Copier le lien » + retour
  « ✓ Copié ») sur /tableau et /compte/vues, via dcc.Clipboard (children /
  copied_children), à la place de l'icône seule.
- Le lien direct s'affiche en texte (span#share-url-text) plutôt qu'un
  <input> : évite la troncature d'affichage du champ Bootstrap form-control en
  flex (zone de texte bloquée ~218px), montre tout le lien avec retour à la
  ligne, et prend la largeur du lien. Stylé comme un champ (bordure, coins
  arrondis, texte 90%).
- Instructions /compte/vues simplifiées ; E2E adapté (#share-url-text).
2026-07-13 22:08:00 +02:00
Colin Maudry 770b7cbdf8 fix(entity_grid): sortie MATCH unique pour le datasource (dash.set_props)
Le datasource _get_rows des grilles acheteur/titulaire mélangeait une sortie
MATCH (getRowsResponse de la grille pattern-matching) avec deux sorties fixes
(<org>-total / -total-unique). Dash l'interdit : le dev-renderer (mode debug de
run.py) lève « MATCH wildcards must be on the same keys for all Outputs » (4
erreurs : 2 par page). Le callback échoue au dispatch et la grille reste vide.

Correctif : _get_rows n'a plus qu'une sortie MATCH (getRowsResponse) ; les
stores totaux (id fixes) sont alimentés impérativement via dash.set_props.

Test de régression en debug=True (dev-renderer) : sans le correctif la grille ne
charge aucune ligne ; avec, lignes + compteur « N marchés » présents.
2026-07-13 19:40:26 +02:00
Colin Maudry f0a891b73f fix(tableau): griser/désactiver les boutons vues pour les non-abonnés
La barre des vues sauvegardées restait masquée pour les non-abonnés via un
style inline display:none — inopérant car .d-inline-flex est
display:inline-flex !important, la barre était donc toujours visible.

La barre reste désormais visible pour tous ; « Sauvegarder la vue » et
« Mes vues » sont grisés et désactivés (disabled) pour les non-abonnés. Le
gating serveur de save_view (prepare_view_to_save) reste inchangé.
2026-07-13 19:23:03 +02:00
Colin Maudry 3034e911e7 feat(tableau): masquage du bloc de partage piloté par événements AG Grid (#112)
Remplace le verrou de dérive à compteur (suppress-next) par une détection
événementielle : eventListeners AG Grid (filterChanged/sortChanged) →
dashAgGridFunctions.hideShareOnUserAction, qui efface active-view sur action
utilisateur (source != 'api') et ignore l'écho de l'application programmatique.

Le compteur suppress-next souffrait d'une course d'ordonnancement Dash (l'écho
lisait la valeur pré-batch) et le diff de columnState était impraticable (état
réémis bruité). La visibilité du bloc bascule via la classe (d-flex/d-none) et
non le style inline, car .d-flex est display:flex !important.

Task 7 : test E2E de bout en bout (application + masquage sur action).
2026-07-13 18:41:36 +02:00
Colin Maudry 0e0fb32222 feat(compte/vues): Ouvrir via URL courte + bouton copier (#112) 2026-07-13 17:35:53 +02:00
Colin Maudry 2a22951829 feat(tableau): verrou de derive + affichage du bloc de partage (#112) 2026-07-13 17:34:29 +02:00
Colin Maudry 301a8668b3 feat(tableau): resolution ?vue= + bloc URL de partage (#112) 2026-07-13 17:32:48 +02:00
Colin Maudry 439b6f9cdc feat(vues): resolveur pur du parametre ?vue= (#112) 2026-07-13 15:28:54 +02:00
Colin Maudry 3a0d868e93 feat(vues): slugify + build_view_url + parsing du jeton (#112) 2026-07-13 15:23:47 +02:00
Colin Maudry a66af0190d feat(vues): jeton public + get_by_token + backfill (#112) 2026-07-13 15:19:32 +02:00
Colin Maudry 940f0de0a7 Tirets pour le slug, _ pour le token (spec) #112 2026-07-13 15:14:14 +02:00
Colin Maudry ac73f6efc1 docs(vues): plan d'implementation partage par URL courte (#112)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:13:30 +02:00
Colin Maudry d2fa17761d feat(mcp): durcir le consentement OAuth (remember-cookie SameSite + CSRF) (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:07:59 +02:00
Colin Maudry bb2aee85a2 Libellés français pour la pagination #47 2026-07-13 15:02:47 +02:00
Colin Maudry 10770c5d14 docs(vues): design partage de vues par URL courte (#112)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:02:06 +02:00
Colin Maudry cc412f459f docs(mcp): documenter APP_BASE_URL/egress pour le connecteur OAuth (#114) 2026-07-13 14:47:41 +02:00
Colin Maudry 03afb8347c feat(mcp): instructions OAuth Claude.ai/ChatGPT sur /compte/mcp (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:43:01 +02:00
Colin Maudry 34a8470792 feat(mcp): migrations OAuth/usage + câblage app.py (#114)
Ajoute les migrations 0008-0011 (oauth_clients, oauth_codes,
oauth_tokens, mcp_usage) et câble le serveur d'autorisation OAuth 2.1
dans le bloc DASH_MCP_ENABLED de app.py : init des schémas oauth/usage,
purge périodique, enregistrement des routes /oauth et /.well-known, et
exemption CSRF de ces routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:38:33 +02:00
Colin Maudry 8258b92f98 feat(mcp): garde /_mcp unifié statique+OAuth, audience & usage (#114)
Le garde before_request de /_mcp accepte désormais les jetons OAuth
opaques (store.get_token_by_access) en plus des jetons statiques
colibre_*, avec vérification d'audience (resource == mcp_resource)
et d'expiration. Le 401 porte resource_metadata pour le découverte
RFC 9728. Sur succès, incrémente le compteur du bon store et
journalise dans mcp_usage (best-effort).

Corrige aussi une régression d'assertion stricte sur le header
WWW-Authenticate dans test_app_wiring.py, cassée par le nouveau
suffixe resource_metadata.
2026-07-13 14:32:49 +02:00
Colin Maudry 4e3c8517ad feat(mcp): flux OAuth authorize+token avec gate abonnement (#114)
Remplace le stub 501 de src/mcp/oauth/authorize.py par le flux réel
authlib (GET consentement, POST émission de code, échange de token),
avec gate d'abonnement avant tout affichage du consentement.

Deux ajustements de compatibilité authlib 1.7.2 dans server.py,
découverts en exécutant le flux bout-en-bout pour la première fois :
AUTHLIB_INSECURE_TRANSPORT en mode DEVELOPMENT (le client de test
Flask ne sert pas en HTTPS) et OAUTH2_REFRESH_TOKEN_GENERATOR (off
par défaut côté Flask, requis pour émettre les refresh_token du scope
offline_access). Détails dans .superpowers/sdd/task-9-report.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:27:58 +02:00
Colin Maudry 633462f18b feat(mcp): routes découverte + DCR + révocation OAuth (#114) 2026-07-13 14:19:49 +02:00
Colin Maudry ca2f6746f8 fix(mcp): refresh renvoie invalid_grant si abonnement perdu (#114) 2026-07-13 14:17:23 +02:00
Colin Maudry 0970803e5e feat(mcp): configuration authlib (grants + PKCE + DCR, #114)
- AuthorizationCodeGrant avec PKCE requis (CodeChallenge), RefreshTokenGrant
  avec rotation + re-vérification d'abonnement, DCR (ClientRegistrationEndpoint)
  et révocation (RevocationEndpoint) câblés sur le store sqlite (Tasks 1-3),
  metadata (Task 5) et consent.subscription_ok (Task 6).
- _Token (dict + check_client/get_scope) fait le pont entre les rows sqlite
  bruts et le protocole TokenMixin qu'authlib 1.7.2 attend pour le refresh
  et la révocation.
2026-07-13 14:12:03 +02:00
Colin Maudry 7dea07a30f feat(mcp): gate abonnement + écrans de consentement OAuth (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:04:22 +02:00
Colin Maudry c693992041 feat(mcp): documents de découverte OAuth (RFC 9728/8414, #114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:00:30 +02:00
Colin Maudry db0550a170 feat(mcp): journal d'usage mcp_usage (détection niveau 1, #114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:56:08 +02:00
Colin Maudry 13538a4f41 feat(mcp): store OAuth — tokens access/refresh + usage (#114)
Implements OAuth token management functions for Task 3:
- save_token: persists access/refresh tokens with hashing
- get_token_by_access/refresh: retrieves tokens by their hashes
- increment_usage: tracks token usage count and last_used_at
- revoke_token: sets revoked_at timestamp

All 3 tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:51:55 +02:00
Colin Maudry adbaffe545 feat(mcp): store OAuth — codes d'autorisation (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:48:42 +02:00
Colin Maudry f6132d41a2 feat(mcp): store OAuth — schéma + clients DCR (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:45:12 +02:00
Colin Maudry 490ecf940d docs(mcp): plan d'implémentation OAuth 2.1 connecteur MCP (scope B2, #114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:40:56 +02:00
Colin Maudry f63c08f381 docs(mcp): design serveur OAuth 2.1 pour connecteur MCP (scope B2, #114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:30:32 +02:00
Colin Maudry 8b3e72d20a MCP uniquemnet pour les clients CLI 2026-07-13 12:53:14 +02:00
Colin Maudry 341dd84686 test(acheteur): couverture E2E du défilement horizontal AG Grid (#41) 2026-07-13 12:03:37 +02:00
Colin Maudry 87d59c2ce0 test(acheteur/titulaire): sélecteurs AG Grid pour la grille des marchés (#41)
test_002_filter_persistence et test_015_org_pages_filter_date ciblaient
encore le DataTable historique (.marches_table th[data-dash-column] input)
sur /acheteurs et /titulaires ; ces deux pages rendent désormais une
grille AG Grid (src/utils/entity_grid.py). Adapte les deux tests au
filtre flottant AG Grid (col-id + .ag-floating-filter-input), sur la
colonne texte "objet" (le filtre de date AG Grid utilise un <input
type="date"> natif peu fiable à piloter via Selenium). test_002 vérifie
en plus que la persistance du filtre est bien scopée par fiche
(entity_id/year) en rechargeant la même URL.

test_marches_table_hscroll_bar_present (tests/test_tableau_hscroll.py)
ciblait aussi /acheteurs/123 pour couvrir .marches_table + table_hscroll.js
: ce comportement n'est plus exercé par aucune page migrée. Le seul repli
restant (observatoire.py, hors périmètre #41/Lot 2b) a un bug préexistant
sans rapport qui empêche son rendu en Selenium sur ce jeu de données ;
le test est donc marqué skip avec la justification détaillée, en attendant
soit la correction de ce bug séparé, soit le retrait de cette couverture.

test_003_tableau_download n'a pas nécessité de changement (callbacks
"toutes les données" inchangés).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:03:37 +02:00
Colin Maudry 7d413ccda4 feat(titulaire): migration du tableau des marchés vers AG Grid (#41) 2026-07-13 12:03:37 +02:00
Colin Maudry 91aeca6f04 docs(plan): allow_duplicate store columnState + smoke test forward-safe (Task 5/6) 2026-07-13 12:03:37 +02:00
Colin Maudry 639c2eb986 feat(acheteur): migration du tableau des marchés vers AG Grid (#41) 2026-07-13 12:03:37 +02:00
Colin Maudry 0d2ad33d6c fix(entity_grid): allow_duplicate sur le store columnState partagé (collision acheteur/titulaire) (#41)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:03:37 +02:00
Colin Maudry 2454c8109f feat(entity_grid): register_entity_grid_callbacks (datasource/reset/export/meta) (#41) 2026-07-13 12:03:37 +02:00
Colin Maudry 76c1096f4c feat(entity_grid): fonctions pures de grille scopée acheteur/titulaire (#41) 2026-07-13 12:03:37 +02:00
Colin Maudry 94addf7fae feat(figures): get_top_org_ag_grid (top 10 en AG Grid client-side) (#41) 2026-07-13 12:03:37 +02:00
Colin Maudry 0c22138e19 feat(figures): ag_grid() paramétrable (id dict + persisted_props) (#41)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:03:37 +02:00
Colin Maudry 495fe153b1 feat(grid): export_dataframe scopable par base_where_sql/base_params (#41) 2026-07-13 12:03:37 +02:00
Colin Maudry 9e0aa71910 docs: retire la modif CLAUDE.md du plan (déjà faite) 2026-07-13 12:03:37 +02:00
Colin Maudry f603d98ddb docs: plan d'implémentation migration AG Grid acheteur+titulaire (Lot 2a, #41) 2026-07-13 12:03:37 +02:00
Colin Maudry c9074462d6 docs: aligner la spec Lot 2a sur l'état actuel du code AG Grid (dev)
- ag_grid() à paramétrer (id dict + persisted_props), export_dataframe() à
  étendre (base_where_sql/base_params) : extensions rétro-compatibles Lot 1
- reset efface filtres ET tris (approche #47-safe, préserve largeur/ordre)
- compteur "X marchés (Y lignes)" reproduit depuis /tableau (total_unique)
- columnState relu depuis le store partagé (pas le State grille), anti-boucle
2026-07-13 12:03:37 +02:00
Colin Maudry 0e1991683b docs: spec migration AG Grid acheteur.py + titulaire.py (Lot 2a, #41) 2026-07-13 12:03:37 +02:00
Colin Maudry 10c1e45d71 Update version de Dash dans CLAUDE.md 2026-07-13 12:01:00 +02:00
Colin Maudry f2810640ba %àj instructions clients MCP 2026-07-13 09:25:39 +02:00
Colin Maudry 8d937d387a Changelog updated 2026-07-13 09:23:50 +02:00
Colin Maudry 04125f91f8 Ajout du tool schema_donnees 2026-07-13 09:23:33 +02:00
Colin Maudry b4e778ab8c Mode d'emploi tableau 2026-07-13 07:50:34 +02:00
Colin Maudry 445c8666cf Valeurs possibles dans la description des champs 2026-07-13 07:36:54 +02:00
Colin Maudry 26de687ece Améliorations sur le style de l'ag grid, ajout nb marchés uniques #47 2026-07-13 06:38:58 +02:00
Colin Maudry ebc4736025 Améliorations sur le style de l'ag grid, ajout nb marchés uniques #47 2026-07-12 20:45:38 +02:00
Colin Maudry 6e9b451734 feat(tableau): réserver l'espace de la barre de défilement horizontale AG Grid
alwaysShowHorizontalScroll force la piste de scroll à rester visible
en permanence (comportement déjà natif sous Chrome). Sans effet sur
Firefox, dont le curseur en overlay est géré par l'OS/le navigateur
et non contrôlable côté appli (#41).
2026-07-12 20:45:23 +02:00
Colin Maudry 6dd7dca271 Améliorations sur le style de l'ag grid, ajout nb marchés uniques #47 2026-07-12 20:44:43 +02:00
Colin Maudry f580459bd5 Améliorations sur le style de l'ag grid #47 2026-07-11 16:58:36 +02:00
Colin Maudry 117ce9a4ab fix(tableau): filtres à conditions multiples ignorés + tri non réinitialisé (#41)
Deux bugs découverts en usage réel sur /tableau :

1. filtermodel_to_ast lisait condition1/condition2 pour un filtre à
   deux conditions (ET/OU) sur une colonne — mais AG Grid >=29.2 (la
   version 35.2.0 utilisée ici) encode ça via une liste `conditions`,
   confirmé par la doc Dash AG Grid "Filter Model & Dash Callbacks" >
   "Filter Model Multiple Conditions". Le filtre était donc
   silencieusement ignoré (colonne exclue du AST, comme si aucun
   filtre n'était posé), d'où des résultats sans rapport avec les
   valeurs saisies. Corrigé : filtermodel_to_ast lit désormais
   `conditions` (N éléments) en priorité, avec repli sur
   condition1/condition2 (forme dépréciée mais "still accepted" selon
   AG Grid). L'inverse (ast_to_filtermodel, utilisé au rappel d'une
   vue sauvegardée) produit aussi la forme `conditions`.

2. Le bouton "Réinitialiser" ("Supprime tous les filtres et les tris")
   ne réinitialisait que filterModel, jamais le tri. Corrigé via
   resetColumnState (remet les colonnes à l'état de columnDefs, qui
   reflète déjà la visibilité choisie via le sélecteur de colonnes —
   seul le tri est donc affecté).

Reproduit et corrigé en TDD (tests/test_query_ast.py,
tests/test_grid.py), vérifié manuellement en navigateur par l'auteur
du rapport de bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:21:53 +02:00
Colin Maudry 4e07c69a85 feat(tableau): traduire les libellés de filtre AG Grid en français (#41)
Menu de filtre de colonne (Contient, Égal à, Vide, ET/OU, etc.) traduit
via l'option native AG Grid localeText, exposée par dashGridOptions.
N'affecte pas l'apparence de base conservée au Lot 1 (aucun thème/CSS
custom) — uniquement le texte des libellés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:43:13 +02:00
Colin Maudry cb205a89f3 fix(tests): copier users.test.sqlite vers une base jetable pour les tests
Les tests qui démarrent l'app complète (Selenium) écrivent dans la base
users pointée par USERS_DB_PATH (comptes créés, vues sauvegardées...).
Comme ce fichier était le fixture committé lui-même, chaque exécution de
la suite le laissait modifié dans l'arbre de travail — gênant pour les
commits et le nettoyage de worktree.

Même schéma déjà utilisé pour DUCKDB_PATH et DATA_SCHEMA_CACHE dans ce
fichier : le fixture committé est copié vers une base jetable gitignorée
(tests/users.runtime.sqlite) au chargement de conftest.py, et
USERS_DB_PATH pointe sur la copie. tests/users.test.sqlite reste
désormais toujours propre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:43:13 +02:00
Colin Maudry ea6998ac11 fix(mcp): isole le reload de src.app dans test_app_wiring (pollution inter-tests avec AG-Grid, #111) 2026-07-10 16:27:05 +02:00
Colin Maudry 34e5065550 Merge branch 'worktree-111-mcp-connecteur' into dev (connecteur MCP, scope B #111) 2026-07-10 16:13:06 +02:00
Colin Maudry d2e114da34 Merge branch '41-migration-ag-grid' into dev 2026-07-10 16:06:01 +02:00
Colin Maudry a9d9594a31 test(mcp): combler les lacunes de couverture relevées par la revue finale
Ajoute des tests de bout en bout pour les filtres montant/date/cpv, le
chemin titulaire de search_organisations, et la pagination (page>1,
clamping page=0). Normalise montant_total en float dans les deux
branches de compute_org_stats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry 5b7074529c feat(mcp): activer le serveur MCP via DASH_MCP_ENABLED (scope A #111) 2026-07-10 16:00:12 +02:00
Colin Maudry 3493dbba25 docs: ajouter l'entrée changelog pour le serveur MCP (#111)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry ed288823c9 feat(mcp): expose les 4 fonctions métier via @mcp_enabled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry 6b9cc412f7 feat(mcp): tools stats_acheteur / stats_titulaire (agrégations) 2026-07-10 16:00:12 +02:00
Colin Maudry 6cea1fdc95 feat(mcp): tool rechercher_marches (filtres hybrides + pagination) 2026-07-10 16:00:12 +02:00
Colin Maudry 98cbcd9311 test(mcp): vérifier l'extraction HTML->texte plain du nom (revue Task 4) 2026-07-10 16:00:12 +02:00
Colin Maudry 9bebc96c5d feat(mcp): tool search_organisations (résolution nom -> id)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry cd29c0358b feat(mcp): search_org accepte track=False pour ne pas polluer Matomo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry 89cc754a95 feat(mcp): helper track_mcp_tool pour tracer les appels MCP dans Matomo 2026-07-10 16:00:12 +02:00
Colin Maudry 4b3a2f5432 feat(mcp): sérialisation Polars -> JSON pour les tools MCP
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry 4c9bb87808 docs: plan d'implémentation tools MCP (scope A de #111)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry d29c245f9d docs: spec design tools MCP (scope A de #111)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:00:12 +02:00
Colin Maudry a8ba261e7c docs(mcp): config DASH_MCP_ENABLED + changelog connecteur (scope B #111) 2026-07-10 15:27:06 +02:00
Colin Maudry 71ad4dd1ca fix(mcp): apply_pending tolère l'absence de api_tokens (migration 0007, scope B #111) 2026-07-10 15:19:58 +02:00
Colin Maudry ff78b86ad0 feat(mcp): page compte Connecteur MCP + instructions clients (scope B #111)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:16:36 +02:00
Colin Maudry 4dbe986310 feat(mcp): blueprint création/révocation de jetons MCP (scope B #111) 2026-07-10 15:08:24 +02:00
Colin Maudry ec45dc05ec feat(mcp): l'API REST refuse les jetons kind=mcp (scope B #111) 2026-07-10 15:04:46 +02:00
Colin Maudry 4f18cc07f2 feat(mcp): câble le garde /_mcp + exemption CSRF dans l'app (scope B #111) 2026-07-10 15:00:53 +02:00
Colin Maudry 697711f676 feat(mcp): garde d'abonnement sur /_mcp (scope B #111) 2026-07-10 14:46:50 +02:00
Colin Maudry 4f36022443 fix(tableau): vues sauvegardées en AST canonique, cache du comptage, synchro visibilité colonnes (#41) 2026-07-10 14:44:14 +02:00
Colin Maudry 2318702e29 feat(mcp): migration 0007 kind + init schema jetons au démarrage (scope B #111) 2026-07-10 14:43:43 +02:00
Colin Maudry 647d7a7759 feat(mcp): colonne kind + fonctions jetons par utilisateur (scope B #111) 2026-07-10 14:39:02 +02:00
Colin Maudry 9314da7ee6 docs(mcp): plan d'implémentation du connecteur MCP (scope B #111) 2026-07-10 14:35:52 +02:00
Colin Maudry 0bfac680a5 fix(tableau): vues sauvegardées pré-migration + double comptage track_search (#41)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 14:34:47 +02:00
Colin Maudry 7941b08d4a docs(mcp): spec du connecteur MCP par jeton abonné (scope B #111) 2026-07-10 14:24:19 +02:00
Colin Maudry e59e2e50b3 test: adapter les tests DataTable historiques à la migration AG Grid de /tableau (#41) 2026-07-10 14:20:27 +02:00
Colin Maudry c81533aa8e test(tableau): intégration AG Grid end-to-end (#41)
Vérifie via Selenium/DashComposite que /tableau charge et affiche la
grille AG Grid avec au moins une ligne rendue, sans erreur console
SEVERE. Dernière tâche de la migration DataTable -> AG Grid.
2026-07-10 14:13:43 +02:00
Colin Maudry 055b0edc7e docs(tableau): mode d'emploi réécrit pour AG Grid (#41)
Décrit les filtres de colonne AG Grid (flottant/entonnoir, texte/numérique/
date, combinaison ET/OU), le tri multi-colonne (Maj+clic) et le défilement
infini, à la place de l'ancienne syntaxe icontains/i</i> et des liens
d'exemple ?filtres=... (mécanisme retiré à la tâche 10). Retire la ligne
"Partager la vue" de la légende des boutons (bouton supprimé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 13:53:19 +02:00
Colin Maudry 8efd8db07e feat(tableau): vues sauvegardées en JSON AST, retrait URL riche (#41)
Les vues stockent désormais {filterModel, columnState} en JSON dans
saved_views.query au lieu d'une query string DSL. Le rappel d'une vue
applique filterModel + columnState à la grille AG Grid via un nouveau
callback apply_saved_view, déclenché par pattern-matching sur les items
du menu "Mes vues" (désormais cliquables au lieu de liens href).

Retire restore_view_from_url, sync_url_and_reset_button,
show_confirmation, le bouton "Partager la vue" et le clientside
clean_filters de tableau.py - la migration AG Grid retire le mécanisme
d'URL riche sans rétrocompatibilité.

Le lien "Ouvrir" de compte/vues.py pointe maintenant vers /tableau nu
(limitation connue documentée, le rappel cross-page reste à faire).
2026-07-10 08:35:31 +02:00
Colin Maudry fbea2ebd53 feat(tableau): export Excel via DuckDB (AST->SQL) (#41)
Rewrite download_data to read filterModel/columnState from the AG Grid
component and add export_dataframe (compiles filterModel to SQL via
filtermodel_to_ast/ast_to_sql, builds ORDER BY, excludes hidden columns,
queries DuckDB via query_marches). Replaces the old Polars filter_query
pipeline tied to the removed DataTable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 08:26:10 +02:00
Colin Maudry 436906911b feat(tableau): sélecteur de colonnes piloté par columnDefs (#41)
apply_hidden_columns régénère columnDefs (via grid_column_defs) sur
tableau_grid quand le Store tableau-hidden-columns change, remplaçant
store_hidden_columns qui ciblait l'ancienne prop hidden_columns de la
DataTable (supprimée en tâche 7). update_checkboxes_from_hidden_columns
lit désormais directement le Store plutôt que la prop de la DataTable
disparue, pour que la synchronisation cases à cocher <-> colonnes
masquées continue de fonctionner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 08:17:44 +02:00
Colin Maudry 4c1c1cc987 feat(tableau): grille AG Grid + datasource getRows server-side (#41)
Remplace le composant DataTable et le callback update_table de tableau.py
par la grille dash-ag-grid (mode ligne infini) et un callback get_rows_tableau
piloté par getRowsRequest/getRowsResponse, plus un callback update_meta qui
synchronise nb_rows/btn-download-data/download-hint via un dcc.Store du total.

Les autres callbacks de la page (sélecteur de colonnes, vues sauvegardées,
export, restauration URL, aide) référencent encore les anciens
id/props (tableau_datatable, hidden_columns, filter_query, sort_by) et seront
mis à jour dans les tâches suivantes (8-11) du plan de migration #41.
2026-07-10 08:11:06 +02:00
Colin Maudry 97e1d0b5de feat(grid): columnDefs + fabrique ag_grid (#41) 2026-07-10 08:05:19 +02:00
Colin Maudry 2ce52d9eab feat(grid): fetch_grid_page datasource server-side AG Grid (#41) 2026-07-10 08:00:59 +02:00
Colin Maudry a7662a8561 feat(query): sort_model_to_sql + sérialisation AST (#41)
Ajoute sort_model_to_sql (adapte le sortModel AG Grid vers sort_by_to_sql
existant) et ast_to_dict/ast_from_dict pour le round-trip JSON de l'AST,
nécessaire aux vues sauvegardées.
2026-07-10 07:57:41 +02:00
Colin Maudry 33ea1205cf feat(query): filtermodel_to_ast (filterModel AG Grid -> AST) (#41) 2026-07-10 07:53:46 +02:00
Colin Maudry 979874760d fix(query): range sur colonnes non-numériques + blank/notBlank sur numérique/date (#41)
- `range` était ignoré silencieusement (TRUE, pas de filtre) sur les
  colonnes texte/date car seul `_numeric_to_sql` le gérait. Ajout du
  cas `range` dans la branche texte/date (BETWEEN, CAST VARCHAR pour
  les dates, comme les autres opérateurs de comparaison).
- `blank`/`notBlank` comparaient toujours à `''`, ce qui fait planter
  DuckDB (Conversion Error) sur les colonnes numériques/date. Le
  check est déplacé après le calcul de is_numeric/col_is_date : ces
  types utilisent IS [NOT] NULL sans comparaison à chaîne vide.
2026-07-10 07:49:43 +02:00
Colin Maudry 68f615370f feat(query): AST de filtre + compilateur ast_to_sql (#41)
Ajoute une représentation canonique du filtre sous forme d'AST booléen
(Condition/And/Or/Not) et son compilateur vers SQL DuckDB paramétré
(ast_to_sql). Réutilise tokenize_text_filter pour les feuilles texte.

Fondation pour la migration /tableau vers dash-ag-grid : cet AST sera
alimenté par le filterModel d'AG Grid (tâche suivante) et, plus tard,
par un champ de requête booléenne libre (#97).
2026-07-10 07:44:30 +02:00
Colin Maudry 7454d0db32 build: ajouter la dépendance dash-ag-grid (#41)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 07:41:19 +02:00
Colin Maudry 74c5de0bda docs(tableau): plan d'implémentation migration AG Grid Lot 1 (#41)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:27:30 +02:00
Colin Maudry 91d3153e10 docs(tableau): trancher scroll infini + export DuckDB dans le spec (#41)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:52:03 +02:00
Colin Maudry 41236df0fe docs(tableau): spec de design migration AG Grid (Lot 1, #41)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:45:41 +02:00
Colin Maudry d69ae9b8de Merge branch '111-mcp-tools' into dev 2026-07-09 21:53:57 +02:00
Colin Maudry 24bc1c08c1 test(mcp): combler les lacunes de couverture relevées par la revue finale
Ajoute des tests de bout en bout pour les filtres montant/date/cpv, le
chemin titulaire de search_organisations, et la pagination (page>1,
clamping page=0). Normalise montant_total en float dans les deux
branches de compute_org_stats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:43:26 +02:00
Colin Maudry 082d3e24b1 feat(mcp): activer le serveur MCP via DASH_MCP_ENABLED (scope A #111) 2026-07-09 20:50:14 +02:00
Colin Maudry 903b1e4dfd docs: ajouter l'entrée changelog pour le serveur MCP (#111)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:42:28 +02:00
Colin Maudry 5a6bd59297 feat(mcp): expose les 4 fonctions métier via @mcp_enabled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:35:24 +02:00
Colin Maudry dee6e37932 Liste des codes CPV 2026-07-09 20:32:29 +02:00
Colin Maudry ee98aa8186 feat(mcp): tools stats_acheteur / stats_titulaire (agrégations) 2026-07-09 20:30:16 +02:00
Colin Maudry 11da1e9d8d feat(mcp): tool rechercher_marches (filtres hybrides + pagination) 2026-07-09 20:23:05 +02:00
Colin Maudry 47694f2e5d test(mcp): vérifier l'extraction HTML->texte plain du nom (revue Task 4) 2026-07-09 20:18:28 +02:00
Colin Maudry fb4ffaa3e2 Revert "feat(mcp): search_org accepte track=False pour ne pas polluer Matomo"
This reverts commit ce9a79f420.
2026-07-09 20:16:08 +02:00
Colin Maudry ef1e4503c9 Cleanup commentaires .env 2026-07-09 20:14:37 +02:00
Colin Maudry e2d1082e92 feat(mcp): tool search_organisations (résolution nom -> id)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:13:17 +02:00
Colin Maudry 9303c5e206 feat(mcp): search_org accepte track=False pour ne pas polluer Matomo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:04:55 +02:00
Colin Maudry ce9a79f420 feat(mcp): search_org accepte track=False pour ne pas polluer Matomo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:01:57 +02:00
Colin Maudry 571c56ef71 feat(mcp): helper track_mcp_tool pour tracer les appels MCP dans Matomo 2026-07-09 19:57:09 +02:00
Colin Maudry e407f87529 feat(mcp): sérialisation Polars -> JSON pour les tools MCP
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 19:53:08 +02:00
Colin Maudry 6bc7540941 docs: plan d'implémentation tools MCP (scope A de #111)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:47:07 +02:00
Colin Maudry 11cada2ffc docs: spec design tools MCP (scope A de #111)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:36:45 +02:00
Colin Maudry 722694bec9 fix: remplacer la couleur d'accentuation Dash 4 par notre couleur primaire (#101)
Dash 4 utilise le token CSS --Dash-Fill-Interactive-Strong (violet par
défaut) pour l'état actif/focus des composants dcc (Dropdown, Input,
Tabs, DatePicker...). Surchargé au :root avec var(--primary-color).
2026-07-09 17:52:51 +02:00
Colin Maudry 9cf065e0db fix: traduire en français les libellés des dcc.Dropdown (#101)
Dash 4 introduit le prop labels sur dcc.Dropdown ("Select all",
"Deselect all", placeholder "Search", etc.). Ajout d'un dict partagé
DROPDOWN_LABELS_FR (src/utils/frontend.py) appliqué aux 11 dcc.Dropdown
de l'app (observatoire.py, acheteur.py, titulaire.py).
2026-07-09 17:41:05 +02:00
Colin Maudry 07bb4a91d0 fix: masquer les boutons +/- (steppers) sur les champs montant min/max (#101)
Dash 4 ajoute des boutons +/- aux dcc.Input type="number" ; inutiles pour
des montants et prennent de la place, retirés via .dash-input-stepper
(l'input récupère l'espace grâce à son flex: 1 1 0).
2026-07-09 17:30:59 +02:00
Colin Maudry d0e80b6be5 style: accepter le rendu DCC Dash 4 et retirer le CSS en conflit (#101)
Dash 4.4 remplace complètement le react-select v1 sous-jacent de
dcc.Dropdown par un nouveau composant (classes dash-dropdown-*) : les 11
Dropdown de l'app (acheteur.py, titulaire.py, observatoire.py, dont 5
multi=True) restent fonctionnels tels quels — ouverture, recherche,
sélection simple/multiple, fermeture. Le nouveau défaut
closeOnSelect=False sur les multi-select (menu qui reste ouvert après
sélection) est accepté sans correction, conformément au plan.

Retrait de la règle CSS .Select--multi .Select-value (style.css),
devenue totalement inerte : dcc.Dropdown ne rend plus ces classes
react-select, et aucune colonne dropdown de dash_table (admin) n'est
configurée en multi-select. Les autres règles .Select-* (Select-menu-outer,
Select-placeholder) sont conservées : elles servent encore l'éditeur de
cellules dropdown de dash_table (hors périmètre de cette migration).
2026-07-09 16:49:45 +02:00
Colin Maudry ab1efdd3b1 build: bump dash 3.4 -> 4.4 et résolution des dépendances (#101) 2026-07-09 16:30:01 +02:00
Colin Maudry 0950e5c375 docs(plan): plan d'implémentation de la migration Dash 4.4 (#101)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:15:10 +02:00
Colin Maudry cc1caa9671 docs(spec): design de la migration Dash 3.4 → 4.4 (#101)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:12:11 +02:00
Colin Maudry 0137981201 log l'env API_AUTH_DISABLED 2026-07-08 19:08:42 +02:00
Colin Maudry 25df072202 Changement d'icone 2026-07-08 15:02:53 +02:00
Colin Maudry 73b06c324a Changement titre page d'accueil 2026-07-07 21:26:36 +02:00
Colin Maudry 2376987982 fix(admin): revérifier is_admin() dans le callback _update_table (#110)
Le callback Dash _update_table est un endpoint serveur global
(/_dash-update-component) invocable indépendamment du layout : la garde
is_admin() de layout() ne protégeait que l'affichage. Sans contrôle dans
le callback, un non-admin — voire un anonyme sur le chemin lecture —
pouvait lire/écrire toute la base SQLite utilisateurs.

Ajoute la garde en tête du callback + test de non-régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:47:11 +02:00
Colin Maudry 2150ade42b test(abonnement): adapter les tests de _reabo_button à sa nouvelle signature
_reabo_button() ne prend plus has_used_trial (bouton de réabonnement
simplifié) ; les deux anciens tests testant M'abonner/Me réabonner
sont remplacés par un test du comportement actuel.
2026-07-06 11:01:32 +02:00
Colin Maudry 92e30979d9 fix(abonnement): erreur Dash au chargement de mes-infos en mode configure
Le callback _toggle_submit référence inf-cb-retractation/inf-cb-cgu en
Input sans condition, mais ces cases n'étaient rendues qu'en mode
"subscribe". Elles sont maintenant toujours montées (masquées et
pré-cochées en mode configure) pour que Dash trouve toujours ces ids.
2026-07-06 11:01:26 +02:00
Colin Maudry ad028b9e6b Rédactionnel 2026-07-06 10:49:08 +02:00
Colin Maudry fb3292d41e feat(admin): afficher l'email dans subscriptions et subscriber_state
user_id seul n'est pas assez parlant pour débugger ; jointure LEFT JOIN
sur users pour afficher l'email en colonne (lecture seule).
2026-07-06 10:42:53 +02:00
Colin Maudry e8f74aa529 feat(abonnement): message 'changement à la prochaine échéance' sous les cards (#109)
Ajoute la fonction _change_hint et étend le callback _select_plan pour afficher
un hint "changement à la prochaine échéance" quand l'utilisateur sélectionne
un plan différent du plan courant.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-05 20:46:54 +02:00
Colin Maudry 7ae5b75db9 feat(abonnement): mode configure sur mes-infos (formule préactivée, sans cases) (#109)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 20:42:09 +02:00
Colin Maudry a2962c2163 feat(abonnement): prix + bouton Configurer sur /compte/abonnement (#109)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-05 20:35:50 +02:00
Colin Maudry b04f97cb8c feat(abonnement): route /subscriptions/update (maj formule + facturation) (#109)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 20:31:42 +02:00
Colin Maudry 3645c098c2 feat(abonnement): client.change_subscription (PUT /v1/subscription) (#109)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-05 20:28:11 +02:00
Colin Maudry ee1f64a53b docs(abonnement): plan d'implémentation changement d'abonnement (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:19:52 +02:00
Colin Maudry 6610f2d73e docs(abonnement): design changement d'abonnement depuis /compte/abonnement (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:13:48 +02:00
Colin Maudry ee214677bd feat(abonnement): bouton changer de méthode de paiement sur /compte/abonnement
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 17:03:28 +02:00
Colin Maudry 081811bea7 feat(abonnement): route /subscriptions/change-payment-method 2026-07-05 16:52:16 +02:00
Colin Maudry ae98e2cec4 docs(abonnement): plan d'implémentation changement de méthode de paiement (#108) 2026-07-05 16:48:37 +02:00
Colin Maudry 1cfb3e91e2 feat(abonnement): client.get_payment_info_url pour le changement de carte Frisbii 2026-07-05 16:46:38 +02:00
Colin Maudry 7664fe368d docs(abonnement): design changement de méthode de paiement (#108) 2026-07-05 16:39:40 +02:00
Colin Maudry 3e3ba7c4f8 Utilisation de uv and pre-commmit avant git add dans CLAUDE 2026-07-05 00:03:06 +02:00
Colin Maudry 349fb17c90 feat(abonnement): sélection de formule par cartes cliquables dans mes-infos
Remplace la garde ?plan= (redirection si absent) par une sélection
interactive : deux cartes de formule (réutilisées de la page publique via
_plan_card) cliquables, un callback recopie le choix dans un champ caché
natif soumis avec le POST du formulaire. Le bouton de soumission reste
désactivé tant qu'aucune formule n'est choisie (en plus des cases à
cocher existantes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:43:39 +02:00
Colin Maudry 315c500005 feat(abonnement): CTA connexion vers /a-propos/abonnement
Remplace le CTA du bas de /connexion ("Créer un compte avec mon adresse
email" → "/inscription") par un lien vers l'offre d'abonnement ("Pas encore
de compte ? Voir les abonnements" → "/a-propos/abonnement").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:39:06 +02:00
Colin Maudry 3b744d87f0 feat(abonnement): /compte/abonnement non-abonné affiche M'abonner/Me réabonner 2026-07-04 23:34:35 +02:00
Colin Maudry c5c63cb68e fix(abonnement): calcul du prix TTC robuste (évite les artefacts flottants)
Remplace le hack `str(int(prix_ht) * 1.2).replace('.0', '')` par
`round(prix_ht * 1.2, 2)` formaté avec `:g`, qui gère correctement les
prix qui ne multiplient pas proprement par 1.2 (ex: 24 -> 28.8 au lieu
de 28.799999999999997).

Le fichier src/pages/compte/abonnement.py contient le même bug mais
n'est pas touché ici : sa copie de _plan_card sera supprimée à la
tâche 4 du plan de refonte du tunnel d'abonnement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:28:14 +02:00
Colin Maudry e904ec4a7d feat(abonnement): page publique avec cards et bouton Je m'abonne conditionnel
La page publique /a-propos/abonnement affiche désormais les cards de
formule (informatives, sans bouton par card), un explainer des
fonctionnalités, et un unique CTA "Je m'abonne" dont la cible dépend
de l'état d'authentification/abonnement. Retrait de la sous-section
"Fonctionnalités incluses" des CGU (doublon avec l'explainer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:21:19 +02:00
Colin Maudry 6464baebd2 feat(auth): auto-login à la validation d'email, redirection vers mes-infos
Quand l'utilisateur valide son email, il est maintenant connecté automatiquement
via login_user() et redirigé directement vers /compte/abonnement/mes-infos
au lieu de /connexion?verified=1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:16:29 +02:00
Colin Maudry cc2b34e84a feat(abonnement): linkedin_button paramétrable par next, inscription vers mes-infos
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:13:11 +02:00
Colin Maudry a24a96288d Task 6 : sélection de formule par cartes cliquables (au lieu de RadioItems)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:05:32 +02:00
Colin Maudry 38743cfb95 Plan d'implémentation refonte tunnel abonnement (+ correction spec radios Dash 3.4)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:56:29 +02:00
Colin Maudry b89da05c84 Spec : refonte du tunnel d'abonnement (offre publique)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:46:43 +02:00
Colin Maudry e44e2940fc Tous les libellés de sources apparaissent dans la matrice 2026-07-04 20:50:30 +02:00
Colin Maudry c748cb944e Déplacement de la matrice de doublon vers Données 2026-07-04 20:44:41 +02:00
Colin Maudry c9ece32601 Rédactionnel, tableau étapes dans Donnéees 2026-07-04 20:19:51 +02:00
Colin Maudry 6256adf826 Taille du point de l'org 2026-07-03 23:31:24 +02:00
Colin Maudry 1551faaf0c Normaliser les échecs d'écriture set_cell en ValueError
- IntegrityError (ex: email déjà utilisé) est désormais capturée et
  reconvertie en ValueError, pour rester dans le funnel d'alerte
  existant du callback admin au lieu de faire planter le callback Dash.
- Une UPDATE qui touche 0 ligne (ligne supprimée entre le chargement du
  tableau et la soumission de l'édition) lève désormais une ValueError
  au lieu d'être silencieusement traitée comme un succès (ce qui aurait
  créé un log d'audit trompeur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:10:32 +02:00
Colin Maudry eb8f54dbf2 fix(figures): calculer center/zoom côté serveur au lieu d'utiliser bounds
La prop `bounds` de dash-leaflet lève une exception JS (TypeError sur
.equals() avec une valeur précédente indéfinie) au tout premier montage
du composant Map — confirmé via la stack trace navigateur, reproductible
même en dernière version (1.1.3). Cette exception laissait le clustering
dans un état incohérent (declustering impossible avant un zoom complet
suivi d'un dézoom).

bounds_to_center_zoom calcule un center/zoom approximatifs (projection
Web Mercator, taille de conteneur supposée) pour cadrer l'organisme et
ses contreparties, en évitant complètement la prop `bounds` bugguée.
Cadrage moins précis qu'un vrai fitBounds() navigateur, mais robuste.
2026-07-03 22:54:21 +02:00
Colin Maudry 6739e4926b test(admin): add end-to-end Selenium coverage for the generic table editor
Covers anonymous/non-admin access (404), and the full admin flow: switch
table, edit a subscriptions.prix_ht cell through the real DataTable UI,
verify the write lands in the DB, and verify the edit is logged and
visible in the admin_actions table.

tests/users.test.sqlite is committed and shared by the whole Selenium
session, so _cleanup_user deletes every row the tests create. Beyond
resetting sqlite_sequence (already needed for the AUTOINCREMENT
counter), a plain DELETE also leaves stale, never-zeroed bytes behind
in the admin_actions/subscriptions b-tree pages once they go back to
zero rows, which byte-diffs the file even though its logical content
is unchanged. VACUUM rebuilds the file from live data only, producing
a deterministic page layout (verified empirically across independent
runs with different random test data). Re-baselined the fixture to
that canonical vacuumed state so `git status` stays clean after
running the suite.
2026-07-03 22:43:53 +02:00
Colin Maudry 9d3b0dd068 fix(figures): réactiver l'animation du fitBounds sur les cartes organisme
animate: False (ajouté pour contourner le point organisme surdimensionné,
désormais résolu via un rendu en icône) empêchait apparemment la
bibliothèque de clustering de recalculer correctement son arbre de
clusters par niveau de zoom au montage, bloquant le declustering des
contreparties jusqu'à un zoom complet suivi d'un dézoom.
2026-07-03 22:34:26 +02:00
Colin Maudry e763a0b878 Taille du point de l'org 2026-07-03 22:29:04 +02:00
Colin Maudry 86977bd37e fix(figures): rendre le point de l'organisme en icône plutôt qu'en circleMarker
bringToFront() sur un circleMarker SVG (overlayPane) ne pouvait pas passer
devant les bulles de cluster (markerPane, toujours au-dessus dans l'ordre
des panes Leaflet), et le rendu SVG se redimensionnait visuellement pendant
l'animation de zoom. Le point is_home est maintenant un L.marker + divIcon
(comme les clusters), avec zIndexOffset élevé : toujours au premier plan et
taille constante quel que soit le zoom.
2026-07-03 22:17:54 +02:00
Colin Maudry f1c28acc96 feat(figures): mettre en avant le point de l'organisme consulté sur la carte
Marque is_home=True sur les marqueurs du home_type (acheteur ou titulaire
selon la page) dans get_org_location_map. Côté client, pointToLayer donne
à ce point un rayon légèrement plus grand (8 vs 5) et le ramène toujours
au premier plan (bringToFront), indépendamment de l'ordre des couches.
2026-07-03 22:08:43 +02:00
Colin Maudry a96f772d7a fix(figures): désactiver l'animation du fitBounds sur les cartes organisme
Un fitBounds animé interrompu (deux Input sur pathname+année pouvant
re-déclencher le callback quasi simultanément) laissait le transform CSS
de zoom de Leaflet figé à une grande échelle (ex: scale(16)), faisant
apparaître les marqueurs individuels démesurément gros sur /acheteurs et
/titulaires. Constaté en vérification manuelle sur test.colibre.fr.
2026-07-03 16:43:04 +02:00
Colin Maudry 22dd49f337 docs(figures): corriger le commentaire d'ordre des couches GeoJSON
Le commentaire affirmait à tort que l'organisme consulté est toujours
peint au-dessus de sa contrepartie ; l'ordre est en réalité fixe
(titulaire puis acheteur), indépendant de home_type. Relevé par la
revue finale de branche.
2026-07-03 16:15:13 +02:00
Colin Maudry acb8d5e0d1 feat(titulaire): afficher les acheteurs sur la carte de la fiche titulaire
Supprime point_on_map, devenue inutilisée après migration des deux pages
vers get_org_location_map.
2026-07-03 16:10:02 +02:00
Colin Maudry d201b7a3c6 feat(acheteur): afficher les titulaires sur la carte de la fiche acheteur 2026-07-03 16:05:05 +02:00
Colin Maudry 8b69fee31c feat(figures): ajouter get_org_location_map (carte cluster organisme + contrepartie) 2026-07-03 15:59:47 +02:00
Colin Maudry 21d3a30d8b refactor(figures): extraite build_org_markers pour réutilisation 2026-07-03 15:55:29 +02:00
Colin Maudry ad872d3b2c docs: ajouter le plan d'implémentation des cartes acheteur/titulaire 2026-07-03 15:48:32 +02:00
Colin Maudry f301c2ab0f docs: ajouter le design des cartes acheteur/titulaire avec contrepartie 2026-07-03 15:15:46 +02:00
Colin Maudry caf800e5e8 fix(admin): guard find_changed_cell against cross-table schema mismatch
When switching tables, the table-switch branch writes fresh data for the
new table, which re-fires the same callback with data_previous still
holding the old table's rows. find_changed_cell only checked row count
before diffing, so if the two tables happened to have the same number of
rows it would zip mismatched-schema dicts and report a spurious changed
cell (usually the PK column), producing a confusing red alert right after
switching tables.
2026-07-03 14:46:12 +02:00
Colin Maudry d229b58f3b feat(admin): replace dedicated pages with a generic table editor at /admin 2026-07-03 14:19:01 +02:00
Colin Maudry cf23863a30 refactor(admin): apply formatting fixes from linter hooks 2026-07-03 13:04:15 +02:00
Colin Maudry 19991dd22f Ajouter le plan d'implémentation de l'éditeur générique de tables (/admin) 2026-07-03 12:34:22 +02:00
Colin Maudry 6bec76535c Remplacer le design du panneau admin par un éditeur générique de tables
Pivot avant merge : au lieu de pages dédiées par cas d'usage, une seule
page /admin avec sélecteur de table + édition de cellule DataTable,
plus facile à faire évoluer au fil des besoins de support.
2026-07-03 12:27:04 +02:00
Colin Maudry cfcbfe9768 Ajouter la colonne handle Frisbii à l'historique d'abonnements (admin) 2026-07-03 11:55:51 +02:00
Colin Maudry 8eb5c06198 Changelog v3.0.0 2026-07-03 11:44:59 +02:00
Colin Maudry 43122a7c12 Rédactionnel dans la page d'abonnements 2026-07-03 11:43:41 +02:00
Colin Maudry 348da79175 test(admin): verify login succeeded in non-admin 404 test
test_admin_non_admin_gets_404 asserted the same /admin 404 that
anonymous visitors also get, without first confirming the login
actually went through. A broken login (falls back to /connexion on
bad credentials, unverified email, etc.) would leave the session
anonymous and the test would keep passing for the wrong reason,
silently degrading into a duplicate of test_admin_anonymous_gets_404.
Now waits for the post-login redirect to /compte/abonnement (this
user has no subscription) before exercising the admin guard.
2026-07-03 10:44:31 +02:00
Colin Maudry 7cc1fadf0f test(admin): add end-to-end Selenium coverage for the admin panel
Covers anonymous → 404, non-admin → 404, and the full admin flow
(list, detail, status change, journal) through a real login and a
real running app. tests/users.test.sqlite is committed and shared by
the whole Selenium session, so the test cleanup also resets the
sqlite_sequence high-water marks that plain DELETEs don't roll back,
keeping the file byte-stable across runs. This run additionally bakes
in migration 0006_create_admin_actions (new admin_actions table), the
first time any Selenium test has booted the real app since that
migration was added — the same one-time process by which migrations
0001-0005 already ended up committed in this fixture.
2026-07-03 10:34:35 +02:00
Colin Maudry 5d56f8180a feat(admin): add /admin/journal audit log page 2026-07-03 10:24:40 +02:00
Colin Maudry f8d1e60519 feat(admin): add /admin/user/<user_id> detail page 2026-07-03 10:20:17 +02:00
Colin Maudry 96358423df feat(admin): add /admin user list page 2026-07-03 10:13:04 +02:00
Colin Maudry e483d7af4d feat(admin): add subscription-status mutation route
Wires is_admin(), SUBSCRIPTION_STATUSES/get_current/set_status, and
log_action() into POST /admin/actions/subscription-status: validates
the requested status and that subscription_id matches the user's
current subscription, applies the change, and logs an audit entry.
Registers the admin blueprint in init_auth() and documents ADMIN_EMAIL
in .template.env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:46:22 +02:00
Colin Maudry 2f2cd151fb feat(admin): add is_admin() access guard
Implement access control function for admin panel. Returns True only if
ADMIN_EMAIL env var is set, user is authenticated, and email matches
case-insensitively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:41:57 +02:00
Colin Maudry c4851ff0ae refactor: format test_db.py for consistency
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:38:46 +02:00
Colin Maudry fb62c28e10 feat(admin): add admin_actions audit table and log/list functions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:38:33 +02:00
Colin Maudry 0209ec0d5c feat(admin): add subscription history, status override, and statuses constant
Adds four new DB helpers for the admin panel:
- SUBSCRIPTION_STATUSES: tuple of valid subscription statuses
- list_by_user(): retrieve all subscriptions for a user (newest first)
- set_status(): override a subscription's status and updated_at timestamp
- get_subscriber_state(): public wrapper for internal _get_state()

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:34:09 +02:00
Colin Maudry 9ea8bee940 feat(admin): add list_users() to auth DB layer
Implements list_users(limit: int = 1000) -> list[sqlite3.Row] in the auth
DB layer to retrieve all users ordered by creation date (most recent first).

- Returns all users, optionally capped at limit (default 1000)
- Orders by created_at DESC to show newest users first
- Follows existing DB layer patterns using get_conn().execute().fetchall()

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:29:03 +02:00
Colin Maudry e820041cef Ajouter le plan d'implémentation du panneau admin (/admin) 2026-07-03 09:19:15 +02:00
Colin Maudry e678399fe7 Préciser la pagination native (20/page) du tableau /admin 2026-07-03 09:07:27 +02:00
Colin Maudry 2cbee97afb Ajouter le spec du panneau admin interne (/admin)
Design pour un panneau de support/débug (liste des comptes, historique
d'abonnements, correction manuelle de statut) protégé par ADMIN_EMAIL,
avec journal d'audit en base plutôt qu'en logs applicatifs.
2026-07-03 09:02:14 +02:00
Colin Maudry 0b88a65414 Utiliser le Checkout API pour la session de souscription (accept_url/cancel_url)
hosted_page_links.payment_info (POST /v1/subscription) n'honore pas
accept_url/cancel_url malgré la doc, même passés en query string (constaté
en test sur test.colibre.fr). On génère maintenant la page de paiement via
POST /v1/session/subscription (Checkout API), qui accepte ces champs dans
son body et redirige effectivement le navigateur — même mécanisme déjà
fonctionnel pour l'ajout de moyen de paiement (create_recurring_session).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 18:21:05 +02:00
Colin Maudry 14ae1a7bfb Gérer l'affichage d'un abonnement expiré (statut Frisbii "expired")
_show_active_view affichait par erreur la vue "abonnement actif" (avec un
faux "Prochaine facturation") pour un abonnement expiré. Un abonnement
expiré bascule maintenant sur l'écran de re-souscription, avec une alerte
dédiée. Jamais remarqué jusqu'ici car les webhooks Frisbii ne remontaient
pas ce changement de statut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 18:13:12 +02:00
Colin Maudry 1159697c0e Fix redirection Frisbii après ajout de moyen de paiement au premier abonnement
accept_url/cancel_url n'existent pas dans le schéma de POST /v1/subscription
(ils y sont silencieusement ignorés) : Frisbii attend ces paramètres en
query string sur le lien hosted_page_links.payment_info retourné. Ajout
d'un warning loggé sur signature de webhook invalide, jusque-là silencieuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 17:56:19 +02:00
Colin Maudry ea20491e9c Changements rédactionnels : abonnement par virement, avantage abonnements 2026-07-02 16:50:33 +02:00
Colin Maudry 8a428ac1aa Ajout d'une conf de redirection nginx de test 2026-07-02 16:49:03 +02:00
Colin Maudry d08f7aeeac Nombre de jours d'essai en dur plutot que via appels HTTP 2026-07-02 13:55:22 +02:00
Colin Maudry 1bdcaaf5d8 Réactivation de l'env MAIL_SUPPRESS_SEND 2026-07-02 13:42:05 +02:00
Colin Maudry a38b2d5e71 Remplacement du Loading... screen 2026-07-02 12:03:50 +02:00
Colin Maudry 74afb7aef8 Nettoayge 2026-07-02 11:46:42 +02:00
Colin Maudry 2ed560d4e8 Nettoayge 2026-07-02 11:32:45 +02:00
Colin Maudry cd107a0213 Refactor structure modules pages/compte, petits changements de texte 2026-07-02 10:53:01 +02:00
Colin Maudry 9ff2373ccb decp.info => colibre 2026-07-01 18:00:36 +02:00
Colin Maudry 126834c7d4 fix(subscriptions): un abonnement en échec (failed) ne bloque plus le réabonnement 2026-07-01 16:56:34 +02:00
Colin Maudry 872fc33dd7 feat(subscriptions): cancel/webhook/add_payment_callback sur get_current et customer_known 2026-07-01 16:41:57 +02:00
Colin Maudry 0ffe647657 feat(subscriptions): subscribe() génère et transmet le handle, mark_failed en cas d'échec 2026-07-01 14:37:25 +02:00
Colin Maudry 179e6e436d feat(subscriptions): create_subscription_session prend le handle en paramètre 2026-07-01 14:34:09 +02:00
Colin Maudry 64158e9080 feat(subscriptions): historique multi-lignes + subscriber_state + handle abo-{user_id}-N
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:29:47 +02:00
Colin Maudry f52b2b7b71 docs: plan d'implémentation pour le handle d'abonnement et l'historique
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:24:42 +02:00
Colin Maudry b077fd318d docs: note la vérification de méthode de paiement réelle comme suivi séparé
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:13:39 +02:00
Colin Maudry 17c379f0b3 docs: design pour le handle d'abonnement personnalisé et l'historique des abonnements
Spec pour remplacer generate_handle par un handle abo-{user_id}-N unique,
ce qui nécessite de scinder subscriptions en historique multi-lignes et
d'introduire subscriber_state pour l'état cumulatif (votes, essai).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:12:11 +02:00
Colin Maudry a2c20adb4e perf: paginer/agréger les pages acheteur et titulaire côté DuckDB
Les pages acheteur/titulaire chargeaient l'intégralité des marchés d'une
organisation dans un dcc.Store côté client (jusqu'à 16k lignes pour les
plus gros acheteurs), envoyée sur le réseau à chaque interaction. Le
tableau, le top 10 et l'histogramme des distances récupèrent maintenant
leurs données via des requêtes DuckDB scopées (pagination/agrégation
poussées en SQL), sur le modèle déjà utilisé par la page tableau.

Corrige aussi le CLS des mêmes pages en réservant l'espace des
conteneurs remplis par callback (carte, top 10, histogramme).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 13:46:44 +02:00
Colin Maudry 8515ba8d84 refactor: replace all decpinfo with colibre in test suite
Update all client and fixture references throughout the test suite
to use colibre branding instead of the legacy decpinfo naming.

- test_auth.py: token format
- benchmark.py: token format documentation
- test_routes.py: customer handles
- test_client.py: customer identifiers
- test_db.py: all database fixtures

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-01 11:19:25 +02:00
Colin Maudry 190b8154b4 refactor: replace decpinfo with colibre in test client identifiers
Update test fixtures and client references to use colibre branding
instead of the legacy decpinfo naming convention.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-01 11:17:18 +02:00
Colin Maudry a1bf0b381b fix: reduce subscribe button width to fit content
Adjust the width of the 'S'abonner' button to match the label size
using CSS width: fit-content property.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-01 11:10:19 +02:00
Colin Maudry 63a7ff3d89 Petites améliorations de formulation et d'UI 2026-07-01 10:17:25 +02:00
Colin Maudry 502d3b390a Icones colibre temporaires #57 2026-06-30 23:09:26 +02:00
Colin Maudry 2e9888998a Bump 3.0.0 2026-06-30 23:07:50 +02:00
Colin Maudry 7184b7768a feat(seo): index de sitemaps acheteurs/titulaires, canonical, robots
- robots.txt déclare désormais le sitemap
- /sitemap.xml devient un index de sous-sitemaps paginés (limite 50k URLs/fichier)
  générés depuis DuckDB et mis en cache 24h : pages statiques, acheteurs, titulaires
- balise canonical auto-référente (JS, car index_string Dash partagé)
- config nginx de référence : 301 decp.info → colibre.fr (chemin préservé)
- tests SEO via test client Flask

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:06:46 +02:00
Colin Maudry 9e8ad6c74b decp.info => colibre dans llms.md #57 2026-06-30 22:48:13 +02:00
Colin Maudry 41491159a3 Ajouts des icones colibre #57 2026-06-30 22:41:19 +02:00
Colin Maudry 64d4821fae fix(tests): met à jour le préfixe de token decpinfo_ → colibre_ #57
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 21:49:51 +02:00
Colin Maudry 9b124deeea refactor: rebrand decp.info to colibre #57
- Rename project from decp.info to colibre across all codebase
- Update domain from https://decp.info to https://colibre.fr
- Update GitHub repo references to ColinMaudry/colibre
- Rename deployment files: decpinfo-backup.* → colibre-backup.*
- Update project configuration and documentation
- Rename project assets: decp.info.png → colibre.png
- Update environment variables and constants (DOMAIN_NAME, TOKEN_PREFIX, GITHUB_REPO, etc.)
- Update URLs in all pages, tests, and configuration files
- Keep DECP acronym in text (unchanged per requirements)
- Add rebrand note to README.md

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-30 21:43:33 +02:00
Colin Maudry b46d5d8244 Redirection llms.txt pour les LLMS 2026-06-30 21:02:14 +02:00
Colin Maudry d8faccab46 fix: corrige bug build_database + fiabilise et isole la suite de tests
src/db.py : le remplacement des noms d'organisation nuls utilisait
.name.keep() sur une expression concat_str référençant *_id ; Polars nommait
alors le résultat d'après *_id, écrasant la colonne titulaire_id/acheteur_id et
laissant *_nom inchangée. Remplacé par .alias(col).

Suite de tests — élimination de la pollution inter-tests (callbacks/conn
globaux Dash) qui faisait échouer ~15 tests Selenium en exécution complète :
- tests/conftest.py : DUCKDB_PATH et DATA_SCHEMA_CACHE pointent sur des chemins
  de test isolés (tests/decp.duckdb, tests/schema.cache.json, gitignorés) — on
  ne touche plus jamais aux fichiers versionnés decp.duckdb et
  schema.fixture.json (qui étaient mutés et cassaient la collecte au run
  suivant). Viewport Chrome élargi à 1600px.
- tests/test_db.py : fixture module-scoped qui recharge src.db après le module
  (test_query_marches faisait importlib.reload vers une DB temporaire au schéma
  réduit, polluant le conn global → ColumnNotFoundError ensuite). Assertion mise
  à jour pour le nouveau libellé "[Inconnu de l'INSEE (...)]".

Tests périmés / fragiles corrigés :
- tests/auth/test_oauth_routes.py : mock LinkedIn aligné sur la route (userinfo
  récupéré via .get() séparé) ; destination post-login /compte/abonnement.
- tests/test_main.py : test_002 filtre sur dateNotification (uid retiré du
  tableau), scrollIntoView (colonne hors viewport), attentes de l'application du
  filtre et de la restauration de la persistance ; test_015 via
  wait_for_no_elements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:22:27 +02:00
Colin Maudry 60b59d2d03 Précision sur les SIRET inconnu 2026-06-30 17:34:27 +02:00
Colin Maudry 5a64f0f682 Ordre des sections 2026-06-30 17:34:08 +02:00
Colin Maudry 69a8b847ae style(toolbar): aligne le style des boutons acheteur/titulaire/observatoire sur tableau.py
- table-menu → table-toolbar sur les 3 vues
- Boutons colonnes/télécharger : color=secondary size=sm ; html.Button → dbc.Button
- Bouton reset : color=danger outline=True size=sm, label "Réinitialiser"
- Compteur nb_rows sorti du toolbar vers un div.table-meta séparé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 17:19:39 +02:00
Colin Maudry 8825e76101 Possible de remplacer une vue existante #95 2026-06-30 16:59:37 +02:00
Colin Maudry c4aa90b0d1 feat: améliorations UX roadmap votes #94
- Votes cappés à VOTES_PER_WEEK (pas d'accumulation) pour valoriser
  les connexions régulières
- Solde de votes affiché dans la liste (input inéditable) à la place
  du bandeau Alert, avec date de prochain rechargement
- Compteur de votes par feature affiché comme input inéditable en
  fin de ligne (avant le bouton "+")
- Animation FLIP JS (roadmap_flip.js) : seules les lignes qui changent
  de position sont animées après un vote
- Renommage votes_credited_until → votes_last_credited_at (migration 0005)
- Constantes du module utilisées dans les assertions de tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 16:39:13 +02:00
Colin Maudry f04e1b4c44 Petites améliorations tableau.py 2026-06-30 15:14:43 +02:00
Colin Maudry 9a93ec986a fix: guard is_authenticated dans cast_vote #94 2026-06-30 14:37:54 +02:00
Colin Maudry 78534000a0 refactor(tableau): barre d'outils compacte sur une ligne + légende d'aide
Barre d'outils repensée au-dessus du tableau :
- boutons compacts (size=sm) sur une seule rangée + ligne d'infos discrète
  en dessous (2 lignes au total sur desktop)
- libellés explicites (Sauvegarder la vue, Partager la vue, Télécharger
  (Excel)), espacement homogène, couleurs conformes à la charte
  (neutre=secondary, Réinitialiser=danger outline)
- Mode d'emploi rejeté en fin de barre via CSS order (DOM/callbacks intacts)
- .table-menu (3 autres pages) laissée intacte

Téléchargement bloqué (>65 000 lignes) : la raison s'affiche en clair dans
la ligne d'infos (fiable cross-browser) plutôt que dans une infobulle sur
bouton désactivé. Override scopé au tableau via update_table ; acheteur/
titulaire non touchés.

Mode d'emploi : légende en tête listant chaque bouton reproduit (inerte)
en face de sa fonction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:33:28 +02:00
Colin Maudry ce3c3a5e6f feat: le lien de version pointe vers /a-propos/roadmap #94 2026-06-30 14:30:07 +02:00
Colin Maudry 9bed4e3fb9 feat: page publique /a-propos/roadmap (lecture seule) #94 2026-06-30 14:25:59 +02:00
Colin Maudry 8161a8b8b4 fix: test_visible_sections vérifie roadmap au lieu d'archives #94
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 14:20:51 +02:00
Colin Maudry 5b9faa5a76 feat: page abonné /compte/roadmap avec vote #94 2026-06-30 14:19:34 +02:00
Colin Maudry 147c943cdf fix: vote_counts() dans le bloc try/except de roadmap_content #94
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 14:17:04 +02:00
Colin Maudry 0403d5212e feat: composants d'affichage de la roadmap #94 2026-06-30 14:14:17 +02:00
Colin Maudry 50e3299ffb feat: récupération cachée des issues roadmap GitHub #94 2026-06-30 14:05:28 +02:00
Colin Maudry fb45c8e7d5 feat: registre des votes feature_votes #94
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:55:28 +02:00
Colin Maudry d98e7313de feat: gel des votes au désabonnement/réabonnement #94 2026-06-30 13:48:44 +02:00
Colin Maudry 04e6fca765 feat: accumulation paresseuse et dépense de votes #94 2026-06-30 13:44:25 +02:00
Colin Maudry 4b754c9d77 feat: colonnes de votes sur subscriptions #94
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:41:37 +02:00
Colin Maudry b7ca9c1ffa docs: plan implémentation vote roadmap #94
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:30:58 +02:00
Colin Maudry 0cfafedeef feat: charte graphique des boutons (primary/secondary/danger)
Override CSS scopé aux classes .btn-* (jamais les variables --bs-* racine,
donc alertes/badges inchangés) pour remplacer les couleurs dérivées du thème
Simplex (danger mauve, secondary gris illisible). Trois rôles : la couleur
encode la fonction, le remplissage l'emphase.

- primary terracotta : action principale validante
- secondary gris ardoise : action neutre/alternative (lisible)
- danger rouge #c0392b : destructif (outline par défaut, plein en confirmation)

Audit + conformation des usages :
- compte_admin « Supprimer mon compte » : primary outline -> danger outline
- compte_abonnement « Me désabonner » : outline-primary -> outline-danger
- compte_abonnement « Je suis sûr » : outline-primary -> danger plein

Tests : ajout tests/test_boutons.py (garde de non-régression du primary).
conftest : implémente le hook pytest_setup_options (--headless=new) ; la
fixture chrome_options n'était jamais utilisée par dash.testing, d'où les
fenêtres Chrome qui s'ouvraient pendant les tests.

Specs et plan : docs/superpowers/{specs,plans}/2026-06-30-charte-boutons*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:21:24 +02:00
Colin Maudry d436fd4d9e docs: fix chemin src/utils dans spec roadmap #94
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:19:02 +02:00
Colin Maudry 5578a9deb0 docs: spec design vote roadmap #94
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:17:44 +02:00
Colin Maudry f39c54210f Nettoyages et ajustements UI 2026-06-29 17:15:36 +02:00
Colin Maudry dc30d889c4 Merge vues sauvegardées #95 2026-06-29 16:54:11 +02:00
Colin Maudry d2f23ae6c8 Amélioration des tests 2026-06-29 16:53:19 +02:00
Colin Maudry b2806d6176 fix: erreur rename dupliqué + feedback modal visible #95
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:45:47 +02:00
Colin Maudry 2d88217bbf feat: page /compte/vues (liste, renommer, supprimer) #95 2026-06-29 16:34:52 +02:00
Colin Maudry 7c77524a3a feat: UI sauvegarde et application des vues sur /tableau #95 2026-06-29 16:29:58 +02:00
Colin Maudry 3d09b489b9 feat: helpers UI/validation des vues sauvegardées #95 2026-06-29 16:22:54 +02:00
Colin Maudry 22fc0cbab2 refactor: extrait build_view_query et le réutilise dans le partage #95
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:19:51 +02:00
Colin Maudry db148eef90 Page connexion : réorganisation et bouton inscription #90
- Mot de passe oublié déplacé sous le formulaire de connexion
- Bouton "Créer un compte avec mon adresse email" en bas (btn-primary)
- Bouton Me désabonner affiché aussi pour les abonnements pending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:14:43 +02:00
Colin Maudry 03023a6b8c feat: table saved_views et CRUD #95 2026-06-29 16:14:00 +02:00
Colin Maudry cb14b7c68e Ajoute plan d'implémentation sauvegarde des vues #95
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:05:40 +02:00
Colin Maudry 9d4280f69d Ajoute spec sauvegarde des vues #95
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:57:44 +02:00
Colin Maudry 8cd5bfe821 fix(csrf): supprimer prevent_initial_call=True sur _fill_csrf_inputs
Avec prevent_initial_call=True, le callback ne s'exécutait pas lors de
la chaîne initiale (_generate_csrf_token → csrf-token), laissant le champ
csrf_token vide au premier chargement direct de /connexion → erreur 400.

Ajoute des tests comportementaux avec CSRF activé (comme en production) et
un test architectural qui vérifie que le callback reste appelable initialement.
Corrige aussi les assertions de redirection post-login (/compte/abonnement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:46:30 +02:00
Colin Maudry f8112274cf Gestion abonnement existant : ajout PM et résiliation avec confirmation #90
- Page /compte/abonnement affiche la vue de gestion si une subscription existe (pending, trial, active, cancelled), pas les offres
- Status pending : alerte + bouton "Ajouter une méthode de paiement"
- Status trial/active : bouton "Me désabonner" ouvrant une modale de confirmation
- Route add-payment crée une recurring session Frisbii (checkout-api.frisbii.com)
- Callback add-payment/callback récupère l'id du PM créé et l'associe à la subscription via POST /v1/subscription/{handle}/pm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:34:30 +02:00
Colin Maudry 98b4d13f94 Petites améliorations sur la facturation #90 2026-06-29 13:35:52 +02:00
Colin Maudry b5f529c789 Formulaire d'infos de facturation avant paiement (#abonnement)
- Nouvelle page /compte/abonnement/mes-infos avec formulaire deux
  colonnes (prénom, nom, SIRET, entreprise, adresse, CP, ville, pays)
- Lookup SIRET via l'annuaire des entreprises pour pré-remplissage
- Cases à cocher obligatoires : renonciation rétractation + CGU (modale)
- Le bouton Valider est désactivé tant que les deux cases ne sont pas cochées
- Pays : liste déroulante des 20 pays de la zone euro, France par défaut
- Le SIRET est stocké dans users.siret (migration 0002) pour pré-remplissage
- Pré-remplissage des infos Frisbii (GET customer) si déjà client
- Côté Frisbii : update_customer si client existant, create_customer sinon
- Les URLs accept/cancel sont désormais transmises dans le body Frisbii
- Mise à jour des CGU : distinction données decp.info vs Frisbii

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 13:21:10 +02:00
Colin Maudry 5e5c5170a7 Page erreur 500 2026-06-26 18:29:04 +02:00
Colin Maudry 0eb2807630 Merge branch 'dev2' into dev
# Conflicts:
#	CHANGELOG.md
#	pyproject.toml
#	src/assets/css/style.css
#	src/pages/_apropos_shell.py
#	uv.lock
2026-06-26 18:27:24 +02:00
Colin Maudry b6a95e5464 Premier jet des CGU #93 2026-06-26 18:25:12 +02:00
Colin Maudry 401bf5e204 Ajoute spec et plan d'implémentation TOUS_ABONNES
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:23:42 +02:00
Colin Maudry 8faeaa5804 Bandeau TOUS_ABONNES et boutons S'abonner désactivés
Affiche un bandeau d'info sur /compte/abonnement et grise les boutons
S'abonner quand l'accès gratuit pour tous est activé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:12:58 +02:00
Colin Maudry 2bdcfdeb1a Ajoute le drapeau TOUS_ABONNES pour l'accès gratuit
Ouvre les fonctionnalités d'abonné à tout utilisateur connecté tant que
Frisbii n'a pas validé la réception de paiements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:06:27 +02:00
Colin Maudry 57dd4d22eb Lien a propos direct, ordre section, mini erreur dans marche.py 2026-06-26 15:46:10 +02:00
Colin Maudry b25150faa3 orjson pour sérialiser les sorties API 2026-06-26 12:24:47 +02:00
Colin Maudry 8926c5b02c Layout fluide (pleine largeur) pour /a-propos 2026-06-26 12:24:15 +02:00
Colin Maudry b4f7e94800 Redesign de a-propos 2026-06-26 12:24:07 +02:00
Colin Maudry e229caf320 Fix affichage jours d'essai 2026-06-25 22:41:48 +02:00
Colin Maudry 5bd7029066 Wrap les erreurs httpx du client Frisbii en FrisbiiError 2026-06-25 22:34:29 +02:00
Colin Maudry 1bd15f627f Mise un place d'un système DIY de migrations DB 2026-06-25 22:15:31 +02:00
Colin Maudry 6e0722e2d7 S'assure que src/ n'est pas dans le path au démarrage 2026-06-25 21:56:10 +02:00
Colin Maudry 89bcd9f161 Merge branch 'feature/90_subscriptions' into dev 2026-06-25 21:14:47 +02:00
Colin Maudry e932c66af0 Corrections des bugs de la connexion à Frisbii #90 2026-06-25 21:14:35 +02:00
Colin Maudry 5f500a1aa8 fix(abonnement): garde active→pending, datetime parse, resolve_handle vide (#90)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 19:26:15 +02:00
Colin Maudry d3025e323b feat(abonnement): page /compte/abonnement + accès premium réel (#90)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 19:08:22 +02:00
Colin Maudry 3d1cb69463 feat(abonnement): routes subscribe/cancel/webhook + câblage app (#90) 2026-06-25 19:00:53 +02:00
Colin Maudry 4b1d8bf5c3 feat(abonnement): signature + mapping des webhooks Frisbii (#90) 2026-06-25 18:56:26 +02:00
Colin Maudry 4f90184ddd feat(abonnement): catalogue de plans + durée d'essai dynamique (#90) 2026-06-25 18:53:41 +02:00
Colin Maudry b7a79d8d86 feat(abonnement): table subscriptions + état (#90) 2026-06-25 18:50:50 +02:00
Colin Maudry 007cb28b21 feat(abonnement): client HTTP Frisbii (#90)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 18:47:21 +02:00
Colin Maudry b0543c0c03 docs(abonnement): mention explicite 'sans période d'essai' si essai déjà utilisé (#90)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 18:43:51 +02:00
Colin Maudry a18d43dae4 docs(abonnement): anti-abus essai (trial_used + no_trial) (#90)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 18:41:31 +02:00
Colin Maudry 421750957f docs(abonnement): plan d'implémentation Frisbii (#90)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 18:36:25 +02:00
Colin Maudry 78aea4a69a docs(abonnement): durée d'essai lue depuis le plan Frisbii (non codée en dur) (#90)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 18:29:22 +02:00
Colin Maudry 3f12e6e210 docs(abonnement): essai gratuit de 2 jours (config plan Frisbii) (#90)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 18:06:37 +02:00
Colin Maudry 3528924857 docs(abonnement): spec Frisbii (souscription, résiliation, webhooks) (#90)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 18:03:33 +02:00
Colin Maudry 6d07dcb0bb orjson pour sérialiser les sorties API 2026-06-25 13:04:24 +02:00
Colin Maudry 2701eb0422 Layout fluide (pleine largeur) pour /compte et /a-propos 2026-06-25 09:37:06 +02:00
Colin Maudry b93aca9005 feat(ux): refonte barre latérale /compte et fix CLI tokens
- Déconnexion déplacée dans la sidebar (lien discret) et retirée du dropdown navbar
- Email utilisateur connecté = lien simple vers /compte/admin (sans dropdown)
- Hover orange pâle sur les nav-links des sidebars
- fix(cli): load_dotenv() dans tokens_cli pour charger .env hors contexte app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
2026-06-25 02:11:28 +02:00
Colin Maudry 82999ef056 Redesign de a-propos 2026-06-25 01:19:36 +02:00
Colin Maudry 590b927e31 fix(auth): corrections runtime LinkedIn OAuth (#88)
- Workaround nonce OIDC LinkedIn (non-conformité) : capture MissingClaimError après échange de code réussi et fetch userinfo séparément
- Ajout token_endpoint_auth_method: client_secret_post (requis par LinkedIn)
- suppress_callback_exceptions=True pour corriger les erreurs Dash multi-pages
- Suppression bouton déconnexion redondant sur /compte/admin et style hover incorrect sur la navbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:38:01 +02:00
Colin Maudry 5a702ac77e fix(auth): null password_hash pour utilisateurs OAuth (#88)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:27:15 +02:00
Colin Maudry aac3753226 feat(auth): bouton Connexion avec LinkedIn sur connexion/inscription (#88)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:18:41 +02:00
Colin Maudry 9f10f1c4de feat(auth): routes /auth/linkedin et callback OIDC (#88)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:14:29 +02:00
Colin Maudry d79e990c04 feat(auth): init Authlib + provider LinkedIn (config env) (#88)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:11:26 +02:00
Colin Maudry d72cad90ec feat(auth): resolve_oauth_user (liaison/création de compte OIDC) (#88)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:07:46 +02:00
Colin Maudry 44031efcd5 feat(auth): oauth_identities table + password_hash nullable (#88)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 23:04:18 +02:00
Colin Maudry 8037f2733e docs(auth): plan d'implémentation connexion LinkedIn
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:58:28 +02:00
Colin Maudry 6038b44fa8 docs(auth): spec connexion LinkedIn (OIDC)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:51:56 +02:00
Colin Maudry ccdf476c4f fix(compte): styles nav sidebar, zone danger et bouton déconnexion (#73)
- Nav sidebar : indicateur bordure gauche rouge au lieu du pill rouge plein
- Zone danger : couleur rouge directe (#d9230f) au lieu de Bootstrap danger (violet en Simplex)
- Bouton Déconnexion : outline pour le rendre clairement stylisé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:38:28 +02:00
Colin Maudry ff7aaa54cd fix(auth): vérification promote_pending_email, purge tokens change-email, fallback login (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:25:34 +02:00
Colin Maudry dba8669f4b test(compte): redirections d'accès de l'espace compte (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:16:02 +02:00
Colin Maudry e2124024e1 fix: move _toggle_offcanvas callback to _compte_shell.py
The callback was defined in compte_admin.py but controlled components
(compte-offcanvas and compte-offcanvas-open) rendered by account_shell()
in _compte_shell.py. Moving the callback to where the components live
improves code organization and reduces coupling.

Closes #73

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:10:01 +02:00
Colin Maudry 95f7d80b45 feat(compte): page /compte/admin (email, mot de passe, suppression) (#73) 2026-06-24 22:07:05 +02:00
Colin Maudry cce4d9873a feat(compte): coquille de la section Abonnement (#73) 2026-06-24 22:06:42 +02:00
Colin Maudry 6144e3c18d feat(auth): route delete-account avec confirmation par mot de passe (#73) 2026-06-24 18:49:52 +02:00
Colin Maudry 558df9627b feat(auth): routes change-email + confirm-email-change (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:48:08 +02:00
Colin Maudry 90261d4f31 feat(auth): email de confirmation de changement d'adresse (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:45:37 +02:00
Colin Maudry 4602b45f30 feat(compte): coquille account_shell (sidebar + offcanvas + garde d'accès) (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:43:53 +02:00
Colin Maudry f301a0a336 feat(auth): email en attente (pending_email) + migration (#73)
Adds pending_email column to users table with idempotent migration.
Implements set_pending_email() and promote_pending_email() for email-change workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:43:04 +02:00
Colin Maudry 442192cb35 docs(compte): plan d'implémentation de l'espace compte (#73)
Refs #73

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:38:09 +02:00
Colin Maudry ba5ad2c2ac docs(compte): design de l'espace compte multi-sections (#73)
Design validé en brainstorming : coquille account_shell (sidebar +
offcanvas), routage /compte/* (une page par section), 3 niveaux d'accès
avec stub d'abonnement, section Compte (email avec re-vérification,
mot de passe, suppression via modale).

Refs #73

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:32:17 +02:00
Colin Maudry 816c6d67b0 Changelog 2.9.0 2026-06-24 18:01:59 +02:00
Colin Maudry 9baf02810f Merge branch 'feature/89_backup' into dev 2026-06-24 17:49:15 +02:00
Colin Maudry 6161a103b0 Fix README 2026-06-24 17:49:08 +02:00
Colin Maudry ac3ba89c0a fix(backup): retirer users.sqlite du suivi git, logging, test restore CLI, garde snapshot (#89)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:45:40 +02:00
Colin Maudry 02df6c10a0 feat(backup): unités systemd, variables d'env et doc de déploiement (#89)
Ajoute les artefacts de déploiement finaux pour la sauvegarde systématisée
de users.sqlite sur S3 :
- Service systemd pour exécuter le backup (oneshot)
- Timer systemd pour le déclencher toutes les heures
- Variables d'env pour configuration S3 et chiffrement
- Documentation d'installation et restauration dans CLAUDE.md
- Guide rapide dans README.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:29:03 +02:00
Colin Maudry 196a9db8c1 refactor(backup): retirer le return 1 inaccessible dans main() (#89)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:26:37 +02:00
Colin Maudry 1453990f7a feat(backup): CLI backup/list/restore (#89)
Implement CLI interface for the backup module with three subcommands:
- backup: create a backup and apply rotation
- list: list available backups
- restore: restore a specific backup

Also add __main__.py to enable 'python -m src.backup'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:23:57 +02:00
Colin Maudry ded5e66ccc fix(backup): nettoyer le fichier temporaire en cas d'erreur dans restore (#89)
Wraps temp file operations in try/except to ensure the temporary database file is always cleaned up, even if an exception occurs during write_snapshot or verify_integrity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:21:28 +02:00
Colin Maudry cae0a528f3 feat(backup): orchestration backup/list/restore (#89)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:18:47 +02:00
Colin Maudry 0234505932 refactor(backup): type hints sur FakeStorage (#89) 2026-06-24 17:16:40 +02:00
Colin Maudry 108592f289 feat(backup): wrapper de stockage S3 (boto3) + faux stockage de test (#89) 2026-06-24 17:13:17 +02:00
Colin Maudry efefc3f5b0 feat(backup): snapshot SQLite cohérent et contrôle d'intégrité (#89)
Implémente les trois fonctions de gestion de snapshots SQLite:
- make_snapshot: crée un snapshot gzippé cohérent via sqlite3.Connection.backup()
- write_snapshot: écrit le snapshot décompressé à destination
- verify_integrity: valide l'intégrité d'une base avec PRAGMA integrity_check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:10:22 +02:00
Colin Maudry 2a691e37b7 feat(backup): chiffrement Fernet des sauvegardes
- Ajouter encrypt() et decrypt() avec clés Fernet urlsafe-base64
- Tests de roundtrip et échec avec mauvaise clé

Fixes #89
2026-06-24 17:08:34 +02:00
Colin Maudry 927f56cb1c feat(backup): rotation multi-paliers (fonction pure)
Implémente la fonction select_retained() pour la rétention multi-paliers:
- Paliers fixes: horaire/12h, 12h/72h, quotidien/21j
- Palier mensuel: 12 mois calendaires
- Fonction pure, pas de dépendances sur d'autres modules backup

Fixes #89
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:06:27 +02:00
Colin Maudry 48c0649a68 feat(backup): nommage horodaté des clés S3 (#89)
Ajoute les fonctions make_key() et parse_timestamp() pour gérer
le format des clés S3 avec horodatage UTC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:02:47 +02:00
Colin Maudry 1d2a945aa1 feat(backup): config et dépendances pour la sauvegarde S3 (#89) 2026-06-24 17:00:34 +02:00
Colin Maudry 3ad328103c docs(backup): plan d'implémentation sauvegarde S3 (#89) 2026-06-24 16:57:11 +02:00
Colin Maudry 555a87fdf7 docs(backup): conception sauvegarde base utilisateurs sur S3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:51:45 +02:00
Colin Maudry 3f1c39d5a6 fix(brevo): passer X-Sib-Sandbox dans le body email, pas en header HTTP (#87)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:22:42 +02:00
Colin Maudry c3408ecc3c refactor(brevo): utiliser DEVELOPMENT pour le mode sandbox Brevo (#87)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:40:46 +02:00
Colin Maudry c0927c718d test(brevo): test d'intégration sandbox optionnel (#87)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:35:23 +02:00
Colin Maudry 162bc550c0 chore(brevo): retirer la config SMTP et les templates Jinja d'email (#87)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:34:45 +02:00
Colin Maudry e4c291a785 feat(brevo): envoyer les emails transactionnels via l'API Brevo v5 (#87)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:33:14 +02:00
Colin Maudry 570f9f4153 build(brevo): remplacer flask-mail par brevo-python v5 (#87)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:30:48 +02:00
Colin Maudry 99abdfed0b docs(brevo): plan d'implémentation + maj spec avec l'API v5 vérifiée (#87)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:19:42 +02:00
Colin Maudry f41ee64c32 docs(brevo): spec de migration des emails transactionnels vers Brevo (#87)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:08:09 +02:00
Colin Maudry afe99ea79f fix(auth): utiliser threading.local() pour les connexions SQLite par thread
La connexion singleton partagée entre threads causait sqlite3.InterfaceError
lors de requêtes concurrentes sur la page compte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 12:23:55 +02:00
Colin Maudry 7be0332f02 Merge branch 'dev' into feature/73_compte_utilisateur 2026-06-24 05:34:27 +02:00
Colin Maudry 8830b2ac91 fix(test): mettre à jour les assertions de compute_considerations_stats pour le retour en 3-tuple
La fonction retourne désormais (count_ren, count_pos_ren, pct) pour les clés
_renseignees, mais les tests attendaient encore l'ancien format (count_ren, pct).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 05:34:03 +02:00
Colin Maudry b1c33c29ed Env MAIL_SUPPRESS_SEND 2026-06-24 05:29:24 +02:00
Colin Maudry c99f4d970e fix(csrf): centraliser l'injection des tokens CSRF via un dcc.Store et un callback pattern-matching
Remplace les 7 callbacks CSRF individuels (un par formulaire, avec IDs string
page-spécifiques) par un seul dcc.Store(id="csrf-token") dans le layout principal
et un callback pattern-matching Output({"type": "csrf-input", "index": ALL}).
Évite les erreurs Dash "id non trouvé dans le layout" sans recourir à
suppress_callback_exceptions=True.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 05:26:07 +02:00
Colin Maudry c228f7be55 fix(auth): corriger le lien de validation email et l'isolation des tests
Le lien de vérification pointait vers la page Dash /verification-email
(qui se contente de rediriger vers /connexion) au lieu de la route
backend /auth/verify-email qui consomme réellement le token et marque
l'email comme vérifié. Conséquence : l'email n'était jamais validé et le
login renvoyait email_not_verified en boucle.

Ajoute aussi MAIL_SUPPRESS_SEND=true au bloc env de pytest : sans cela,
le load_dotenv() de src.app injectait MAIL_SUPPRESS_SEND=false depuis le
.env local, désactivant la suppression d'envoi et faisant échouer les
tests d'auth qui envoient un email (selon l'ordre d'import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 03:55:52 +02:00
Colin Maudry 4e1c610405 feat(auth): découpler MAIL_SUPPRESS_SEND de DEVELOPMENT et logger les emails supprimés
Ajoute la variable MAIL_SUPPRESS_SEND pour pouvoir tester l'envoi d'emails
en mode développement sans toucher au flag DEVELOPMENT. Un warning est loggé
à chaque tentative d'envoi supprimée avec la cause et les détails du message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 03:43:55 +02:00
Colin Maudry cbce646fd8 Merge branch 'dev' into feature/73_compte_utilisateur 2026-06-24 03:01:43 +02:00
Colin Maudry a6f60ce3c4 Cache le champ de filtrage pour la colonne 'Marché' 2026-06-24 02:56:42 +02:00
Colin Maudry f9aefb55d8 feat(titulaire): afficher le libellé d'activité NAF en sous-titre gris
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:32:12 +02:00
Colin Maudry d99bb00d6c Compactage 2026-06-23 20:16:51 +02:00
Colin Maudry afdd3c8904 Ajout de openpyxl explicite #83 2026-06-23 20:16:24 +02:00
Colin Maudry ad0a220953 feat(tableaux): scroll horizontal ergonomique + barre custom (#82)
- Barre de défilement JS orange (12px) injectée en haut de chaque
  .marches_table, collée avec position:sticky, masquée si le tableau
  tient dans le viewport
- Scroll contenu dans .dash-spreadsheet-container (overflow-x:hidden)
  → plus de scrollbar navigateur en bas de page
- Drag souris + tactile, clic sur le track, molette/trackpad
- Recalcul automatique au resize et aux re-renders Dash (MutationObserver)
- Test Selenium de non-régression (overflow-x:hidden sur le conteneur)

Sticky headers abandonnés (incompatibles avec scroll contenu).
Portée : 4 pages via la classe partagée .marches_table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:12:47 +02:00
Colin Maudry f508c821c2 test(tableaux): adapter test post-abandon sticky headers #82
Le test ne vérifie plus position:sticky sur th (sticky abandonné car
incompatible avec scroll contenu). Vérifie à la place que overflow-x:hidden
est bien en place sur le conteneur Dash — garantit l'absence de scrollbar
navigateur en bas de page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:00:41 +02:00
Colin Maudry 45526f7c7b fix(tableaux): restaurer position:sticky sur la barre, 12px #82
Le retrait de position:sticky sur .dt-hscroll avait cassé le layout
(bouton Remettre à zéro visible au mauvais endroit, z-index perdu).
La barre redevient sticky top:0 z-index:11 — les th.dash-header restent
sans sticky ni z-index (ils ne fonctionnaient de toute façon pas en
scroll contenu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:52:42 +02:00
Colin Maudry 5e64a4553d fix(tableaux): supprimer sticky headers, barre 12px #82
- Retire position:sticky/z-index sur th.dash-header (abandonnés avec
  le scroll contenu : overflow-x:hidden crée un scroll container qui
  empêche le sticky page-level, et le z-index provoquait un recouvrement
  des champs de filtre)
- Retire la règle :has() devenue inutile
- Barre de scroll réduite à 12px

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:46:43 +02:00
Colin Maudry 2e22fcabe9 fix(tableaux): scroll contenu dans dashContainer, plus de scrollbar page #82
Remplace le scroll de page (window.scrollTo) par dashContainer.scrollLeft :
- overflow-x:hidden sur .dash-spreadsheet-container → la table ne déborde
  plus la page → scrollbar navigateur en bas éliminée
- overflow-y:clip évite la conversion CSS visible→auto (pas de scrollbar
  vertical parasite sur le conteneur)
- metrics(), syncThumb, drag et clic utilisent tous dashContainer
- wheel handler capture les gestures trackpad horizontaux et les redirige
  vers dashContainer.scrollLeft

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:20:41 +02:00
Colin Maudry 3c0cf6bc07 fix(tableaux): barre pleine largeur viewport + masquage par table #82
- adjustBarWidth() brise le padding Bootstrap (margin-left négatif +
  width: 100vw) pour que la barre s'étende jusqu'au bord de la fenêtre
- metrics() utilise window.innerWidth comme largeur de piste (cohérent
  avec la barre pleine largeur)
- hasOverflow mesure la largeur de .cell-table de CETTE table via
  getBoundingClientRect(), plutôt que document.documentElement.scrollWidth
  → les petits tableaux (top10 /acheteur, /titulaire, /observatoire)
  n'affichent pas de barre s'ils ne débordent pas individuellement

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:02:36 +02:00
Colin Maudry 9c5fc9257a fix(tableaux): thumb custom toujours visible, drag souris et tactile #82
Remplace le scrollbar natif (invisible en mode overlay sous Linux) par
un thumb div orange toujours visible. Track gris (#e0e0e0) 16px.
Drag souris + toucher + clic sur le track supportés.
Supprime la dépendance aux pseudo-éléments webkit et scrollbar-color.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:50:51 +02:00
Colin Maudry 0edca05df5 fix(tableaux): barre 16px toujours visible, headers décalés en dessous #82
- overflow-x:scroll force le thumb orange toujours visible (pas overlay)
- hauteur 16px pour le drag-and-drop
- :has(.dt-hscroll:not(.is-hidden)) décale les th à top:16px quand la
  barre est présente, évitant le recouvrement

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:26:36 +02:00
Colin Maudry 099fba58f7 fix(tableaux): option A — sync barre avec window.scrollX + style orange #82
Abandonne le wrapper overflow-x (incompatible avec position:sticky des
en-têtes). La barre contrôle désormais le scroll horizontal de la page
via window.scrollTo/scrollX, ce qui préserve le sticky des th.
Scrollbar orange 12px, styling webkit + Firefox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:22:08 +02:00
Colin Maudry 817aeedc6f fix(tableaux): garde hscrollReady après vérification dashContainer #82
Si la garde est posée avant de vérifier que dashContainer existe, un
déclenchement prématuré de rootObs (avant le rendu Dash) marque le
wrapper comme traité sans jamais injecter le scroll wrapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:42:50 +02:00
Colin Maudry 1d27d517f3 fix(tableaux): garde hscrollReady avant manipulation DOM #82
Évite la boucle infinie : le rootObs se déclenchait sur les insertions
DOM de setup() avant que la garde ne soit posée, relançant setup() en
boucle et faisant monter le CPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:33:09 +02:00
Colin Maudry a5e802d20a refactor: use write_styled_excel in all download callbacks #83 2026-06-23 17:14:16 +02:00
Colin Maudry e5ca7d62a3 fix: protect wb.close() with try/finally, strengthen width assertions, declare openpyxl dev dep #83
- I-1: Add explicit lower bounds to width assertions (>= 15 for autre, >= 40 for objet)
- I-2: Wrap write_excel() and close() in try/finally to ensure cleanup even on exception
- M-2: Add openpyxl to dev dependencies in pyproject.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 17:08:32 +02:00
Colin Maudry 35e72645bd feat: add write_styled_excel utility for styled Excel exports #83
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 17:05:55 +02:00
Colin Maudry 39dfa22b0d Plan : amélioration du style des exports Excel #83
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 17:00:28 +02:00
Colin Maudry 96c0b0dd35 fix(tableaux): scroll horizontal via wrapper overflow-x auto #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:52:22 +02:00
Colin Maudry 10ed9c4c4b Spec : amélioration du style des exports Excel #83
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 16:21:20 +02:00
Colin Maudry ce4b06fe3d refactor(tableaux): commentaires et nettoyage table_hscroll.js #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:14:20 +02:00
Colin Maudry 2413ffb4f0 test(tableaux): barre miroir et en-tête sticky sur /tableau #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:05:49 +02:00
Colin Maudry cb1685d055 feat(tableaux): barre de défilement horizontale miroir synchronisée #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:00:45 +02:00
Colin Maudry 4be82ac76b feat(tableaux): en-têtes de colonnes collants #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:58:49 +02:00
Colin Maudry 83d38f8489 Correction du mode d'emploi sur l'accentuation 2026-06-23 15:48:37 +02:00
Colin Maudry ec2b8389c3 docs: verdict spike DOM tableaux #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:44:11 +02:00
Colin Maudry 3e305845dd Fix réduction du nombre de colonne, ajoute de lla colonne Marché #84 2026-06-23 15:28:42 +02:00
Colin Maudry 68bd398759 docs: plan d'implémentation scroll horizontal tableaux #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:14:03 +02:00
Colin Maudry 0ed807f98c docs: design scroll horizontal + en-têtes sticky des tableaux #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:08:47 +02:00
Colin Maudry f94701207b Fix non split des DISPLAYED_COLUMNS 2026-06-23 14:13:30 +02:00
Colin Maudry 9e3f6765e0 Améliorations CLAUDE.md pour plus utiliser rtk 2026-04-21 21:54:10 +02:00
Colin Maudry a906a40b7b Fix import src.utils.cache 2026-04-21 17:21:32 +02:00
Colin Maudry d4844140b4 src/auth : exempter les routes Dash internes de la protection CSRF (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 23:11:17 +02:00
Colin Maudry 40e593ffdc Changelog : comptes utilisateurs (#73) 2026-04-20 22:58:20 +02:00
Colin Maudry 5151b32f3e tests/auth/test_csrf.py : vérifie que CSRF est actif (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:56:58 +02:00
Colin Maudry 20e7eed424 src/app.py : navbar avec lien Connexion / dropdown utilisateur (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:56:13 +02:00
Colin Maudry af0efbe928 src/pages : pages mot de passe oublié et réinitialisation (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:53:24 +02:00
Colin Maudry 038858985c src/pages/verification_email.py : page statut vérification email (#73) 2026-04-20 22:53:10 +02:00
Colin Maudry 7b27c15515 src/pages/compte.py : page mon compte (protégée) (#73) 2026-04-20 22:52:51 +02:00
Colin Maudry 88863415d7 src/pages/connexion.py : page de connexion (#73) 2026-04-20 22:52:46 +02:00
Colin Maudry 5149582958 src/pages/inscription.py : page d'inscription (#73) 2026-04-20 22:52:29 +02:00
Colin Maudry 49bd1b3036 src/auth/routes.py : route /auth/change-password (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:51:40 +02:00
Colin Maudry 6b78abb7fd src/auth/routes.py : routes de réinitialisation du mot de passe (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:50:20 +02:00
Colin Maudry ff9b3a6092 src/auth/routes.py : routes /auth/login et /auth/logout (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:39:59 +02:00
Colin Maudry 4d9a300e0c src/auth/routes.py : route /auth/verify-email (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:38:13 +02:00
Colin Maudry e275f10dbd src/auth/routes.py : route /auth/signup (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:37:15 +02:00
Colin Maudry 1c0fb0a960 src/auth/routes.py : blueprint auth et fixtures de test (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:35:06 +02:00
Colin Maudry 8896726e09 src/app.py : initialisation de l'authentification au démarrage (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:34:12 +02:00
Colin Maudry a02a9df081 src/auth/setup.py : init_auth et helper safe_next (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:32:32 +02:00
Colin Maudry 7976257029 src/auth/mailer.py : envoi d'emails HTML+texte (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:31:00 +02:00
Colin Maudry 72fd495c5b src/auth/models.py : classe User Flask-Login (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:25:56 +02:00
Colin Maudry 92fe76d072 src/auth/tokens.py : génération et validation des tokens (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:24:49 +02:00
Colin Maudry 1a07e5ac0f src/auth/db.py : CRUD tokens de vérification et reset (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:23:02 +02:00
Colin Maudry ae4a206de7 src/auth/db.py : CRUD users (#73) 2026-04-20 17:19:52 +02:00
Colin Maudry 5c6e75e3d3 src/auth/db.py : schéma SQLite et connexion (#73) 2026-04-20 17:15:26 +02:00
Colin Maudry 9ffa9c39f8 Dépendances auth : Flask-Login/Mail/WTF, email-validator (#73)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:09:41 +02:00
Colin Maudry 5a7a4da027 Plan d'implémentation comptes utilisateurs (#73)
23 tâches TDD : setup deps/env, auth/db, tokens, models, mailer,
init_auth, 6 routes Flask, 6 pages Dash, navbar, tests CSRF,
smoke test end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 17:02:42 +02:00
Colin Maudry 8342c6a0e6 Design des comptes utilisateurs (#73)
Spec validée par brainstorming : inscription avec vérification email,
connexion (Flask-Login), reset password par token stocké en DB,
page compte, SQLite + sqlite3 stdlib, Flask-Mail, CSRF via Flask-WTF.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 16:52:49 +02:00
318 changed files with 54074 additions and 2145 deletions
+19
View File
@@ -12,3 +12,22 @@ build
**/decp.duckdb.tmp **/decp.duckdb.tmp
**/decp.duckdb.lock **/decp.duckdb.lock
**/schema.cache.json **/schema.cache.json
# Runtime databases (never commit)
users.sqlite
*.sqlite
*.sqlite-*
!tests/*.sqlite
!tests/**/*.sqlite
# Copie jetable de tests/users.test.sqlite, générée par tests/conftest.py
tests/users.runtime.sqlite
# Cache disque isolé pour pytest (CACHE_DIR, cf. pyproject.toml) : évite de
# partager /tmp/colibre-cache avec le serveur dev (résultats de requêtes
# faussés par les données de test après un rmtree() croisé, cf. src/app.py)
tests/cache/
# LLM plugins
.superpowers/
.codegraph/
.claude
+67 -8
View File
@@ -1,5 +1,5 @@
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432 DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
DUCKDB_PATH=./decp.duckdb DUCKDB_PATH=./colibre.duckdb
PORT=8050 PORT=8050
DEVELOPMENT=True DEVELOPMENT=True
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234" SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
@@ -12,23 +12,82 @@ DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-be
DATA_SCHEMA_CACHE=./schema.cache.json DATA_SCHEMA_CACHE=./schema.cache.json
# Colonnes masquées par défaut # Colonnes masquées par défaut
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset" DISPLAYED_COLUMNS="acheteur_nom, montant, objet, titulaire_nom, dateNotification, dureeMois, acheteur_departement_code"
# Formulaire de contact # Formulaire de contact
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
LOGIN_PASSWORD="" # mot de passe du serveur LOGIN_PASSWORD="" # mot de passe du serveur
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
# Matomo # Matomo
MATOMO_ID_SITE= MATOMO_ID_SITE=
MATOMO_BASE_URL= MATOMO_BASE_URL=
MATOMO_TOKEN= MATOMO_TOKEN=
# Widget de chat Chatwoot (essai, offre managée, issue #120). Laisser vide
# pour désactiver le widget.
CHATWOOT_WEBSITE_TOKEN=
# API privée # API privée
DISABLE_API_AUTH="false" API_AUTH_DISABLED="false"
USERS_DB_PATH=./users.sqlite
MATOMO_URL=https://analytics.maudry.com/matomo.php MATOMO_URL=https://analytics.maudry.com/matomo.php
MATOMO_SITE_ID=14 MATOMO_SITE_ID=14
MATOMO_TRACKING_ENABLED=true MATOMO_TRACKING_ENABLED=true
# Comptes utilisateurs
USERS_DB_PATH=./users.sqlite
# Sert aussi à chiffrer les jetons MCP stockés (fonction « Copier le jeton » sur
# /compte/mcp). Sa rotation déconnecte les sessions ET rend les jetons existants
# non ré-affichables (les utilisateurs devront en régénérer).
SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
APP_BASE_URL=http://localhost:8050
# Panneau admin interne (accès à /admin, protégé par cette adresse)
ADMIN_EMAIL=
# Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal,
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
# {APP_BASE_URL}/auth/linkedin/callback
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
# Brevo — envoi des emails transactionnels (vérification email, reset mot de passe)
BREVO_API_KEY=
BREVO_TEMPLATE_VERIFY_ID=
BREVO_TEMPLATE_RESET_ID=
MAIL_FROM=noreply@colibre.fr # expéditeur (doit être un expéditeur vérifié dans Brevo)
MAIL_FROM_NAME=colibre # nom d'expéditeur
# Active/désactive le mode sandbox Brevo (X-Sib-Sandbox: drop) indépendamment de DEVELOPMENT.
# Si non défini, suit DEVELOPMENT (sandbox actif par défaut en dev/tests).
MAIL_SUPPRESS_SEND=
# Sauvegarde de users.sqlite vers un stockage S3-compatible
S3_ENDPOINT_URL=https://s3.exemple.net
S3_BUCKET=colibre-backups
S3_BACKUP_PREFIX=backups
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# Clé Fernet : générer avec
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables.
BACKUP_ENCRYPTION_KEY=
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
FRISBII_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer)
FRISBII_PLAN_SIMPLE=abonnement-colibre # handle du plan "abonnement simple" (20 € HT/mois)
FRISBII_PLAN_SOUTIEN=soutien-colibre # handle du plan "abonnement de soutien" (50 € HT/mois)
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
# Accès gratuit temporaire
TOUS_ABONNES=false
# Active le serveur MCP (/_mcp) ET le connecteur d'abonné (scope B, #111).
# À true, l'accès à /_mcp exige un jeton MCP (généré dans /compte/mcp) lié à un
# abonnement actif. Laisser false tant que le connecteur n'est pas déployé.
# Déploiement recommandé : activer d'abord sur test.colibre.fr (branche dev).
DASH_MCP_ENABLED=false
# Le connecteur MCP OAuth (Claude.ai, ChatGPT) requiert APP_BASE_URL en HTTPS
# et que l'egress Anthropic 160.79.104.0/21 puisse joindre le serveur.
# APP_BASE_URL sert d'issuer OAuth et à construire les URLs de découverte.
# Votes attribués chaque semaine aux abonné·es pour choisir les fonctionnalités prioritaires
VOTES_PER_WEEK=4
+40 -18
View File
@@ -1,3 +1,25 @@
### 3.0.0
**Fonctionnalités par abonnement**
- Abonnement payant à colibre : période d'essai gratuite, souscription et gestion du moyen de paiement, résiliation, historique de facturation ([#90](https://github.com/ColinMaudry/colibre/issues/90))
- Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau ([#95](https://github.com/ColinMaudry/colibre/issues/95))
- Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94))
- Accès aux données de colibre via un connecteur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Mistral, Gemini, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111))
**Autres améliorations**
- Nouvelle interface de tableau : plus d'options de filtres, plus performante ([#47](https://github.com/ColinMaudry/colibre/issues/47))
- Ajout des codes et libellés NAF des titulaires, affichés sur leur page /titulaire
- Export Excel mis en forme (styles) ([#83](https://github.com/ColinMaudry/colibre/issues/83))
- Chat intégré 💬 pour répondre à vos questions et recueillir vos suggestions en direct ([#120](https://github.com/ColinMaudry/colibre/issues/120))
- Réduction du nombre de colonnes affichées par défaut et ajout de la colonne "Marché" dans la page Tableau ([#84](https://github.com/ColinMaudry/colibre/issues/84))
- Refonte et mise en page pleine largeur de la page À propos
- Ajout des conditions générales d'utilisation (CGU) ([#93](https://github.com/ColinMaudry/colibre/issues/93))
- Amélioration du référencement (sitemaps acheteurs/titulaires)
- Amélioration des performances des pages acheteur et titulaire
- decp.info devient **colibre** : nouveau nom, nouvelles icônes ([#57](https://github.com/ColinMaudry/colibre/issues/57))
##### 2.8.1 (25 juin 2026) ##### 2.8.1 (25 juin 2026)
- Correction du bug dans la création de token d'API - Correction du bug dans la création de token d'API
@@ -35,7 +57,7 @@
##### 2.7.4 (22 avril 2026) ##### 2.7.4 (22 avril 2026)
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72) - Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/colibre/issues/72)
##### 2.7.3 (20 avril 2026) ##### 2.7.3 (20 avril 2026)
@@ -44,11 +66,11 @@
##### 2.7.2 (19 avril 2026) ##### 2.7.2 (19 avril 2026)
- Chargement des données depuis une base DuckDB plutôt qu'en mémoire (plus de stabilité) ([#71](https://github.com/ColinMaudry/decp.info/issues/71)) - Chargement des données depuis une base DuckDB plutôt qu'en mémoire (plus de stabilité) ([#71](https://github.com/ColinMaudry/colibre/issues/71))
- Mise en cache des vue sur l'observatoire pour un chargement plus rapide (remise à zéro quotidienne) - Mise en cache des vue sur l'observatoire pour un chargement plus rapide (remise à zéro quotidienne)
- Correction de bug : la liste de colonnes par défaut est bien appliquée plutôt qu'afficher toutes les colonnes - Correction de bug : la liste de colonnes par défaut est bien appliquée plutôt qu'afficher toutes les colonnes
- Quelques corrections de bugs d'affichage - Quelques corrections de bugs d'affichage
- Refactorisation des fonctions utilitaires (`utils.py` approchait des 1 000 lignes) - Refactorisation des fonctions utilitaire (`utils.py` approchait des 1 000 lignes)
##### 2.7.1 (23 mars 2026) ##### 2.7.1 (23 mars 2026)
@@ -79,7 +101,7 @@
##### 2.5.1 (29 janvier 2026) ##### 2.5.1 (29 janvier 2026)
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68)) - Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/colibre/issues/67), [#68](https://github.com/ColinMaudry/colibre/issues/68))
#### 2.5.0 (29 janvier 2026) #### 2.5.0 (29 janvier 2026)
@@ -94,11 +116,11 @@
#### 2.4.0 (22 janvier 2026) #### 2.4.0 (22 janvier 2026)
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63)) - Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/colibre/issues/63))
- Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source) - Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source)
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50)) - Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/colibre/issues/50))
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`) - Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36)) - Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/colibre/issues/36))
- Désactivation du bloquage des robot d'agents de LLM (robots.txt) - Désactivation du bloquage des robot d'agents de LLM (robots.txt)
##### 2.3.1 (16 janvier 2026) ##### 2.3.1 (16 janvier 2026)
@@ -129,9 +151,9 @@
#### 2.2.0 (13 novembre 2025) #### 2.2.0 (13 novembre 2025)
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58)) - Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/colibre/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55))) - Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/colibre/issues/55)))
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54)) - Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/colibre/issues/54))
##### 2.1.7 (11 novembre 2025) ##### 2.1.7 (11 novembre 2025)
@@ -162,22 +184,22 @@
##### 2.1.1 (1er octobre 2025) ##### 2.1.1 (1er octobre 2025)
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43)) - ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/colibre/issues/43))
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau - ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
#### 2.1.0 (30 septembre 2025) #### 2.1.0 (30 septembre 2025)
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎 - Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/colibre/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/colibre/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/colibre/issues/40)) 🔎
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️ - Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/colibre/issues/39)) pour de beaux aperçus de liens 🖼️
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨 - Formulaire de contact ([#48](https://github.com/ColinMaudry/colibre/issues/48)) 📨
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅 - Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/colibre/issues/33)) 💅
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖 - Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/colibre/issues/33)) 📖
- Affichage du numéro de version près du logo et lien vers ici 🤓 - Affichage du numéro de version près du logo et lien vers ici 🤓
- Variables globales uniquement en lecture (😁) - Variables globales uniquement en lecture (😁)
##### 2.0.1 (23 septembre 2025) ##### 2.0.1 (23 septembre 2025)
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38) - Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/colibre/issues/38)
- Amélioration du script de déploiement (deploy.sh) - Amélioration du script de déploiement (deploy.sh)
- Meilleures instructions d'installation et lancement - Meilleures instructions d'installation et lancement
- Coquilles 🐚 - Coquilles 🐚
@@ -217,7 +239,7 @@
- ajout d'une page "Notes de version" - ajout d'une page "Notes de version"
- meilleur lien pour la documentation des champs - meilleur lien pour la documentation des champs
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info) - déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/colibre)
### 1.1.0 (25/05/2021) ### 1.1.0 (25/05/2021)
+53 -17
View File
@@ -4,21 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**decp.info** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French. **colibre** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
## Commands ## Commands
### Setup ### Setup
Setting up the virtual environment:
```bash
python -m venv .venv # s'il n'existe pas déjà
source .venv/bin/activate
rtk pip install -U pip > /dev/null 2>&1
rtk pip install -e . --group=dev
```
Environment variables: Environment variables:
```bash ```bash
@@ -28,24 +19,28 @@ cp .template.env .env # then customize .env
### Development ### Development
```bash ```bash
python run.py # starts Dash app uv run run.py # starts Dash app
``` ```
### Production ### Production
```bash ```bash
gunicorn app:server gunicorn run:server
``` ```
### Tests ### Tests
```bash ```bash
rtk pytest # run all tests (some are Selenium-based integration tests) uv run pytest # run all tests (some are Selenium-based integration tests)
rtk pytest tests/test_main.py::test_001_logo_and_search # run a single test uv run pytest tests/test_main.py::test_001_logo_and_search # run a single test
``` ```
Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver. Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver.
## Ajouts git
Avant d'ajouter des fichier dans git (`git add` ou `git commit -a`), exécute `pre-commit` pour que ruff formate les fichiers.
## Architecture ## Architecture
### Multi-page Dash app ### Multi-page Dash app
@@ -82,7 +77,7 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from `
### UI stack ### UI stack
- **Dash 3.4** + **Dash Bootstrap Components** for layout - **Dash 4.4** + **Dash Bootstrap Components** for layout
- **Plotly Express** for charts - **Plotly Express** for charts
- **Dash Leaflet** + **Dash Extensions** for interactive maps with clustering - **Dash Leaflet** + **Dash Extensions** for interactive maps with clustering
- Custom CSS in `src/assets/css/` - Custom CSS in `src/assets/css/`
@@ -92,7 +87,48 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from `
- `DEVELOPMENT=true` enables debug logging and is set automatically during tests - `DEVELOPMENT=true` enables debug logging and is set automatically during tests
- `.env` file is required at runtime (copy from `template.env`) - `.env` file is required at runtime (copy from `template.env`)
### Migrations de schéma SQLite
Les migrations sont gérées dans `src/migrations.py` via une liste `_MIGRATIONS` de tuples `(id, sql)`. Elles sont appliquées automatiquement au démarrage de l'app (via `init_subscriptions`).
Pour ajouter une migration :
```python
# src/migrations.py
_MIGRATIONS = [
("0001_add_prix_ht_to_subscriptions", "ALTER TABLE subscriptions ADD COLUMN prix_ht REAL"),
("0002_ma_nouvelle_migration", "ALTER TABLE ... "), # ajouter ici
]
```
- L'ID doit être unique et croissant (convention `NNNN_description`)
- Les migrations appliquées sont tracées dans la table `schema_migrations`
- `apply_pending()` est idempotent : sans effet si la migration est déjà enregistrée, et tolère le cas où la colonne existe déjà dans le schéma (DB fraîche)
### Deployment ### Deployment
- `main` branch → manual deploy to decp.info via GitHub Actions - `main` branch → manual deploy to colibre.fr via GitHub Actions
- `dev` branch → auto-deploy to test.decp.info via GitHub Actions - `dev` branch → auto-deploy to test.colibre.fr via GitHub Actions
#### Sauvegarde de la base utilisateurs
`users.sqlite` est sauvegardée toutes les heures sur S3 par un timer systemd
(voir `deploy/colibre-backup.{service,timer}`). Installation initiale (une fois,
sur le serveur, en root) :
```bash
cp deploy/colibre-backup.service deploy/colibre-backup.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now colibre-backup.timer
systemctl list-timers colibre-backup.timer # vérifier le prochain déclenchement
```
Restauration manuelle :
```bash
cd /var/www/colibre && source .venv/bin/activate
python -m src.backup list
systemctl stop colibre
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
systemctl start colibre
```
+35 -5
View File
@@ -1,8 +1,10 @@
# decp.info # colibre
> **Note:** Ce projet a été rebaptisé de **decp.info** à **colibre** en 2026.
> Outil d'exploration et de téléchargement des données essentielles de la commande publique. > Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> [decp.info](https://decp.info) => [colibre.fr](https://colibre.fr)
## Installation et lancement ## Installation et lancement
@@ -20,11 +22,39 @@ uv run run.py
## Déploiement ## Déploiement
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml) - **Production** (branche `main`, [colibre.fr](https://colibre.fr)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/colibre/actions/workflows/deploy.yaml)
- **Test** (branche `dev`, [test.decp.info](https://test.decp.info)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action. - **Test** (branche `dev`, [test.colibre.fr](https://test.colibre.fr)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
Ne pas oublier de mettre à jour les fichier .env. Ne pas oublier de mettre à jour les fichier .env.
### Sauvegarde de la base utilisateurs
`users.sqlite` est sauvegardée toutes les heures sur S3 via un timer systemd. Pour lister les sauvegardes disponibles :
```bash
python -m src.backup list
```
Pour restaurer une sauvegarde, arrêtez le service, restaurez la base, puis redémarrez :
```bash
systemctl stop colibre
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
systemctl start colibre
```
## Migrations de base de données
Les migrations SQLite s'appliquent **automatiquement au démarrage de l'app** — aucune action manuelle requise. Il suffit de redémarrer le service après un déploiement.
Pour vérifier quelles migrations ont été appliquées :
```bash
sqlite3 users.sqlite "SELECT id, applied_at FROM schema_migrations ORDER BY applied_at;"
```
Pour ajouter une migration, voir les instructions dans `src/migrations.py`.
## Liens connexes ## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données) - [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
@@ -32,4 +62,4 @@ Ne pas oublier de mettre à jour les fichier .env.
## Notes de version ## Notes de version
Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md). Voir [CHANGELOG](https://github.com/ColinMaudry/colibre/blob/main/CHANGELOG.md).
+29
View File
@@ -0,0 +1,29 @@
# Contexte
Je souhate ajouter la possibilité pour les utilisateurs de créer un compte utilisateur. Les données des utilisateurs sont stockés dans une base de données sqlite située à la racine du projet. Les seules données demandées sont une adresse email et un mot de passe. Un lien dans le menu supérieur, tout à droite, permet d'accéder à une page qui permet soit de se connecter, soit de créer un compte.
Il s'agit des fondations des comptes utilisateurs, d'autres fonctionnalités viendront s'ajouter.
# Inscription
Données nécessaires :
- adresse email
- mot de passe
Le mot de passe est hashé en base de données.
# Connexion
- adresse email
- mot passe
Lien vers une page de réinitialisation du mot de passe.
# Page du compte
Possibilité de changer le mot de passe.
# Configuration
Ces fonctionnalités nécessitent d'envoyer des emails. Les options nécessaire à une connexion à un service SMTP sont fournies sous la forme de variables d'environnement.
+261
View File
@@ -0,0 +1,261 @@
code,libellé
0000,Organisme de placement collectif en valeurs mobilières sans personnalité morale
1000,Entrepreneur individuel
2110,Indivision entre personnes physiques
2120,Indivision avec personne morale
2210,Société créée de fait entre personnes physiques
2220,Société créée de fait avec personne morale
2310,Société en participation entre personnes physiques
2320,Société en participation avec personne morale
2385,Société en participation de professions libérales
2400,Fiducie
2700,Paroisse hors zone concordataire
2800,Assujetti unique à la TVA
2900,Autre groupement de droit privé non doté de la personnalité morale
3110,Représentation ou agence commerciale d'état ou organisme public étranger immatriculé au RCS
3120,Société commerciale étrangère immatriculée au RCS
3205,Organisation internationale
3210,"État, collectivité ou établissement public étranger"
3220,Société étrangère non immatriculée au RCS
3290,Autre personne morale de droit étranger
4110,Établissement public national à caractère industriel ou commercial doté d'un comptable public
4120,Établissement public national à caractère industriel ou commercial non doté d'un comptable public
4130,Exploitant public
4140,Établissement public local à caractère industriel ou commercial
4150,Régie d'une collectivité locale à caractère industriel ou commercial
4160,Institution Banque de France
5191,Société de caution mutuelle
5192,Société coopérative de banque populaire
5193,Caisse de crédit maritime mutuel
5194,Caisse (fédérale) de crédit mutuel
5195,Association coopérative inscrite (droit local Alsace Moselle)
5196,Caisse d'épargne et de prévoyance à forme coopérative
5202,Société en nom collectif
5203,Société en nom collectif coopérative
5306,Société en commandite simple
5307,Société en commandite simple coopérative
5308,Société en commandite par actions
5309,Société en commandite par actions coopérative
5310,Société en libre partenariat (SLP)
5370,Société de Participations Financières de Profession Libérale Société en commandite par actions (SPFPL SCA)
5385,Société d'exercice libéral en commandite par actions
5410,SARL nationale
5415,SARL d'économie mixte
5422,SARL immobilière pour le commerce et l'industrie (SICOMI)
5426,SARL immobilière de gestion
5430,SARL d'aménagement foncier et d'équipement rural (SAFER)
5431,SARL mixte d'intérêt agricole (SMIA)
5432,SARL d'intérêt collectif agricole (SICA)
5442,SARL d'attribution
5443,SARL coopérative de construction
5451,SARL coopérative de consommation
5453,SARL coopérative artisanale
5454,SARL coopérative d'intérêt maritime
5455,SARL coopérative de transport
5458,SARL coopérative de production (SCOP)
5459,SARL union de sociétés coopératives
5460,Autre SARL coopérative
5470,Société de Participations Financières de Profession Libérale Société à responsabilité limitée (SPFPL SARL)
5485,Société d'exercice libéral à responsabilité limitée
5499,Société à responsabilité limitée (sans autre indication)
5505,SA à participation ouvrière à conseil d'administration
5510,SA nationale à conseil d'administration
5515,SA d'économie mixte à conseil d'administration
5520,Fonds à forme sociétale à conseil d'administration
5522,SA immobilière pour le commerce et l'industrie (SICOMI) à conseil d'administration
5525,SA immobilière d'investissement à conseil d'administration
5530,SA d'aménagement foncier et d'équipement rural (SAFER) à conseil d'administration
5531,Société anonyme mixte d'intérêt agricole (SMIA) à conseil d'administration
5532,SA d'intérêt collectif agricole (SICA) à conseil d'administration
5542,SA d'attribution à conseil d'administration
5543,SA coopérative de construction à conseil d'administration
5546,SA de HLM à conseil d'administration
5547,SA coopérative de production de HLM à conseil d'administration
5548,SA de crédit immobilier à conseil d'administration
5551,SA coopérative de consommation à conseil d'administration
5552,SA coopérative de commerçants-détaillants à conseil d'administration
5553,SA coopérative artisanale à conseil d'administration
5554,SA coopérative (d'intérêt) maritime à conseil d'administration
5555,SA coopérative de transport à conseil d'administration
5558,SA coopérative de production (SCOP) à conseil d'administration
5559,SA union de sociétés coopératives à conseil d'administration
5560,Autre SA coopérative à conseil d'administration
5570,Société de Participations Financières de Profession Libérale Société anonyme à conseil d'administration (SPFPL SA à conseil d'administration)
5585,Société d'exercice libéral à forme anonyme à conseil d'administration
5599,SA à conseil d'administration (s.a.i.)
5605,SA à participation ouvrière à directoire
5610,SA nationale à directoire
5615,SA d'économie mixte à directoire
5620,Fonds à forme sociétale à directoire
5622,SA immobilière pour le commerce et l'industrie (SICOMI) à directoire
5625,SA immobilière d'investissement à directoire
5630,Safer anonyme à directoire
5631,SA mixte d'intérêt agricole (SMIA)
5632,SA d'intérêt collectif agricole (SICA)
5642,SA d'attribution à directoire
5643,SA coopérative de construction à directoire
5646,SA de HLM à directoire
5647,Société coopérative de production de HLM anonyme à directoire
5648,SA de crédit immobilier à directoire
5651,SA coopérative de consommation à directoire
5652,SA coopérative de commerçants-détaillants à directoire
5653,SA coopérative artisanale à directoire
5654,SA coopérative d'intérêt maritime à directoire
5655,SA coopérative de transport à directoire
5658,SA coopérative de production (SCOP) à directoire
5659,SA union de sociétés coopératives à directoire
5660,Autre SA coopérative à directoire
5670,Société de Participations Financières de Profession Libérale Société anonyme à Directoire (SPFPL SA à directoire)
5685,Société d'exercice libéral à forme anonyme à directoire
5699,SA à directoire (s.a.i.)
5710,"SAS, société par actions simplifiée"
5770,Société de Participations Financières de Profession Libérale Société par actions simplifiée (SPFPL SAS)
5785,Société d'exercice libéral par action simplifiée
5800,Société européenne
6100,Caisse d'Épargne et de Prévoyance
6210,Groupement européen d'intérêt économique (GEIE)
6220,Groupement d'intérêt économique (GIE)
6316,Coopérative d'utilisation de matériel agricole en commun (CUMA)
6317,Société coopérative agricole
6318,Union de sociétés coopératives agricoles
6411,Société d'assurance à forme mutuelle
6511,Sociétés Interprofessionnelles de Soins Ambulatoires 
6521,Société civile de placement collectif immobilier (SCPI)
6532,Société civile d'intérêt collectif agricole (SICA)
6533,Groupement agricole d'exploitation en commun (GAEC)
6534,Groupement foncier agricole
6535,Groupement agricole foncier
6536,Groupement forestier
6537,Groupement pastoral
6538,Groupement foncier et rural
6539,Société civile foncière
6540,Société civile immobilière
6541,Société civile immobilière de construction-vente
6542,Société civile d'attribution
6543,Société civile coopérative de construction
6544,Société civile immobilière d' accession progressive à la propriété
6551,Société civile coopérative de consommation
6554,Société civile coopérative d'intérêt maritime
6558,Société civile coopérative entre médecins
6560,Autre société civile coopérative
6561,SCP d'avocats
6562,SCP d'avocats aux conseils
6563,SCP d'avoués d'appel
6564,SCP d'huissiers
6565,SCP de notaires
6566,SCP de commissaires-priseurs
6567,SCP de greffiers de tribunal de commerce
6568,SCP de conseils juridiques
6569,SCP de commissaires aux comptes
6571,SCP de médecins
6572,SCP de dentistes
6573,SCP d'infirmiers
6574,SCP de masseurs-kinésithérapeutes
6575,SCP de directeurs de laboratoire d'analyse médicale
6576,SCP de vétérinaires
6577,SCP de géomètres experts
6578,SCP d'architectes
6585,Autre société civile professionnelle
6589,Société civile de moyens
6595,Caisse locale de crédit mutuel
6596,Caisse de crédit agricole mutuel
6597,Société civile d'exploitation agricole
6598,Exploitation agricole à responsabilité limitée
6599,Autre société civile
6901,Autre personne de droit privé inscrite au registre du commerce et des sociétés
7111,Autorité constitutionnelle
7112,Autorité administrative ou publique indépendante
7113,Ministère
7120,Service central d'un ministère
7150,Service du ministère de la Défense
7160,Service déconcentré à compétence nationale d'un ministère (hors Défense)
7171,Service déconcentré de l'État à compétence (inter) régionale
7172,Service déconcentré de l'État à compétence (inter) départementale
7179,(Autre) Service déconcentré de l'État à compétence territoriale
7190,Ecole nationale non dotée de la personnalité morale
7210,Commune et commune nouvelle
7220,Département
7225,Collectivité et territoire d'Outre Mer
7229,(Autre) Collectivité territoriale
7230,Région
7312,Commune associée et commune déléguée
7313,Section de commune
7314,Ensemble urbain
7321,Association syndicale autorisée
7322,Association foncière urbaine
7323,Association foncière de remembrement
7331,Établissement public local d'enseignement
7340,Pôle métropolitain
7341,Secteur de commune
7342,District urbain
7343,Communauté urbaine
7344,Métropole
7345,Syndicat intercommunal à vocation multiple (SIVOM)
7346,Communauté de communes
7347,Communauté de villes
7348,Communauté d'agglomération
7349,Autre établissement public local de coopération non spécialisé ou entente
7351,Institution interdépartementale ou entente
7352,Institution interrégionale ou entente
7353,Syndicat intercommunal à vocation unique (SIVU)
7354,Syndicat mixte fermé
7355,Syndicat mixte ouvert
7356,Commission syndicale pour la gestion des biens indivis des communes
7357,Pôle d'équilibre territorial et rural (PETR)
7361,Centre communal d'action sociale
7362,Caisse des écoles
7363,Caisse de crédit municipal
7364,Établissement d'hospitalisation
7365,Syndicat inter hospitalier
7366,Établissement public local social et médico-social
7367,Centre Intercommunal d'action sociale (CIAS)
7371,Office public d'habitation à loyer modéré (OPHLM)
7372,Service départemental d'incendie et de secours (SDIS)
7373,Établissement public local culturel
7378,Régie d'une collectivité locale à caractère administratif
7379,(Autre) Établissement public administratif local
7381,Organisme consulaire
7382,Établissement public national ayant fonction d'administration centrale
7383,Établissement public national à caractère scientifique culturel et professionnel
7384,Autre établissement public national d'enseignement
7385,Autre établissement public national administratif à compétence territoriale limitée
7389,Établissement public national à caractère administratif
7410,Groupement d'intérêt public (GIP)
7430,Établissement public des cultes d'Alsace-Lorraine
7450,"Etablissement public administratif, cercle et foyer dans les armées "
7470,Groupement de coopération sanitaire à gestion publique
7490,Autre personne morale de droit administratif
8110,Régime général de la Sécurité Sociale
8120,Régime spécial de Sécurité Sociale
8130,Institution de retraite complémentaire
8140,Mutualité sociale agricole
8150,Régime maladie des non-salariés non agricoles
8160,Régime vieillesse ne dépendant pas du régime général de la Sécurité Sociale
8170,Régime d'assurance chômage
8190,Autre régime de prévoyance sociale
8210,Mutuelle
8250,Assurance mutuelle agricole
8290,Autre organisme mutualiste
8310,Comité social économique dentreprise
8311,Comité social économique d'établissement
8410,Syndicat de salariés
8420,Syndicat patronal
8450,Ordre professionnel ou assimilé
8470,Centre technique industriel ou comité professionnel du développement économique
8490,Autre organisme professionnel
8510,Institution de prévoyance
8520,Institution de retraite supplémentaire
9110,Syndicat de copropriété
9150,Association syndicale libre
9210,Association non déclarée
9220,Association déclarée
9221,Association déclarée d'insertion par l'économique
9222,Association intermédiaire
9223,Groupement d'employeurs
9224,Association d'avocats à responsabilité professionnelle individuelle
9230,"Association déclarée, reconnue d'utilité publique"
9240,Congrégation
9260,"Association de droit local (Bas-Rhin, Haut-Rhin et Moselle)"
9300,Fondation
9900,Autre personne morale de droit privé
9970,Groupement de coopération sanitaire à gestion privée
1 code libellé
2 0000 Organisme de placement collectif en valeurs mobilières sans personnalité morale
3 1000 Entrepreneur individuel
4 2110 Indivision entre personnes physiques
5 2120 Indivision avec personne morale
6 2210 Société créée de fait entre personnes physiques
7 2220 Société créée de fait avec personne morale
8 2310 Société en participation entre personnes physiques
9 2320 Société en participation avec personne morale
10 2385 Société en participation de professions libérales
11 2400 Fiducie
12 2700 Paroisse hors zone concordataire
13 2800 Assujetti unique à la TVA
14 2900 Autre groupement de droit privé non doté de la personnalité morale
15 3110 Représentation ou agence commerciale d'état ou organisme public étranger immatriculé au RCS
16 3120 Société commerciale étrangère immatriculée au RCS
17 3205 Organisation internationale
18 3210 État, collectivité ou établissement public étranger
19 3220 Société étrangère non immatriculée au RCS
20 3290 Autre personne morale de droit étranger
21 4110 Établissement public national à caractère industriel ou commercial doté d'un comptable public
22 4120 Établissement public national à caractère industriel ou commercial non doté d'un comptable public
23 4130 Exploitant public
24 4140 Établissement public local à caractère industriel ou commercial
25 4150 Régie d'une collectivité locale à caractère industriel ou commercial
26 4160 Institution Banque de France
27 5191 Société de caution mutuelle
28 5192 Société coopérative de banque populaire
29 5193 Caisse de crédit maritime mutuel
30 5194 Caisse (fédérale) de crédit mutuel
31 5195 Association coopérative inscrite (droit local Alsace Moselle)
32 5196 Caisse d'épargne et de prévoyance à forme coopérative
33 5202 Société en nom collectif
34 5203 Société en nom collectif coopérative
35 5306 Société en commandite simple
36 5307 Société en commandite simple coopérative
37 5308 Société en commandite par actions
38 5309 Société en commandite par actions coopérative
39 5310 Société en libre partenariat (SLP)
40 5370 Société de Participations Financières de Profession Libérale Société en commandite par actions (SPFPL SCA)
41 5385 Société d'exercice libéral en commandite par actions
42 5410 SARL nationale
43 5415 SARL d'économie mixte
44 5422 SARL immobilière pour le commerce et l'industrie (SICOMI)
45 5426 SARL immobilière de gestion
46 5430 SARL d'aménagement foncier et d'équipement rural (SAFER)
47 5431 SARL mixte d'intérêt agricole (SMIA)
48 5432 SARL d'intérêt collectif agricole (SICA)
49 5442 SARL d'attribution
50 5443 SARL coopérative de construction
51 5451 SARL coopérative de consommation
52 5453 SARL coopérative artisanale
53 5454 SARL coopérative d'intérêt maritime
54 5455 SARL coopérative de transport
55 5458 SARL coopérative de production (SCOP)
56 5459 SARL union de sociétés coopératives
57 5460 Autre SARL coopérative
58 5470 Société de Participations Financières de Profession Libérale Société à responsabilité limitée (SPFPL SARL)
59 5485 Société d'exercice libéral à responsabilité limitée
60 5499 Société à responsabilité limitée (sans autre indication)
61 5505 SA à participation ouvrière à conseil d'administration
62 5510 SA nationale à conseil d'administration
63 5515 SA d'économie mixte à conseil d'administration
64 5520 Fonds à forme sociétale à conseil d'administration
65 5522 SA immobilière pour le commerce et l'industrie (SICOMI) à conseil d'administration
66 5525 SA immobilière d'investissement à conseil d'administration
67 5530 SA d'aménagement foncier et d'équipement rural (SAFER) à conseil d'administration
68 5531 Société anonyme mixte d'intérêt agricole (SMIA) à conseil d'administration
69 5532 SA d'intérêt collectif agricole (SICA) à conseil d'administration
70 5542 SA d'attribution à conseil d'administration
71 5543 SA coopérative de construction à conseil d'administration
72 5546 SA de HLM à conseil d'administration
73 5547 SA coopérative de production de HLM à conseil d'administration
74 5548 SA de crédit immobilier à conseil d'administration
75 5551 SA coopérative de consommation à conseil d'administration
76 5552 SA coopérative de commerçants-détaillants à conseil d'administration
77 5553 SA coopérative artisanale à conseil d'administration
78 5554 SA coopérative (d'intérêt) maritime à conseil d'administration
79 5555 SA coopérative de transport à conseil d'administration
80 5558 SA coopérative de production (SCOP) à conseil d'administration
81 5559 SA union de sociétés coopératives à conseil d'administration
82 5560 Autre SA coopérative à conseil d'administration
83 5570 Société de Participations Financières de Profession Libérale Société anonyme à conseil d'administration (SPFPL SA à conseil d'administration)
84 5585 Société d'exercice libéral à forme anonyme à conseil d'administration
85 5599 SA à conseil d'administration (s.a.i.)
86 5605 SA à participation ouvrière à directoire
87 5610 SA nationale à directoire
88 5615 SA d'économie mixte à directoire
89 5620 Fonds à forme sociétale à directoire
90 5622 SA immobilière pour le commerce et l'industrie (SICOMI) à directoire
91 5625 SA immobilière d'investissement à directoire
92 5630 Safer anonyme à directoire
93 5631 SA mixte d'intérêt agricole (SMIA)
94 5632 SA d'intérêt collectif agricole (SICA)
95 5642 SA d'attribution à directoire
96 5643 SA coopérative de construction à directoire
97 5646 SA de HLM à directoire
98 5647 Société coopérative de production de HLM anonyme à directoire
99 5648 SA de crédit immobilier à directoire
100 5651 SA coopérative de consommation à directoire
101 5652 SA coopérative de commerçants-détaillants à directoire
102 5653 SA coopérative artisanale à directoire
103 5654 SA coopérative d'intérêt maritime à directoire
104 5655 SA coopérative de transport à directoire
105 5658 SA coopérative de production (SCOP) à directoire
106 5659 SA union de sociétés coopératives à directoire
107 5660 Autre SA coopérative à directoire
108 5670 Société de Participations Financières de Profession Libérale Société anonyme à Directoire (SPFPL SA à directoire)
109 5685 Société d'exercice libéral à forme anonyme à directoire
110 5699 SA à directoire (s.a.i.)
111 5710 SAS, société par actions simplifiée
112 5770 Société de Participations Financières de Profession Libérale Société par actions simplifiée (SPFPL SAS)
113 5785 Société d'exercice libéral par action simplifiée
114 5800 Société européenne
115 6100 Caisse d'Épargne et de Prévoyance
116 6210 Groupement européen d'intérêt économique (GEIE)
117 6220 Groupement d'intérêt économique (GIE)
118 6316 Coopérative d'utilisation de matériel agricole en commun (CUMA)
119 6317 Société coopérative agricole
120 6318 Union de sociétés coopératives agricoles
121 6411 Société d'assurance à forme mutuelle
122 6511 Sociétés Interprofessionnelles de Soins Ambulatoires 
123 6521 Société civile de placement collectif immobilier (SCPI)
124 6532 Société civile d'intérêt collectif agricole (SICA)
125 6533 Groupement agricole d'exploitation en commun (GAEC)
126 6534 Groupement foncier agricole
127 6535 Groupement agricole foncier
128 6536 Groupement forestier
129 6537 Groupement pastoral
130 6538 Groupement foncier et rural
131 6539 Société civile foncière
132 6540 Société civile immobilière
133 6541 Société civile immobilière de construction-vente
134 6542 Société civile d'attribution
135 6543 Société civile coopérative de construction
136 6544 Société civile immobilière d' accession progressive à la propriété
137 6551 Société civile coopérative de consommation
138 6554 Société civile coopérative d'intérêt maritime
139 6558 Société civile coopérative entre médecins
140 6560 Autre société civile coopérative
141 6561 SCP d'avocats
142 6562 SCP d'avocats aux conseils
143 6563 SCP d'avoués d'appel
144 6564 SCP d'huissiers
145 6565 SCP de notaires
146 6566 SCP de commissaires-priseurs
147 6567 SCP de greffiers de tribunal de commerce
148 6568 SCP de conseils juridiques
149 6569 SCP de commissaires aux comptes
150 6571 SCP de médecins
151 6572 SCP de dentistes
152 6573 SCP d'infirmiers
153 6574 SCP de masseurs-kinésithérapeutes
154 6575 SCP de directeurs de laboratoire d'analyse médicale
155 6576 SCP de vétérinaires
156 6577 SCP de géomètres experts
157 6578 SCP d'architectes
158 6585 Autre société civile professionnelle
159 6589 Société civile de moyens
160 6595 Caisse locale de crédit mutuel
161 6596 Caisse de crédit agricole mutuel
162 6597 Société civile d'exploitation agricole
163 6598 Exploitation agricole à responsabilité limitée
164 6599 Autre société civile
165 6901 Autre personne de droit privé inscrite au registre du commerce et des sociétés
166 7111 Autorité constitutionnelle
167 7112 Autorité administrative ou publique indépendante
168 7113 Ministère
169 7120 Service central d'un ministère
170 7150 Service du ministère de la Défense
171 7160 Service déconcentré à compétence nationale d'un ministère (hors Défense)
172 7171 Service déconcentré de l'État à compétence (inter) régionale
173 7172 Service déconcentré de l'État à compétence (inter) départementale
174 7179 (Autre) Service déconcentré de l'État à compétence territoriale
175 7190 Ecole nationale non dotée de la personnalité morale
176 7210 Commune et commune nouvelle
177 7220 Département
178 7225 Collectivité et territoire d'Outre Mer
179 7229 (Autre) Collectivité territoriale
180 7230 Région
181 7312 Commune associée et commune déléguée
182 7313 Section de commune
183 7314 Ensemble urbain
184 7321 Association syndicale autorisée
185 7322 Association foncière urbaine
186 7323 Association foncière de remembrement
187 7331 Établissement public local d'enseignement
188 7340 Pôle métropolitain
189 7341 Secteur de commune
190 7342 District urbain
191 7343 Communauté urbaine
192 7344 Métropole
193 7345 Syndicat intercommunal à vocation multiple (SIVOM)
194 7346 Communauté de communes
195 7347 Communauté de villes
196 7348 Communauté d'agglomération
197 7349 Autre établissement public local de coopération non spécialisé ou entente
198 7351 Institution interdépartementale ou entente
199 7352 Institution interrégionale ou entente
200 7353 Syndicat intercommunal à vocation unique (SIVU)
201 7354 Syndicat mixte fermé
202 7355 Syndicat mixte ouvert
203 7356 Commission syndicale pour la gestion des biens indivis des communes
204 7357 Pôle d'équilibre territorial et rural (PETR)
205 7361 Centre communal d'action sociale
206 7362 Caisse des écoles
207 7363 Caisse de crédit municipal
208 7364 Établissement d'hospitalisation
209 7365 Syndicat inter hospitalier
210 7366 Établissement public local social et médico-social
211 7367 Centre Intercommunal d'action sociale (CIAS)
212 7371 Office public d'habitation à loyer modéré (OPHLM)
213 7372 Service départemental d'incendie et de secours (SDIS)
214 7373 Établissement public local culturel
215 7378 Régie d'une collectivité locale à caractère administratif
216 7379 (Autre) Établissement public administratif local
217 7381 Organisme consulaire
218 7382 Établissement public national ayant fonction d'administration centrale
219 7383 Établissement public national à caractère scientifique culturel et professionnel
220 7384 Autre établissement public national d'enseignement
221 7385 Autre établissement public national administratif à compétence territoriale limitée
222 7389 Établissement public national à caractère administratif
223 7410 Groupement d'intérêt public (GIP)
224 7430 Établissement public des cultes d'Alsace-Lorraine
225 7450 Etablissement public administratif, cercle et foyer dans les armées
226 7470 Groupement de coopération sanitaire à gestion publique
227 7490 Autre personne morale de droit administratif
228 8110 Régime général de la Sécurité Sociale
229 8120 Régime spécial de Sécurité Sociale
230 8130 Institution de retraite complémentaire
231 8140 Mutualité sociale agricole
232 8150 Régime maladie des non-salariés non agricoles
233 8160 Régime vieillesse ne dépendant pas du régime général de la Sécurité Sociale
234 8170 Régime d'assurance chômage
235 8190 Autre régime de prévoyance sociale
236 8210 Mutuelle
237 8250 Assurance mutuelle agricole
238 8290 Autre organisme mutualiste
239 8310 Comité social économique d’entreprise
240 8311 Comité social économique d'établissement
241 8410 Syndicat de salariés
242 8420 Syndicat patronal
243 8450 Ordre professionnel ou assimilé
244 8470 Centre technique industriel ou comité professionnel du développement économique
245 8490 Autre organisme professionnel
246 8510 Institution de prévoyance
247 8520 Institution de retraite supplémentaire
248 9110 Syndicat de copropriété
249 9150 Association syndicale libre
250 9210 Association non déclarée
251 9220 Association déclarée
252 9221 Association déclarée d'insertion par l'économique
253 9222 Association intermédiaire
254 9223 Groupement d'employeurs
255 9224 Association d'avocats à responsabilité professionnelle individuelle
256 9230 Association déclarée, reconnue d'utilité publique
257 9240 Congrégation
258 9260 Association de droit local (Bas-Rhin, Haut-Rhin et Moselle)
259 9300 Fondation
260 9900 Autre personne morale de droit privé
261 9970 Groupement de coopération sanitaire à gestion privée
+100
View File
@@ -0,0 +1,100 @@
File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 940, in exec_module
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
File "/var/www/decpinfo/run.py", line 1, in <module>
from src.app import app
File "/var/www/decpinfo/src/app.py", line 30, in <module>
app: Dash = Dash(
^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 639, in __init__
self.init_app()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 752, in init_app
self.enable_pages()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 2535, in enable_pages
_import_layouts_from_pages(self.config.pages_folder)
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/_pages.py", line 442, in _import_layouts_from_pages
spec.loader.exec_module(page_module) # type: ignore[reportOptionalMemberAccess]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/pages/observatoire.py", line 19, in <module>
from src.db import schema
File "/var/www/decpinfo/src/db.py", line 126, in <module>
DB_PATH = _ensure_database()
^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 119, in _ensure_database
if should_rebuild(db_path, parquet_path):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 23, in should_rebuild
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/pathlib.py", line 1014, in stat
return os.stat(self, follow_symlinks=follow_symlinks)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/srv/shared/decp/prod/dist/decp.parquet'
[2026-05-01 08:00:30 +0200] [2216551] [INFO] Worker exiting (pid: 2216551)
[2026-05-01 08:00:30 +0200] [2216552] [ERROR] Exception in worker process
Traceback (most recent call last):
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/arbiter.py", line 608, in spawn_worker
worker.init_process()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/workers/base.py", line 135, in init_process
self.load_wsgi()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/workers/base.py", line 147, in load_wsgi
self.wsgi = self.app.wsgi()
^^^^^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/base.py", line 66, in wsgi
self.callable = self.load()
^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/wsgiapp.py", line 57, in load
return self.load_wsgiapp()
^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/wsgiapp.py", line 47, in load_wsgiapp
return util.import_app(self.app_uri)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/util.py", line 370, in import_app
mod = importlib.import_module(module)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/importlib/__init__.py", line 126, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<frozen importlib._bootstrap>", line 1206, in _gcd_import
File "<frozen importlib._bootstrap>", line 1178, in _find_and_load
File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 940, in exec_module
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
File "/var/www/decpinfo/run.py", line 1, in <module>
from src.app import app
File "/var/www/decpinfo/src/app.py", line 30, in <module>
app: Dash = Dash(
^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 639, in __init__
self.init_app()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 752, in init_app
self.enable_pages()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 2535, in enable_pages
_import_layouts_from_pages(self.config.pages_folder)
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/_pages.py", line 442, in _import_layouts_from_pages
spec.loader.exec_module(page_module) # type: ignore[reportOptionalMemberAccess]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/pages/observatoire.py", line 19, in <module>
from src.db import schema
File "/var/www/decpinfo/src/db.py", line 126, in <module>
DB_PATH = _ensure_database()
^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 119, in _ensure_database
if should_rebuild(db_path, parquet_path):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 23, in should_rebuild
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/pathlib.py", line 1014, in stat
return os.stat(self, follow_symlinks=follow_symlinks)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/srv/shared/decp/prod/dist/decp.parquet'
[2026-05-01 08:00:30 +0200] [2216552] [INFO] Worker exiting (pid: 2216552)
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216549) exited with code 3
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216550) was sent SIGTERM!
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216552) was sent SIGTERM!
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216551) was sent SIGTERM!
[2026-05-01 08:00:31 +0200] [2216548] [ERROR] Shutting down: Master
[2026-05-01 08:00:31 +0200] [2216548] [ERROR] Reason: Worker failed to boot.
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Sauvegarde horaire de users.sqlite (colibre) vers S3
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=colibre
WorkingDirectory=/var/www/colibre
EnvironmentFile=/var/www/colibre/.env
ExecStart=/var/www/colibre/.venv/bin/python -m src.backup backup
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Déclenche la sauvegarde horaire de users.sqlite (colibre)
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
+114
View File
@@ -0,0 +1,114 @@
# Configuration nginx de référence pour colibre.fr + migration decp.info.
#
# Installation (sur le serveur, en root) :
# cp deploy/nginx-colibre.conf /etc/nginx/sites-available/colibre
# ln -s /etc/nginx/sites-available/colibre /etc/nginx/sites-enabled/colibre
# nginx -t && systemctl reload nginx
#
# Les certificats TLS sont gérés par certbot (Let's Encrypt) :
# certbot --nginx -d colibre.fr -d www.colibre.fr -d decp.info -d www.decp.info
# Obtenir/installer les certificats AVANT d'activer les redirections 301 :
# une 301 vers un https cassé est mise en cache par les navigateurs/Google.
# ---------------------------------------------------------------------------
# 1. Migration decp.info → colibre.fr (301 permanentes, chemin préservé)
# La structure d'URL est identique entre les deux domaines (rebranding),
# donc $request_uri suffit : /acheteurs/123 → https://colibre.fr/acheteurs/123.
# On NE redirige PAS vers la home : chaque ancienne URL garde son équivalent,
# ce qui transfère l'autorité SEO page par page.
# ---------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name decp.info www.decp.info;
# Laisser certbot répondre aux challenges ACME avant la redirection.
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://colibre.fr$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name decp.info www.decp.info;
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
return 301 https://colibre.fr$request_uri;
}
# ---------------------------------------------------------------------------
# 1b. Migration test.decp.info → test.colibre.fr (302 temporaire, chemin
# préservé). Contrairement à la prod (section 1), on reste en 302 tant
# que l'environnement de test est susceptible de bouger, pour ne pas
# risquer un cache navigateur/moteur sur une redirection qui n'est pas
# encore définitive.
# Nécessite un certificat couvrant test.colibre.fr (et test.decp.info
# pour le vhost 443 ci-dessous), par ex. :
# certbot --nginx -d test.colibre.fr -d test.decp.info
# ---------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name test.decp.info;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 302 https://test.colibre.fr$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name test.decp.info;
ssl_certificate /etc/letsencrypt/live/test.colibre.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/test.colibre.fr/privkey.pem;
return 302 https://test.colibre.fr$request_uri;
}
# ---------------------------------------------------------------------------
# 2. www.colibre.fr → colibre.fr (canonicalisation : un seul hôte indexé)
# ---------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name colibre.fr www.colibre.fr;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://colibre.fr$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.colibre.fr;
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
return 301 https://colibre.fr$request_uri;
}
# ---------------------------------------------------------------------------
# 3. Hôte canonique : https://colibre.fr → application Dash (gunicorn)
# ---------------------------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name colibre.fr;
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
# Le sitemap acheteurs/titulaires peut être volumineux : autoriser de
# gros corps de réponse en proxy et des timeouts généreux.
proxy_read_timeout 120s;
location / {
# gunicorn app:server — adapter le bind à l'unit systemd colibre.
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -0,0 +1,318 @@
# Observatoire localStorage Filter Persistence Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Persist all 10 observatoire filter values in `localStorage` so they survive navigation and page reloads; URL params (`acheteur_id` / `titulaire_id`) override stored values.
**Architecture:** Add `dcc.Store(storage_type="local", id="observatoire-filters")` to the observatoire layout. A new save callback writes all 10 filter values to the store on any change (`prevent_initial_call=True` to avoid wiping localStorage on page mount). The existing `restore_filters_from_url` callback is expanded: it now outputs all 10 filters, accepts the store as `State`, and prioritises URL params over stored values.
**Tech Stack:** Dash 3.4 `dcc.Store` with `storage_type="local"`, Dash callbacks (Input / Output / State), pytest + Selenium (`DashComposite`).
---
## File Map
| Action | File |
| ------ | --------------------------------------------------------------------- |
| Modify | `src/pages/observatoire.py` — layout + 2 callbacks |
| Modify | `tests/test_main.py` — add `test_009_observatoire_filter_persistence` |
---
### Task 1: Write a failing integration test
**Files:**
- Modify: `tests/test_main.py`
- [ ] **Step 1: Add test_009 at the end of `tests/test_main.py`**
```python
def test_009_observatoire_filter_persistence(dash_duo: DashComposite):
from src.app import app
dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
# Clear localStorage to start from a clean state
dash_duo.driver.execute_script("localStorage.clear()")
# Navigate to observatoire without URL params
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
# Set the acheteur_id text input
# dcc.Input without debounce fires the save callback on every keystroke,
# so the value is written to localStorage as soon as typing finishes.
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
dash_duo.clear_input(acheteur_input)
acheteur_input.send_keys("999000000000")
import time
time.sleep(0.3) # allow the save callback to complete
# Navigate away
dash_duo.wait_for_page(f"{dash_duo.server_url}/")
# Navigate back without URL params
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
import time
time.sleep(0.5) # allow callback chain to complete
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
assert acheteur_input.get_attribute("value") == "999000000000", (
"acheteur_id should be restored from localStorage after navigating back"
)
# Also verify URL params still override localStorage
dash_duo.wait_for_page(
f"{dash_duo.server_url}/observatoire?acheteur_id=123"
)
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
time.sleep(0.5)
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
assert acheteur_input.get_attribute("value") == "123", (
"URL param acheteur_id should override the value stored in localStorage"
)
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: **FAIL** — the input value after navigating back will be `""`, not `"999000000000"`.
- [ ] **Step 3: Commit**
```bash
git add tests/test_main.py
git commit -m "test: failing test for observatoire localStorage filter persistence #65"
```
---
### Task 2: Add `dcc.Store` to the layout and debounce the text inputs
**Files:**
- Modify: `src/pages/observatoire.py`
- [ ] **Step 1: Add the Store component to the layout**
In `src/pages/observatoire.py`, add this line immediately after the existing `dcc.Location` (line 123):
```python
dcc.Store(id="observatoire-filters", storage_type="local"),
```
So the top of the `layout` list becomes:
```python
layout = [
dcc.Location(id="dashboard_url", refresh="callback-nav"),
dcc.Store(id="observatoire-filters", storage_type="local"),
dbc.Modal(
...
```
- [ ] **Step 2: Add `debounce=True` to both text inputs**
`dcc.Input` without debounce fires on every keystroke, which would trigger the save callback (and the existing heavy `udpate_dashboard_cards` callback) on every character typed. `debounce=True` delays the callback until the user presses Enter or moves focus away.
Change `dashboard_acheteur_id` (around line 178):
```python
dcc.Input(
id="dashboard_acheteur_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
),
```
Change `dashboard_titulaire_id` (around line 209):
```python
dcc.Input(
id="dashboard_titulaire_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
),
```
- [ ] **Step 3: Run the test — still fails (restore not implemented yet)**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: still **FAIL**.
- [ ] **Step 4: Commit**
```bash
git add src/pages/observatoire.py
git commit -m "feat: add dcc.Store and debounce text inputs on observatoire page #65"
```
---
### Task 3: Add a save-to-localStorage callback
**Files:**
- Modify: `src/pages/observatoire.py`
- [ ] **Step 1: Add the save callback after the existing `restore_filters_from_url` callback**
```python
@callback(
Output("observatoire-filters", "data"),
Input("dashboard_year", "value"),
Input("dashboard_acheteur_id", "value"),
Input("dashboard_acheteur_categorie", "value"),
Input("dashboard_acheteur_departement_code", "value"),
Input("dashboard_titulaire_id", "value"),
Input("dashboard_titulaire_categorie", "value"),
Input("dashboard_titulaire_departement_code", "value"),
Input("dashboard_marche_type", "value"),
Input("dashboard_marche_considerationsSociales", "value"),
Input("dashboard_marche_considerationsEnvironnementales", "value"),
prevent_initial_call=True,
)
def save_filters_to_storage(
year,
acheteur_id,
acheteur_categorie,
acheteur_departement_code,
titulaire_id,
titulaire_categorie,
titulaire_departement_code,
marche_type,
considerations_sociales,
considerations_environnementales,
):
return {
"year": year,
"acheteur_id": acheteur_id,
"acheteur_categorie": acheteur_categorie,
"acheteur_departement_code": acheteur_departement_code,
"titulaire_id": titulaire_id,
"titulaire_categorie": titulaire_categorie,
"titulaire_departement_code": titulaire_departement_code,
"marche_type": marche_type,
"considerations_sociales": considerations_sociales,
"considerations_environnementales": considerations_environnementales,
}
```
**Why `prevent_initial_call=True`:** Without it, this callback fires on every page load with all-`None` values (the component defaults), immediately overwriting any saved filters. Setting `prevent_initial_call=True` skips that first call; subsequent user-driven changes still fire normally.
- [ ] **Step 2: Run the test — still fails (restore callback not updated yet)**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: still **FAIL**.
- [ ] **Step 3: Commit**
```bash
git add src/pages/observatoire.py
git commit -m "feat: save observatoire filters to localStorage on change #65"
```
---
### Task 4: Update the restore callback to read from localStorage
**Files:**
- Modify: `src/pages/observatoire.py`
This is the core change. Replace the existing `restore_filters_from_url` callback entirely.
- [ ] **Step 1: Replace the existing callback**
Remove the current `restore_filters_from_url` function and its `@callback` decorator (lines 302323) and replace with:
```python
@callback(
Output("dashboard_year", "value"),
Output("dashboard_acheteur_id", "value"),
Output("dashboard_acheteur_categorie", "value"),
Output("dashboard_acheteur_departement_code", "value"),
Output("dashboard_titulaire_id", "value"),
Output("dashboard_titulaire_categorie", "value"),
Output("dashboard_titulaire_departement_code", "value"),
Output("dashboard_marche_type", "value"),
Output("dashboard_marche_considerationsSociales", "value"),
Output("dashboard_marche_considerationsEnvironnementales", "value"),
Input("dashboard_url", "search"),
Input("dashboard_url", "pathname"),
State("observatoire-filters", "data"),
)
def restore_filters(search, _pathname, stored_filters):
# URL params take absolute priority: clear all other filters and apply only
# the values present in the URL (acheteur_id and/or titulaire_id).
if search:
params = urllib.parse.parse_qs(search.lstrip("?"))
acheteur_id = (params.get("acheteur_id") or [None])[0] or None
titulaire_id = (params.get("titulaire_id") or [None])[0] or None
if acheteur_id or titulaire_id:
return None, acheteur_id, None, None, titulaire_id, None, None, None, None, None
# No URL params: restore from localStorage if available.
if stored_filters:
return (
stored_filters.get("year"),
stored_filters.get("acheteur_id"),
stored_filters.get("acheteur_categorie"),
stored_filters.get("acheteur_departement_code"),
stored_filters.get("titulaire_id"),
stored_filters.get("titulaire_categorie"),
stored_filters.get("titulaire_departement_code"),
stored_filters.get("marche_type"),
stored_filters.get("considerations_sociales"),
stored_filters.get("considerations_environnementales"),
)
return (no_update,) * 10
```
**Key design decisions:**
- `Input("dashboard_url", "pathname")` added alongside `search`: guarantees the callback fires on every navigation to the observatoire page, even when `search` stays `""` (no query string) across two navigations. The `_pathname` value itself is unused in the body; it exists only as a trigger.
- `State("observatoire-filters", "data")` (not `Input`): the store triggers no re-run when the save callback writes to it, avoiding an infinite loop.
- When URL params are present, _all_ non-URL filters are cleared (`None`), which causes the save callback to overwrite localStorage with only the URL-provided values.
- `(no_update,) * 10` when there is nothing to restore: leaves defaults intact.
- [ ] **Step 2: Run the target test**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: **PASS**.
- [ ] **Step 3: Run the full test suite**
```bash
pytest -v
```
Expected: all tests **PASS**. Pay special attention to `test_006` and `test_007` (URL → input / share URL), which exercise the code path that was just rewritten.
- [ ] **Step 4: Commit**
```bash
git add src/pages/observatoire.py
git commit -m "feat: restore observatoire filters from localStorage on page load #65"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,321 @@
# Excel Export Styling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Améliorer les exports Excel de toutes les vues tableau : en-têtes stylisés (fond rouge `#b33821`, texte blanc, gras), largeurs de colonnes issues du `DataTable` commun, largeur minimale de 3.5 cm, et retour à la ligne automatique dans toutes les cellules.
**Architecture:** Une fonction utilitaire `write_styled_excel(df, buffer, worksheet)` centralisée dans `src/utils/table.py` crée le workbook xlsxwriter avec `default_format_properties: {text_wrap: True}`, puis délègue à `polars.DataFrame.write_excel` avec le `header_format` et les `column_widths` calculés. Les 6 callbacks de téléchargement appellent cette fonction à la place de `.write_excel()` direct.
**Tech Stack:** Python, Polars, xlsxwriter (déjà en dep), openpyxl (disponible, utilisé en test uniquement)
## Global Constraints
- Imports de modules internes toujours via `src.` (ex. `from src.utils.table import …`)
- `xlsxwriter` est en dep de prod — pas besoin de l'ajouter
- `openpyxl` est disponible (dep transitive) — pas besoin de l'ajouter à `pyproject.toml`
- Run tests : `uv run pytest` (pas `pytest` direct)
- Couleur primaire de l'app : `#b33821`
- Largeur minimale : 132 pixels (≈ 3.5 cm à 96 DPI)
---
## File Map
| Fichier | Action | Rôle |
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `src/utils/table.py` | Modifier | Ajouter `import xlsxwriter`, constantes `_EXCEL_*`, fonction `write_styled_excel` |
| `tests/test_excel.py` | Créer | Tests unitaires de `write_styled_excel` |
| `src/pages/tableau.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_data` |
| `src/pages/acheteur.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_acheteur_data` et `download_filtered_acheteur_data` |
| `src/pages/titulaire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_titulaire_data` et `download_filtered_titulaire_data` |
| `src/pages/observatoire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_observatoire` |
---
### Task 1 : `write_styled_excel` dans `src/utils/table.py`
**Files:**
- Modify: `src/utils/table.py:1` (ajouter import), `src/utils/table.py:559` (fin de fichier)
- Create: `tests/test_excel.py`
**Interfaces:**
- Produces: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` — exporté publiquement depuis `src.utils.table`
- [ ] **Step 1 : Créer `tests/test_excel.py` avec les tests en échec**
```python
import io
import openpyxl
import polars as pl
import pytest
from src.utils.table import write_styled_excel
def test_write_styled_excel_header_bold_and_color():
df = pl.DataFrame({"objet": ["marché A"], "montant": [1000.0]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
cell = ws.cell(row=1, column=1)
assert cell.font.bold is True
assert cell.fill.fgColor.rgb == "FFB33821"
assert cell.font.color.rgb == "FFFFFFFF"
def test_write_styled_excel_data_cell_text_wrap():
df = pl.DataFrame({"objet": ["marché A"]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
cell = ws.cell(row=2, column=1)
assert cell.alignment.wrap_text is True
def test_write_styled_excel_known_column_wider_than_minimum():
# "objet" a une largeur explicite (350px) > minimum (132px)
# "autre" n'a pas de largeur explicite → reçoit le minimum
df = pl.DataFrame({"autre": ["x"], "objet": ["y"]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre"
width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet"
assert width_autre > 0
assert width_objet > width_autre
def test_write_styled_excel_custom_worksheet_name():
df = pl.DataFrame({"col": ["val"]})
buf = io.BytesIO()
write_styled_excel(df, buf, worksheet="2025")
buf.seek(0)
wb = openpyxl.load_workbook(buf)
assert "2025" in wb.sheetnames
```
- [ ] **Step 2 : Vérifier que les tests échouent**
```bash
uv run pytest tests/test_excel.py -v
```
Attendu : `ImportError` ou `AttributeError: module 'src.utils.table' has no attribute 'write_styled_excel'`
- [ ] **Step 3 : Ajouter `import xlsxwriter` dans `src/utils/table.py`**
Ligne 1 du fichier, après les imports existants. Le bloc imports actuel (lignes 114) devient :
```python
import os
import uuid
import polars as pl
import xlsxwriter
from dash import no_update
from polars import selectors as cs
from unidecode import unidecode
from src.db import count_marches, count_unique_marches, query_marches, schema
from src.utils import logger
from src.utils.cache import cache
from src.utils.data import DATA_SCHEMA
from src.utils.frontend import get_button_properties
from src.utils.tracking import track_search
```
- [ ] **Step 4 : Ajouter les constantes et `write_styled_excel` à la fin de `src/utils/table.py`**
Après la ligne `COLUMNS = schema.names()` (actuellement dernière ligne, 559) :
```python
_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI
_EXCEL_HEADER_FORMAT = {
"bold": True,
"bg_color": "#b33821",
"font_color": "white",
}
_EXCEL_COLUMN_WIDTHS = {
"objet": 350,
"acheteur_nom": 250,
"titulaire_nom": 250,
"acheteur_id": 160,
}
def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None:
col_widths = {
col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0))
for col in df.columns
}
wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}})
ws = wb.add_worksheet(worksheet)
df.write_excel(
workbook=wb,
worksheet=ws,
header_format=_EXCEL_HEADER_FORMAT,
column_widths=col_widths,
)
wb.close()
```
- [ ] **Step 5 : Vérifier que les tests passent**
```bash
uv run pytest tests/test_excel.py -v
```
Attendu : 4 tests PASS
- [ ] **Step 6 : Commit**
```bash
git add src/utils/table.py tests/test_excel.py
git commit -m "feat: add write_styled_excel utility for styled Excel exports #83"
```
---
### Task 2 : Mettre à jour les 6 callbacks de téléchargement
**Files:**
- Modify: `src/pages/tableau.py:26-33,344-345`
- Modify: `src/pages/acheteur.py:30-37,399-402,441-442`
- Modify: `src/pages/titulaire.py:29-36,421-423,462-463`
- Modify: `src/pages/observatoire.py:43,813-814`
**Interfaces:**
- Consumes: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` depuis Task 1
- [ ] **Step 1 : Mettre à jour `src/pages/tableau.py`**
Ajouter `write_styled_excel` à l'import existant (lignes 2633) :
```python
from src.utils.table import (
COLUMNS,
filter_table_data,
get_default_hidden_columns,
invert_columns,
prepare_table_data,
sort_table_data,
write_styled_excel,
)
```
Remplacer le bloc `def to_bytes` dans `download_data` (lignes 344345) :
```python
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
- [ ] **Step 2 : Mettre à jour `src/pages/acheteur.py`**
Ajouter `write_styled_excel` à l'import existant (lignes 3037) :
```python
from src.utils.table import (
COLUMNS,
filter_table_data,
format_number,
get_default_hidden_columns,
prepare_table_data,
sort_table_data,
write_styled_excel,
)
```
Remplacer le bloc `def to_bytes` dans `download_acheteur_data` (lignes 399402) :
```python
def to_bytes(buffer):
write_styled_excel(
df_to_download,
buffer,
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
)
```
Remplacer le bloc `def to_bytes` dans `download_filtered_acheteur_data` (ligne 441442) :
```python
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
- [ ] **Step 3 : Mettre à jour `src/pages/titulaire.py`**
Ajouter `write_styled_excel` à l'import existant (lignes 2936) :
```python
from src.utils.table import (
COLUMNS,
filter_table_data,
format_number,
get_default_hidden_columns,
prepare_table_data,
sort_table_data,
write_styled_excel,
)
```
Remplacer le bloc `def to_bytes` dans `download_titulaire_data` (lignes 421423) :
```python
def to_bytes(buffer):
write_styled_excel(
df_to_download,
buffer,
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
)
```
Remplacer le bloc `def to_bytes` dans `download_filtered_titulaire_data` (lignes 462463) :
```python
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
- [ ] **Step 4 : Mettre à jour `src/pages/observatoire.py`**
Modifier l'import (ligne 43) :
```python
from src.utils.table import COLUMNS, get_default_hidden_columns, prepare_table_data, write_styled_excel
```
Remplacer le bloc `def to_bytes` dans `download_observatoire` (ligne 813814) :
```python
def to_bytes(buffer):
write_styled_excel(dff, buffer)
```
- [ ] **Step 5 : Vérifier les tests existants et les nouveaux**
```bash
uv run pytest tests/test_excel.py tests/test_main.py::test_003_tableau_download -v
```
Attendu : tous PASS (le test existant vérifie que le contenu base64 est non vide et que le nom de fichier commence par `decp_` — comportement inchangé)
- [ ] **Step 6 : Commit**
```bash
git add src/pages/tableau.py src/pages/acheteur.py src/pages/titulaire.py src/pages/observatoire.py
git commit -m "refactor: use write_styled_excel in all download callbacks #83"
```
@@ -0,0 +1,350 @@
# Défilement horizontal ergonomique des tableaux (#82) — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rendre le défilement horizontal des tableaux toujours accessible (barre miroir en haut, synchronisée) et garder les en-têtes de colonnes visibles pendant le défilement vertical de la page.
**Architecture:** Option B (sticky au niveau page). Le tableau reste dans le flux de la page. CSS rend les en-têtes `position: sticky; top: 0`. Une barre de défilement « miroir » est injectée par JS au-dessus de chaque tableau, sticky en haut, et synchronisée avec le conteneur scrollable du tableau. Aucun scroll vertical imbriqué.
**Tech Stack:** Dash 3.4 `dash_table.DataTable`, CSS (`src/assets/css/style.css`), JS vanilla auto-chargé depuis `src/assets/` (pattern existant : MutationObserver, cf. `dash_clientside.js`), tests Selenium (`dash[testing]` / `DashComposite`).
## Global Constraints
- Importer les modules de l'app avec le préfixe `src.` (ex. `src.figures`), jamais `figures`.
- UI en français.
- Cibler la classe partagée `marches_table` (présente sur les 4 pages : `/tableau`, `/acheteur`, `/titulaire`, `/observatoire`) — pas de duplication par page.
- Ne pas modifier la hauteur du tableau ni introduire de conteneur à hauteur fixe (option A explicitement écartée).
- Les commits référencent `#82`.
- Le pre-commit hook lance `prettier` (CSS/JS/MD) et `ruff` (Python) et **modifie les fichiers** : après un échec dû au reformatage, refaire `git add` puis recommiter.
- Terminer chaque message de commit par : `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
---
## File Structure
| Fichier | Responsabilité | Action |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| `src/assets/css/style.css` | En-têtes sticky + neutralisation overflow interne Dash + styles de la barre miroir | Modifier |
| `src/assets/table_hscroll.js` | Injecter la barre miroir au-dessus de chaque `.marches_table`, synchroniser le scroll, masquer si pas de débordement, recalculer au resize / re-render | Créer |
| `tests/test_tableau_hscroll.py` | Test Selenium : barre miroir présente + en-tête sticky sur `/tableau` | Créer |
| `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` | Consigner le verdict du spike (Task 1) | Modifier |
**Décision DOM clé :** la barre miroir est **injectée par JS** (et non ajoutée dans le markup Python), ce qui évite de toucher au markup des 4 pages et fonctionne quel que soit le wrapper. Le conteneur scrollable du tableau est l'élément Dash `.dash-spreadsheet-container` (ou son parent direct), confirmé au spike.
---
## Task 1 : Spike — vérifier le DOM réel et la technique sticky
**But :** lever l'inconnue principale (structure DOM de DataTable + compatibilité `position: sticky` des en-têtes avec un conteneur `overflow-x`). Produit un verdict écrit qui pilote les tâches suivantes.
**Files:**
- Modify: `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` (section « Verdict du spike »)
- [ ] **Step 1 : Lancer l'app**
```bash
source .venv/bin/activate
python run.py
```
Ouvrir `http://127.0.0.1:8050/tableau` dans le navigateur.
- [ ] **Step 2 : Inspecter le DOM du tableau**
Dans les DevTools, sur le tableau, relever et noter :
- l'élément qui porte la **largeur totale** du tableau (table `.cell-table`) et sa largeur (`scrollWidth`) vs. celle de son parent (`clientWidth`) → confirme le débordement ;
- l'élément qui (le cas échéant) porte déjà un `overflow`/`overflow-x` (regarder `.dash-spreadsheet-container`, `.dash-spreadsheet-inner`) via l'onglet _Computed_ ;
- le sélecteur exact de la ligne d'en-tête (attendu : `th.dash-header` dans un `tr`).
- [ ] **Step 3 : Tester l'hypothèse sticky en live**
Dans la console DevTools, appliquer à chaud :
```js
document
.querySelectorAll(
".marches_table .dash-spreadsheet-container, .marches_table .dash-spreadsheet-inner"
)
.forEach((e) => (e.style.overflow = "visible"));
document.querySelectorAll(".marches_table th.dash-header").forEach((e) => {
e.style.position = "sticky";
e.style.top = "0";
e.style.zIndex = "10";
e.style.background = "#fff";
});
```
Scroller verticalement la page → **les en-têtes restent-ils collés en haut ?**
- [ ] **Step 4 : Consigner le verdict**
Ajouter une section « Verdict du spike » à la spec, répondant à :
- sélecteur exact de l'en-tête et du conteneur scrollable ;
- l'astuce `overflow: visible` sur les conteneurs Dash suffit-elle à faire fonctionner le sticky page ? (attendu : oui) ;
- si **non** : noter que les en-têtes sticky devront être pilotés en JS (repositionnement au scroll) — le reste du plan reste valable, seul l'implémentation du sticky de la Task 2 change.
- [ ] **Step 5 : Commit**
```bash
git add docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md
git commit -m "docs: verdict spike DOM tableaux #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2 : En-têtes collants (CSS)
**But :** les en-têtes de colonnes restent visibles en haut de la fenêtre pendant le défilement vertical de la page.
**Files:**
- Modify: `src/assets/css/style.css` (ajouter après le bloc `.marches_table.stuck`, vers la ligne 360)
**Interfaces:**
- Consumes: sélecteurs confirmés au spike (Task 1) : `.marches_table th.dash-header`, conteneurs `.dash-spreadsheet-container` / `.dash-spreadsheet-inner`.
- Produces: classe `marches_table` dont les conteneurs Dash internes sont en `overflow: visible` et dont les en-têtes sont sticky — la Task 3 (JS) s'appuie dessus pour poser le conteneur scrollable et la barre miroir.
- [ ] **Step 1 : Écrire le CSS des en-têtes sticky**
Dans `src/assets/css/style.css`, ajouter :
```css
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */
.marches_table .dash-spreadsheet-container,
.marches_table .dash-spreadsheet-inner {
overflow: visible !important;
}
/* En-têtes de colonnes collants en haut de la fenêtre */
.marches_table th.dash-header {
position: sticky;
top: 0;
z-index: 10;
background-color: #fff;
}
```
> Si le verdict du spike indique que le sticky CSS ne tient pas, remplacer ce bloc par le repositionnement JS décrit dans la spec et le porter en Task 3 ; documenter le choix dans le commit.
- [ ] **Step 2 : Vérifier dans le navigateur**
App lancée (`python run.py`), sur `/tableau` : scroller verticalement → l'en-tête reste figé en haut, sur fond opaque, au-dessus des lignes. Vérifier aussi `/acheteur` et `/observatoire`.
- [ ] **Step 3 : Commit**
```bash
git add src/assets/css/style.css
git commit -m "feat(tableaux): en-têtes de colonnes collants #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3 : Barre de défilement miroir (JS + CSS)
**But :** une barre de défilement horizontale toujours visible en haut du tableau, synchronisée avec le défilement horizontal du tableau, masquée si le tableau ne déborde pas.
**Files:**
- Create: `src/assets/table_hscroll.js`
- Modify: `src/assets/css/style.css` (styles de `.dt-hscroll`)
**Interfaces:**
- Consumes: les `.marches_table` rendues par Dash ; le conteneur scrollable interne (`.dash-spreadsheet-container`, confirmé au spike) dont on lit `scrollWidth`/`clientWidth` et qu'on pilote via `scrollLeft`.
- Produces: pour chaque `.marches_table`, un nœud `<div class="dt-hscroll">` inséré en première position, contenant `<div class="dt-hscroll-inner">` dont la largeur = largeur totale du tableau.
- [ ] **Step 1 : Écrire le CSS de la barre miroir**
Dans `src/assets/css/style.css`, à la suite du bloc de la Task 2 :
```css
/* Barre de défilement horizontale miroir, collée en haut */
.marches_table .dt-hscroll {
position: sticky;
top: 0;
z-index: 11; /* au-dessus des en-têtes sticky */
overflow-x: auto;
overflow-y: hidden;
height: 14px;
}
.marches_table .dt-hscroll-inner {
height: 1px;
}
.marches_table .dt-hscroll.is-hidden {
display: none;
}
```
- [ ] **Step 2 : Écrire le JS de synchronisation**
Créer `src/assets/table_hscroll.js` :
```js
// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82
(function () {
"use strict";
// Renvoie le conteneur réellement scrollable horizontalement du tableau.
function getScrollEl(wrapper) {
return wrapper.querySelector(".dash-spreadsheet-container") || wrapper;
}
function setup(wrapper) {
if (wrapper.dataset.hscrollReady === "1") return;
const scrollEl = getScrollEl(wrapper);
if (!scrollEl) return;
const bar = document.createElement("div");
bar.className = "dt-hscroll is-hidden";
const inner = document.createElement("div");
inner.className = "dt-hscroll-inner";
bar.appendChild(inner);
wrapper.insertBefore(bar, wrapper.firstChild);
let syncing = false;
const onBar = () => {
if (syncing) return;
syncing = true;
scrollEl.scrollLeft = bar.scrollLeft;
syncing = false;
};
const onTable = () => {
if (syncing) return;
syncing = true;
bar.scrollLeft = scrollEl.scrollLeft;
syncing = false;
};
bar.addEventListener("scroll", onBar);
scrollEl.addEventListener("scroll", onTable);
const refresh = () => {
const total = scrollEl.scrollWidth;
const visible = scrollEl.clientWidth;
inner.style.width = total + "px";
bar.classList.toggle("is-hidden", total <= visible + 1);
bar.scrollLeft = scrollEl.scrollLeft;
};
// Recalcule quand le tableau change (pagination, tri, filtre, données).
const obs = new MutationObserver(() => refresh());
obs.observe(scrollEl, { childList: true, subtree: true, attributes: true });
window.addEventListener("resize", refresh);
wrapper.dataset.hscrollReady = "1";
refresh();
}
function scan() {
document.querySelectorAll(".marches_table").forEach(setup);
}
// Les tableaux apparaissent après le rendu Dash : observer le body.
const rootObs = new MutationObserver(() => scan());
rootObs.observe(document.body, { childList: true, subtree: true });
scan();
})();
```
- [ ] **Step 3 : Vérifier dans le navigateur**
App lancée, sur `/tableau` : une barre fine apparaît en haut du tableau ; la faire glisser déplace le tableau horizontalement, et inversement. Réduire la fenêtre / élargir → la barre apparaît/disparaît selon le débordement. Changer de page de pagination → la barre se recalcule. Vérifier que `/acheteur`, `/titulaire`, `/observatoire` (tableaux multiples) fonctionnent chacun indépendamment.
- [ ] **Step 4 : Commit**
```bash
git add src/assets/table_hscroll.js src/assets/css/style.css
git commit -m "feat(tableaux): barre de défilement horizontale miroir synchronisée #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4 : Test Selenium de non-régression
**But :** garantir que la barre miroir est rendue et que les en-têtes sont sticky, et que rien ne casse le chargement de `/tableau`.
**Files:**
- Create: `tests/test_tableau_hscroll.py`
**Interfaces:**
- Consumes: éléments DOM produits par Task 2 (`th.dash-header` sticky) et Task 3 (`.marches_table .dt-hscroll`).
- [ ] **Step 1 : Écrire le test**
Créer `tests/test_tableau_hscroll.py` (s'aligner sur le style des tests existants dans `tests/`, notamment l'usage de `dash_duo`/`DashComposite` et l'import de l'app) :
```python
from selenium.webdriver.common.by import By
def test_tableau_hscroll_bar_present(dash_duo, start_app):
"""La barre miroir est injectée et l'en-tête est collant sur /tableau."""
dash_duo.wait_for_element(".marches_table", timeout=20)
# Barre miroir injectée par table_hscroll.js
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
header = dash_duo.find_element(".marches_table th.dash-header")
position = header.value_of_css_property("position")
assert position == "sticky"
```
> Adapter les fixtures (`dash_duo`, `start_app`, navigation initiale vers `/tableau`) à ce qui existe déjà dans `tests/` : reprendre le mécanisme d'amorçage utilisé par les autres tests (par ex. `tests/test_main.py`) plutôt que d'en inventer un.
- [ ] **Step 2 : Lancer le test et vérifier qu'il échoue si on retire le JS** (sanity)
```bash
rtk pytest tests/test_tableau_hscroll.py -v
```
Expected: PASS avec le JS/CSS en place.
- [ ] **Step 3 : Lancer la suite Selenium impactée**
```bash
rtk pytest tests/test_main.py -v
```
Expected: pas de régression (mêmes résultats qu'avant la branche).
- [ ] **Step 4 : Commit**
```bash
git add tests/test_tableau_hscroll.py
git commit -m "test(tableaux): barre miroir et en-tête sticky sur /tableau #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review
**Couverture spec :**
- En-têtes sticky → Task 2. ✓
- Barre miroir synchronisée en haut → Task 3. ✓
- Masquage si pas de débordement → Task 3 (`is-hidden`). ✓
- Recalcul pagination/tri/filtre/resize → Task 3 (MutationObserver + resize). ✓
- Plusieurs tableaux par page → Task 3 (`querySelectorAll` + `dataset.hscrollReady`). ✓
- Contrainte overflow CSS → Task 1 (spike) + Task 2 (`overflow: visible`). ✓
- Portée 4 pages via `marches_table` → ciblage CSS/JS global. ✓
- Validation navigateur + non-régression Selenium → Task 1/2/3 (manuel) + Task 4. ✓
**Placeholders :** les renvois « adapter aux fixtures existantes » de la Task 4 pointent vers `tests/test_main.py` comme référence concrète (les fixtures Selenium du projet ne sont pas réinventées ici à dessein) ; aucun TODO/TBD ailleurs.
**Cohérence des noms :** classes `dt-hscroll` / `dt-hscroll-inner` / `is-hidden` et flag `dataset.hscrollReady` utilisés de façon identique entre CSS (Task 3 step 1) et JS (Task 3 step 2). `marches_table` et `th.dash-header` cohérents entre Task 2 et Task 4.
@@ -0,0 +1,452 @@
# Migration des emails transactionnels vers Brevo — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer le transport SMTP/Flask-Mail des emails transactionnels (vérification d'adresse, reset de mot de passe) par l'API Brevo et ses templates hébergés, sans changer l'interface publique du mailer.
**Architecture:** `src/auth/mailer.py` est réécrit pour construire un client `Brevo` (SDK v5) et envoyer via `client.transactional_emails.send_transac_email(template_id, params, sender, to)`. Les deux fonctions publiques (`send_verification_email`, `send_reset_email`) gardent leur signature, donc `src/auth/routes.py` n'est pas touché. Seul l'appel d'init dans `setup.py` change (`init_mailer()` sans `app`).
**Tech Stack:** Python, Flask, SDK `brevo-python` v5 (`5.0.0rc1`), pytest, monkeypatch.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex: `from src.utils import logger`).
- SDK épinglé strictement : `brevo-python==5.0.0rc1` (pré-release ; un specifier qui pointe exactement une rc autorise pip à l'installer sans `--pre` global).
- API v5 vérifiée par introspection :
- `from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem`
- `from brevo.core.api_error import ApiError`
- `Brevo(api_key: str, headers: dict | None = None)`
- `client.transactional_emails.send_transac_email(template_id=int, params=dict, sender=SendTransacEmailRequestSender, to=[SendTransacEmailRequestToItem], headers=dict|None)`
- `SendTransacEmailRequestSender(email=..., name=...)`, `SendTransacEmailRequestToItem(email=..., name=...)`
- Tests unitaires hermétiques : aucun appel réseau (le client est monkeypatché).
- Commits fréquents, un par tâche.
- Lancer les tests avec `uv run pytest` (pas `source .venv/bin/activate`).
- Le hook pre-commit (prettier/ruff) peut reformater des fichiers : si un commit échoue pour cause de reformatage, `git add` les fichiers modifiés et recommitter.
---
## File Structure
| Fichier | Responsabilité | Action |
| --------------------------------------- | ------------------------------------------ | ----------------------------------------- |
| `pyproject.toml` | Dépendances | Modifier (swap flask-mail → brevo-python) |
| `src/auth/mailer.py` | Envoi des emails transactionnels via Brevo | Réécrire |
| `src/auth/setup.py` | Initialisation du blueprint auth | Modifier (1 ligne) |
| `tests/auth/test_mailer.py` | Tests unitaires du mailer (mock) | Réécrire |
| `tests/auth/test_mailer_integration.py` | Test d'intégration sandbox optionnel | Créer |
| `.template.env` | Documentation des variables d'env | Modifier |
| `src/auth/templates/emails/*` | Anciens templates Jinja | Supprimer |
---
### Task 1: Basculer la dépendance vers le SDK Brevo
**Files:**
- Modify: `pyproject.toml:25`
**Interfaces:**
- Consumes: (rien)
- Produces: le paquet `brevo` importable dans l'environnement.
- [ ] **Step 1: Remplacer la dépendance dans `pyproject.toml`**
Remplacer la ligne 25 :
```toml
"flask-mail",
```
par :
```toml
"brevo-python==5.0.0rc1",
```
- [ ] **Step 2: Réinstaller les dépendances du projet**
Run: `uv pip install -e . --group=dev`
Expected: installation réussie, `brevo-python 5.0.0rc1` installé, `flask-mail` retiré.
- [ ] **Step 3: Vérifier que le SDK s'importe avec les symboles attendus**
Run:
```bash
uv run python -c "from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem; from brevo.core.api_error import ApiError; print('brevo v5 OK')"
```
Expected: affiche `brevo v5 OK` sans erreur.
- [ ] **Step 4: Commit**
```bash
git add pyproject.toml
git commit -m "build(brevo): remplacer flask-mail par brevo-python v5 (#87)"
```
---
### Task 2: Réécrire le mailer pour utiliser l'API Brevo (TDD)
**Files:**
- Test: `tests/auth/test_mailer.py` (réécriture complète)
- Modify: `src/auth/mailer.py` (réécriture complète)
- Modify: `src/auth/setup.py:39`
**Interfaces:**
- Consumes: `brevo.Brevo`, `brevo.SendTransacEmailRequestSender`, `brevo.SendTransacEmailRequestToItem`, `brevo.core.api_error.ApiError`, `src.utils.logger`.
- Produces (interface publique, inchangée pour `routes.py`) :
- `init_mailer() -> None`
- `send_verification_email(email: str, token: str) -> None`
- `send_reset_email(email: str, token: str) -> None`
- Variables d'env lues : `BREVO_API_KEY`, `BREVO_SANDBOX`, `BREVO_TEMPLATE_VERIFY_ID`, `BREVO_TEMPLATE_RESET_ID`, `MAIL_FROM`, `MAIL_FROM_NAME`, `APP_BASE_URL`.
- [ ] **Step 1: Réécrire le test du mailer (mock du client Brevo)**
Remplacer **tout** le contenu de `tests/auth/test_mailer.py` par :
```python
import pytest
from src.auth import mailer
class _FakeTransac:
def __init__(self):
self.calls = []
def send_transac_email(self, **kwargs):
self.calls.append(kwargs)
class _FakeClient:
def __init__(self):
self.transactional_emails = _FakeTransac()
@pytest.fixture
def fake_client(monkeypatch):
client = _FakeClient()
monkeypatch.setattr(mailer, "_client", client)
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
monkeypatch.setenv("BREVO_TEMPLATE_RESET_ID", "22")
monkeypatch.setenv("MAIL_FROM", "noreply@decp.info")
monkeypatch.setenv("MAIL_FROM_NAME", "decp.info")
return client
def test_send_verification_email(fake_client):
mailer.send_verification_email("a@b.c", "TOKEN123")
calls = fake_client.transactional_emails.calls
assert len(calls) == 1
call = calls[0]
assert call["template_id"] == 11
assert call["to"][0].email == "a@b.c"
assert call["sender"].email == "noreply@decp.info"
assert "/auth/verify-email?token=TOKEN123" in call["params"]["link"]
def test_send_reset_email(fake_client):
mailer.send_reset_email("a@b.c", "RESET456")
calls = fake_client.transactional_emails.calls
assert len(calls) == 1
call = calls[0]
assert call["template_id"] == 22
assert "reinitialiser-mot-de-passe?token=RESET456" in call["params"]["link"]
def test_init_mailer_builds_client(monkeypatch):
monkeypatch.setenv("BREVO_API_KEY", "test-key")
monkeypatch.delenv("BREVO_SANDBOX", raising=False)
monkeypatch.setattr(mailer, "_client", None)
mailer.init_mailer()
assert mailer._client is not None
def test_send_without_init_raises(monkeypatch):
monkeypatch.setattr(mailer, "_client", None)
monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
with pytest.raises(AssertionError):
mailer.send_verification_email("a@b.c", "TOKEN123")
```
- [ ] **Step 2: Lancer les tests pour les voir échouer**
Run: `uv run pytest tests/auth/test_mailer.py -v`
Expected: FAIL — l'ancien `mailer.py` importe `flask_mail` (désinstallé en Task 1) et n'expose pas `_client` ; erreurs d'import / d'attribut.
- [ ] **Step 3: Réécrire `src/auth/mailer.py`**
Remplacer **tout** le contenu de `src/auth/mailer.py` par :
```python
import os
from brevo import (
Brevo,
SendTransacEmailRequestSender,
SendTransacEmailRequestToItem,
)
from brevo.core.api_error import ApiError
from src.utils import logger
_client: Brevo | None = None
def init_mailer() -> None:
"""Construit le client Brevo à partir des variables d'environnement."""
global _client
api_key = os.getenv("BREVO_API_KEY", "")
sandbox = os.getenv("BREVO_SANDBOX", "").lower() == "true"
headers = {"X-Sib-Sandbox": "drop"} if sandbox else None
_client = Brevo(api_key=api_key, headers=headers)
def _base_url() -> str:
return os.getenv("APP_BASE_URL", "http://localhost:8050").rstrip("/")
def _sender() -> SendTransacEmailRequestSender:
return SendTransacEmailRequestSender(
email=os.getenv("MAIL_FROM", "noreply@decp.info"),
name=os.getenv("MAIL_FROM_NAME", "decp.info"),
)
def _template_id(env_var: str) -> int:
raw = os.getenv(env_var, "")
if not raw:
raise RuntimeError(f"{env_var} non défini (template Brevo)")
return int(raw)
def _send_template(template_id: int, recipient: str, params: dict) -> None:
assert _client is not None, "Mailer non initialisé (init_mailer() non appelé)"
try:
_client.transactional_emails.send_transac_email(
template_id=template_id,
params=params,
sender=_sender(),
to=[SendTransacEmailRequestToItem(email=recipient)],
)
except ApiError:
logger.exception(
"Échec d'envoi Brevo (template %s) à %s", template_id, recipient
)
raise
def send_verification_email(email: str, token: str) -> None:
link = f"{_base_url()}/auth/verify-email?token={token}"
_send_template(
_template_id("BREVO_TEMPLATE_VERIFY_ID"), email, {"link": link}
)
def send_reset_email(email: str, token: str) -> None:
link = f"{_base_url()}/reinitialiser-mot-de-passe?token={token}"
_send_template(
_template_id("BREVO_TEMPLATE_RESET_ID"), email, {"link": link}
)
```
- [ ] **Step 4: Mettre à jour l'appel d'init dans `setup.py`**
Dans `src/auth/setup.py`, ligne 39, remplacer :
```python
mailer.init_mailer(app)
```
par :
```python
mailer.init_mailer()
```
- [ ] **Step 5: Lancer les tests du mailer pour les voir passer**
Run: `uv run pytest tests/auth/test_mailer.py -v`
Expected: PASS (4 tests).
- [ ] **Step 6: Lancer toute la suite auth pour vérifier l'absence de régression**
Run: `uv run pytest tests/auth/ -v`
Expected: PASS (aucune dépendance résiduelle à flask-mail ; `routes.py` inchangé fonctionne).
- [ ] **Step 7: Commit**
```bash
git add src/auth/mailer.py src/auth/setup.py tests/auth/test_mailer.py
git commit -m "feat(brevo): envoyer les emails transactionnels via l'API Brevo v5 (#87)"
```
---
### Task 3: Nettoyer la configuration et les templates Jinja
**Files:**
- Modify: `.template.env`
- Delete: `src/auth/templates/emails/verify_email.html`, `verify_email.txt`, `reset_password.html`, `reset_password.txt` (tout le dossier `src/auth/templates/`)
**Interfaces:**
- Consumes: (rien — `mailer.py` n'utilise plus de templates locaux après Task 2)
- Produces: (rien)
- [ ] **Step 1: Vérifier que les variables legacy ne sont pas utilisées ailleurs**
Run: `grep -rn "SENDER_SERVER_DOMAIN\|LOGIN_EMAIL\|FROM_EMAIL\|TO_EMAIL" . --include=*.py`
Expected: aucun résultat. (Si des résultats apparaissent hors `.template.env`, NE PAS supprimer ces variables et le signaler.)
- [ ] **Step 2: Mettre à jour `.template.env`**
Dans la section SMTP de `.template.env`, supprimer les lignes :
```
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
# SMTP pour envoi d'emails (vérification email, reset mot de passe)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_TLS=True
MAIL_SUPPRESS_SEND= # laisser vide pour hériter de DEVELOPMENT ; mettre false pour forcer l'envoi en mode dev
```
et les remplacer par :
```
# Brevo — envoi des emails transactionnels (vérification email, reset mot de passe)
BREVO_API_KEY= # clé API transactionnelle Brevo
BREVO_TEMPLATE_VERIFY_ID= # ID numérique du template "vérification d'adresse"
BREVO_TEMPLATE_RESET_ID= # ID numérique du template "réinitialisation mot de passe"
BREVO_SANDBOX= # mettre "true" pour valider sans délivrer (dev / intégration)
MAIL_FROM=noreply@decp.info # expéditeur (doit être un expéditeur vérifié dans Brevo)
MAIL_FROM_NAME=decp.info # nom d'expéditeur
```
Conserver la variable `APP_BASE_URL` si elle est déjà présente ailleurs dans le fichier ; sinon l'ajouter :
```
APP_BASE_URL=http://localhost:8050 # base des liens dans les emails
```
- [ ] **Step 3: Supprimer les templates Jinja d'email devenus inutiles**
Run: `git rm -r src/auth/templates`
Expected: les 4 fichiers `emails/*.{html,txt}` sont supprimés.
- [ ] **Step 4: Vérifier qu'aucun code ne référence encore ces templates**
Run: `grep -rn "verify_email\|reset_password\|render_template\|jinja_loader\|MAIL_SUPPRESS_SEND\|flask_mail" src/ tests/ --include=*.py`
Expected: aucun résultat.
- [ ] **Step 5: Relancer la suite auth complète**
Run: `uv run pytest tests/auth/ -v`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add .template.env src/auth/templates
git commit -m "chore(brevo): retirer la config SMTP et les templates Jinja d'email (#87)"
```
---
### Task 4: Test d'intégration sandbox optionnel
**Files:**
- Create: `tests/auth/test_mailer_integration.py`
**Interfaces:**
- Consumes: interface publique `mailer.init_mailer()`, `mailer.send_verification_email(email, token)`.
- Produces: un test `@pytest.mark.integration` skippé sauf si `BREVO_API_KEY` est défini.
- [ ] **Step 1: Créer le test d'intégration**
Créer `tests/auth/test_mailer_integration.py` :
```python
import os
import pytest
from src.auth import mailer
pytestmark = pytest.mark.integration
@pytest.mark.skipif(
not os.getenv("BREVO_API_KEY"),
reason="BREVO_API_KEY absent — test d'intégration Brevo ignoré",
)
def test_send_verification_email_sandbox(monkeypatch):
# Force le mode sandbox : Brevo valide la requête sans délivrer.
monkeypatch.setenv("BREVO_SANDBOX", "true")
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
mailer.init_mailer()
# Ne doit pas lever : l'API Brevo accepte la requête en sandbox.
mailer.send_verification_email(
os.getenv("MAIL_FROM", "noreply@decp.info"), "INTEGRATION_TOKEN"
)
```
- [ ] **Step 2: Enregistrer le marker `integration` (si absent)**
Vérifier la présence du marker dans `pyproject.toml` :
Run: `grep -n "integration" pyproject.toml`
Si aucune section `[tool.pytest.ini_options]` avec `markers` ne déclare `integration`, l'ajouter. Exemple à insérer/compléter dans `pyproject.toml` :
```toml
[tool.pytest.ini_options]
markers = [
"integration: tests touchant des services externes (Brevo) ; skippés par défaut en CI",
]
```
(Si la section existe déjà, n'ajouter que la ligne `integration: ...` dans la liste `markers` existante, sans dupliquer la section.)
- [ ] **Step 3: Vérifier que le test est bien collecté puis skippé sans clé**
Run: `uv run pytest tests/auth/test_mailer_integration.py -v`
Expected: 1 test SKIPPED (motif « BREVO_API_KEY absent »), aucun appel réseau.
- [ ] **Step 4: Vérifier que la suite par défaut reste verte**
Run: `uv run pytest tests/auth/ -v`
Expected: PASS, avec le test d'intégration SKIPPED.
- [ ] **Step 5: Commit**
```bash
git add tests/auth/test_mailer_integration.py pyproject.toml
git commit -m "test(brevo): test d'intégration sandbox optionnel (#87)"
```
---
## Notes de vérification finale (manuel, hors CI)
Avant de merger, vérification manuelle réelle :
1. Renseigner `.env` avec `BREVO_API_KEY`, `BREVO_TEMPLATE_VERIFY_ID`, `BREVO_TEMPLATE_RESET_ID`, `MAIL_FROM` (expéditeur vérifié Brevo) et `BREVO_SANDBOX=true`.
2. Lancer `python run.py`, déclencher une inscription, vérifier dans les logs Brevo (tableau de bord) que la requête est reçue (sandbox = acceptée, non délivrée).
3. Passer `BREVO_SANDBOX=` (vide), refaire une inscription avec une vraie adresse, confirmer la réception et que `{{ params.link }}` est correctement substitué dans le template.
4. Confirmer la valeur du header sandbox (`X-Sib-Sandbox: drop`) si le comportement diffère de l'attendu.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,852 @@
# Connexion avec LinkedIn (OIDC) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Permettre la création de compte / connexion via LinkedIn (OIDC) en parallèle de l'auth email+mot de passe existante.
**Architecture:** Authlib (intégration Flask) gère le flux OIDC LinkedIn. Deux routes (`/auth/linkedin`, `/auth/linkedin/callback`) s'ajoutent au blueprint `auth` existant. Une fonction `resolve_oauth_user` relie l'identité LinkedIn à un compte (existant par email, ou nouveau). Le schéma SQLite gagne une table `oauth_identities` et rend `password_hash` nullable.
**Tech Stack:** Flask, Flask-Login, Authlib, SQLite, Dash (pages), pytest.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `from src.auth import db`).
- Tests : `uv run pytest` (pas de `source .venv/bin/activate`).
- Provider unique : `linkedin`. Pas d'abstraction multi-provider.
- On ne stocke que l'email (pas de nom/photo).
- Liaison automatique par email (l'email LinkedIn est vérifié).
- Libellé du bouton : exactement « Connexion avec LinkedIn ».
- Couleur du bouton : fond `rgb(10, 102, 194)`, texte blanc.
- Discovery OIDC LinkedIn : `https://www.linkedin.com/oauth/.well-known/openid-configuration`.
- Scopes : `openid profile email`.
- URL de callback construite depuis `APP_BASE_URL` : `{APP_BASE_URL}/auth/linkedin/callback`.
---
### Task 1: Schéma DB — `password_hash` nullable + table `oauth_identities` + helpers
**Files:**
- Modify: `src/auth/db.py`
- Test: `tests/auth/test_oauth_db.py` (create)
**Interfaces:**
- Consumes: helpers existants `get_conn`, `init_schema`, `get_user_by_email`, `get_user_by_id`, `_now`.
- Produces:
- `create_oauth_user(email: str) -> int` — crée un user `password_hash = NULL`, `email_verified = 1`, renvoie l'id.
- `get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None`.
- `link_oauth_identity(provider: str, subject: str, user_id: int) -> None`.
- Schéma : `users.password_hash` nullable ; table `oauth_identities(provider, subject, user_id, created_at)` PK `(provider, subject)`.
- [ ] **Step 1: Write the failing tests**
Create `tests/auth/test_oauth_db.py`:
```python
import sqlite3
import pytest
from src.auth import db
def test_oauth_identities_table_created(users_db_path):
db.init_schema()
tables = {
r[0]
for r in db.get_conn().execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
assert "oauth_identities" in tables
def test_password_hash_is_nullable_on_fresh_schema(users_db_path):
db.init_schema()
cols = {r["name"]: r for r in db.get_conn().execute("PRAGMA table_info(users)")}
assert cols["password_hash"]["notnull"] == 0
def test_create_oauth_user(users_db_path):
db.init_schema()
uid = db.create_oauth_user("oauth@example.com")
row = db.get_user_by_id(uid)
assert row["email"] == "oauth@example.com"
assert row["password_hash"] is None
assert row["email_verified"] == 1
def test_link_and_get_oauth_identity(users_db_path):
db.init_schema()
uid = db.create_oauth_user("oauth@example.com")
assert db.get_oauth_identity("linkedin", "sub-123") is None
db.link_oauth_identity("linkedin", "sub-123", uid)
row = db.get_oauth_identity("linkedin", "sub-123")
assert row["user_id"] == uid
def test_oauth_identity_pk_prevents_duplicate(users_db_path):
db.init_schema()
uid = db.create_oauth_user("oauth@example.com")
db.link_oauth_identity("linkedin", "sub-123", uid)
with pytest.raises(sqlite3.IntegrityError):
db.link_oauth_identity("linkedin", "sub-123", uid)
def test_migration_makes_legacy_password_hash_nullable(users_db_path):
# Simule un schéma hérité où password_hash est NOT NULL.
conn = db.get_conn()
conn.executescript(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
pending_email TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE email_verification_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
"""
)
now = db._now()
conn.execute(
"INSERT INTO users (id, email, password_hash, email_verified, created_at, updated_at)"
" VALUES (1, 'legacy@example.com', 'hash', 1, ?, ?)",
(now, now),
)
conn.execute(
"INSERT INTO email_verification_tokens (token_hash, user_id, expires_at, created_at)"
" VALUES ('tok', 1, ?, ?)",
(now, now),
)
db.init_schema() # déclenche _migrate
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(users)")}
assert cols["password_hash"]["notnull"] == 0
# Données préservées, pas de cascade-delete déclenchée pendant la reconstruction.
assert db.get_user_by_id(1)["email"] == "legacy@example.com"
assert conn.execute(
"SELECT COUNT(*) FROM email_verification_tokens"
).fetchone()[0] == 1
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/auth/test_oauth_db.py -v`
Expected: FAIL (`oauth_identities` absent, `create_oauth_user` non défini, `password_hash` encore `notnull=1`).
- [ ] **Step 3: Update schema and add helpers in `src/auth/db.py`**
In `USERS_SCHEMA`, change the `password_hash` line to nullable and append the new table. The `users` CREATE becomes:
```python
USERS_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
pending_email TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS oauth_identities (
provider TEXT NOT NULL,
subject TEXT NOT NULL,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (provider, subject),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
"""
```
Replace `_migrate` with a version that also drops the `NOT NULL` on legacy DBs, and add the rebuild helper:
```python
def _migrate(conn: sqlite3.Connection) -> None:
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
if "pending_email" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN pending_email TEXT")
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
if cols.get("password_hash") and cols["password_hash"]["notnull"] == 1:
_rebuild_users_password_nullable(conn)
def _rebuild_users_password_nullable(conn: sqlite3.Connection) -> None:
# SQLite ne peut pas retirer un NOT NULL via ALTER : on reconstruit la table.
# foreign_keys OFF pour éviter le cascade-delete pendant le DROP.
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.execute("BEGIN")
conn.execute(
"""
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
pending_email TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"INSERT INTO users_new (id, email, password_hash, email_verified, "
"pending_email, created_at, updated_at) "
"SELECT id, email, password_hash, email_verified, pending_email, "
"created_at, updated_at FROM users"
)
conn.execute("DROP TABLE users")
conn.execute("ALTER TABLE users_new RENAME TO users")
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)"
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
finally:
conn.execute("PRAGMA foreign_keys = ON")
```
Append the new helpers at the end of `src/auth/db.py`:
```python
def create_oauth_user(email: str) -> int:
conn = get_conn()
now = _now()
cur = conn.execute(
"INSERT INTO users (email, password_hash, email_verified, created_at, updated_at) "
"VALUES (?, NULL, 1, ?, ?)",
(email.lower(), now, now),
)
return cur.lastrowid
def get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None:
return (
get_conn()
.execute(
"SELECT * FROM oauth_identities WHERE provider = ? AND subject = ?",
(provider, subject),
)
.fetchone()
)
def link_oauth_identity(provider: str, subject: str, user_id: int) -> None:
get_conn().execute(
"INSERT INTO oauth_identities (provider, subject, user_id, created_at) "
"VALUES (?, ?, ?, ?)",
(provider, subject, user_id, _now()),
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/auth/test_oauth_db.py tests/auth/test_db.py -v`
Expected: PASS (new tests + existing db tests still green).
- [ ] **Step 5: Commit**
```bash
git add src/auth/db.py tests/auth/test_oauth_db.py
git commit -m "feat(auth): oauth_identities table + password_hash nullable
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: Résolution de compte `resolve_oauth_user`
**Files:**
- Modify: `src/auth/routes.py`
- Test: `tests/auth/test_oauth_resolve.py` (create)
**Interfaces:**
- Consumes (from Task 1): `db.get_oauth_identity`, `db.link_oauth_identity`, `db.create_oauth_user`, plus existants `db.get_user_by_email`, `db.get_user_by_id`, `db.set_email_verified`.
- Produces:
- `resolve_oauth_user(provider: str, subject: str, email: str, email_verified: bool) -> User` — renvoie l'utilisateur (existant lié, lié par email, ou nouvellement créé).
Placée dans `routes.py` (et non `db.py`) car elle construit un `User`, ce qui éviterait un import circulaire `db``models`.
- [ ] **Step 1: Write the failing tests**
Create `tests/auth/test_oauth_resolve.py`:
```python
from werkzeug.security import generate_password_hash
from src.auth import db
from src.auth.models import User
from src.auth.routes import resolve_oauth_user
def test_creates_new_user_when_unknown(users_db_path):
db.init_schema()
user = resolve_oauth_user("linkedin", "sub-1", "new@example.com", True)
assert isinstance(user, User)
row = db.get_user_by_id(int(user.get_id()))
assert row["email"] == "new@example.com"
assert row["password_hash"] is None
assert row["email_verified"] == 1
assert db.get_oauth_identity("linkedin", "sub-1")["user_id"] == row["id"]
def test_links_to_existing_email_account(users_db_path):
db.init_schema()
uid = db.create_user("alice@example.com", generate_password_hash("password12"))
db.set_email_verified(uid)
user = resolve_oauth_user("linkedin", "sub-2", "alice@example.com", True)
assert int(user.get_id()) == uid
assert db.get_oauth_identity("linkedin", "sub-2")["user_id"] == uid
# Le compte garde son mot de passe.
assert db.get_user_by_id(uid)["password_hash"] is not None
def test_links_and_verifies_unverified_existing_account(users_db_path):
db.init_schema()
uid = db.create_user("bob@example.com", generate_password_hash("password12"))
assert db.get_user_by_id(uid)["email_verified"] == 0
resolve_oauth_user("linkedin", "sub-3", "bob@example.com", True)
assert db.get_user_by_id(uid)["email_verified"] == 1
def test_returns_same_user_for_known_identity(users_db_path):
db.init_schema()
first = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
second = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
assert first.get_id() == second.get_id()
# Pas de doublon d'identité.
count = db.get_conn().execute(
"SELECT COUNT(*) FROM oauth_identities WHERE provider='linkedin' AND subject='sub-4'"
).fetchone()[0]
assert count == 1
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/auth/test_oauth_resolve.py -v`
Expected: FAIL with `ImportError: cannot import name 'resolve_oauth_user'`.
- [ ] **Step 3: Implement `resolve_oauth_user` in `src/auth/routes.py`**
Add after the imports / `_DUMMY_HASH` definition:
```python
def resolve_oauth_user(
provider: str, subject: str, email: str, email_verified: bool
) -> User:
identity = db.get_oauth_identity(provider, subject)
if identity is not None:
return User(db.get_user_by_id(identity["user_id"]))
row = db.get_user_by_email(email)
if row is not None:
db.link_oauth_identity(provider, subject, row["id"])
if email_verified and not row["email_verified"]:
db.set_email_verified(row["id"])
return User(db.get_user_by_id(row["id"]))
user_id = db.create_oauth_user(email)
db.link_oauth_identity(provider, subject, user_id)
return User(db.get_user_by_id(user_id))
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/auth/test_oauth_resolve.py -v`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/auth/routes.py tests/auth/test_oauth_resolve.py
git commit -m "feat(auth): resolve_oauth_user (liaison/création de compte OIDC)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 3: Initialisation Authlib + dépendance + variables d'env
**Files:**
- Create: `src/auth/oauth.py`
- Modify: `src/auth/setup.py`
- Modify: `pyproject.toml`
- Modify: `.template.env`
- Modify: `tests/auth/conftest.py`
**Interfaces:**
- Produces:
- `src.auth.oauth.oauth` — instance `authlib.integrations.flask_client.OAuth` (registre).
- `src.auth.oauth.init_oauth(app: Flask) -> None` — init + register du provider `linkedin`.
- `init_auth` appelle `init_oauth(app)`.
- Consumes: env `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
- [ ] **Step 1: Add the dependency**
In `pyproject.toml`, add `"authlib"` to the `dependencies` list (alphabetical-ish, near `email-validator`):
```toml
"email-validator",
"authlib",
```
Then install it:
Run: `uv pip install authlib`
Expected: installs Authlib successfully.
- [ ] **Step 2: Create `src/auth/oauth.py`**
```python
import os
from authlib.integrations.flask_client import OAuth
from flask import Flask
LINKEDIN_DISCOVERY_URL = (
"https://www.linkedin.com/oauth/.well-known/openid-configuration"
)
oauth = OAuth()
def init_oauth(app: Flask) -> None:
oauth.init_app(app)
oauth.register(
name="linkedin",
client_id=os.getenv("LINKEDIN_CLIENT_ID"),
client_secret=os.getenv("LINKEDIN_CLIENT_SECRET"),
server_metadata_url=LINKEDIN_DISCOVERY_URL,
client_kwargs={"scope": "openid profile email"},
)
```
- [ ] **Step 3: Wire it into `init_auth`**
In `src/auth/setup.py`, after `app.register_blueprint(auth_bp)` and before/after the CSRF init, add the import and call. Add near the other `from src.auth...` imports at top:
```python
from src.auth.oauth import init_oauth
```
And inside `init_auth`, after `app.register_blueprint(auth_bp)`:
```python
init_oauth(app)
```
Also warn if LinkedIn isn't configured — append to the bottom of `init_auth`, next to the existing BREVO warning:
```python
if not os.getenv("LINKEDIN_CLIENT_ID"):
logger.warning(
"LINKEDIN_CLIENT_ID non défini : la connexion LinkedIn échouera. "
"Définissez LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET dans .env."
)
```
- [ ] **Step 4: Add env template entries**
In `.template.env`, under the `# Comptes utilisateurs` section, add:
```bash
# Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal,
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
# {APP_BASE_URL}/auth/linkedin/callback
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
```
- [ ] **Step 5: Set test env so `init_auth` can register the provider**
In `tests/auth/conftest.py`, in the `app` fixture, set dummy LinkedIn credentials before `init_auth(app)`:
```python
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("LINKEDIN_CLIENT_ID", "test-client-id")
monkeypatch.setenv("LINKEDIN_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
app = Flask(__name__)
```
- [ ] **Step 6: Verify the app still boots under tests**
Run: `uv run pytest tests/auth/test_app_integration.py -v`
Expected: PASS (registering the provider is lazy — no network call at init).
- [ ] **Step 7: Commit**
```bash
git add src/auth/oauth.py src/auth/setup.py pyproject.toml .template.env tests/auth/conftest.py
git commit -m "feat(auth): init Authlib + provider LinkedIn (config env)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 4: Routes `/auth/linkedin` et `/auth/linkedin/callback`
**Files:**
- Modify: `src/auth/routes.py`
- Test: `tests/auth/test_oauth_routes.py` (create)
**Interfaces:**
- Consumes: `resolve_oauth_user` (Task 2), `oauth` (Task 3), `safe_next`, `login_user`, `session`.
- Produces: routes `GET /auth/linkedin`, `GET /auth/linkedin/callback`. Redirections d'erreur vers `/connexion?error=oauth_cancelled|oauth_failed`.
- [ ] **Step 1: Write the failing tests**
Create `tests/auth/test_oauth_routes.py`:
```python
import pytest
from src.auth import db
from src.auth import oauth as oauth_module
@pytest.fixture
def fake_userinfo(monkeypatch):
"""Patche authorize_access_token pour éviter tout appel réseau."""
state = {"userinfo": None, "raise": False}
def _authorize_access_token():
if state["raise"]:
raise RuntimeError("token exchange failed")
return {"userinfo": state["userinfo"]}
monkeypatch.setattr(
oauth_module.oauth.linkedin,
"authorize_access_token",
_authorize_access_token,
raising=False,
)
return state
def test_login_route_redirects_to_linkedin(client, monkeypatch):
from flask import redirect
monkeypatch.setattr(
oauth_module.oauth.linkedin,
"authorize_redirect",
lambda redirect_uri, **kw: redirect("https://www.linkedin.com/oauth/authorize"),
raising=False,
)
resp = client.get("/auth/linkedin")
assert resp.status_code == 302
assert "linkedin.com" in resp.headers["Location"]
def test_callback_creates_user_and_logs_in(client, fake_userinfo, users_db_path):
db.init_schema()
fake_userinfo["userinfo"] = {
"sub": "sub-xyz",
"email": "newbie@example.com",
"email_verified": True,
}
resp = client.get("/auth/linkedin/callback")
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/compte/admin")
assert db.get_user_by_email("newbie@example.com") is not None
def test_callback_without_email_fails(client, fake_userinfo, users_db_path):
db.init_schema()
fake_userinfo["userinfo"] = {"sub": "sub-noemail", "email_verified": True}
resp = client.get("/auth/linkedin/callback")
assert resp.status_code == 302
assert "error=oauth_failed" in resp.headers["Location"]
def test_callback_token_error_redirects(client, fake_userinfo, users_db_path):
db.init_schema()
fake_userinfo["raise"] = True
resp = client.get("/auth/linkedin/callback")
assert resp.status_code == 302
assert "error=oauth_failed" in resp.headers["Location"]
def test_callback_user_cancelled_redirects(client, users_db_path):
db.init_schema()
resp = client.get("/auth/linkedin/callback?error=user_cancelled_login")
assert resp.status_code == 302
assert "error=oauth_cancelled" in resp.headers["Location"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/auth/test_oauth_routes.py -v`
Expected: FAIL (routes 404 / not defined).
- [ ] **Step 3: Implement the routes in `src/auth/routes.py`**
Add `session` to the flask import line:
```python
from flask import Blueprint, redirect, request, session
```
Add the import of the oauth registry near the top imports:
```python
from src.auth.oauth import oauth
```
Append the two routes at the end of the file:
```python
@auth_bp.route("/linkedin", methods=["GET"])
def linkedin_login():
session["oauth_next"] = safe_next(
request.args.get("next"), fallback="/compte/admin"
)
redirect_uri = f"{os.getenv('APP_BASE_URL', '')}/auth/linkedin/callback"
return oauth.linkedin.authorize_redirect(redirect_uri)
@auth_bp.route("/linkedin/callback", methods=["GET"])
def linkedin_callback():
next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin")
if request.args.get("error"):
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
return _redirect_with_error("/connexion", "oauth_cancelled")
try:
token = oauth.linkedin.authorize_access_token()
except Exception:
logger.exception("Échec de l'échange de token LinkedIn")
return _redirect_with_error("/connexion", "oauth_failed")
userinfo = token.get("userinfo") or {}
subject = userinfo.get("sub")
email = (userinfo.get("email") or "").strip().lower()
if not subject or not email:
logger.error("Réponse LinkedIn sans sub/email : %s", userinfo)
return _redirect_with_error("/connexion", "oauth_failed")
user = resolve_oauth_user(
"linkedin", subject, email, bool(userinfo.get("email_verified"))
)
login_user(user, remember=True)
return redirect(next_url)
```
Add the `os` import at the top of the file if absent:
```python
import os
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/auth/test_oauth_routes.py -v`
Expected: PASS (4 tests).
- [ ] **Step 5: Run the full auth suite**
Run: `uv run pytest tests/auth -v`
Expected: PASS (all auth tests green).
- [ ] **Step 6: Commit**
```bash
git add src/auth/routes.py tests/auth/test_oauth_routes.py
git commit -m "feat(auth): routes /auth/linkedin et callback OIDC
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 5: Bouton « Connexion avec LinkedIn » sur `/connexion` et `/inscription`
**Files:**
- Modify: `src/pages/connexion.py`
- Modify: `src/pages/inscription.py`
- Test: `tests/auth/test_oauth_routes.py` (extend — render assertions)
**Interfaces:**
- Consumes: route `/auth/linkedin` (Task 4).
- Produces: lien `<a href="/auth/linkedin">` stylé, libellé « Connexion avec LinkedIn », + messages d'erreur oauth.
- [ ] **Step 1: Write the failing tests**
Append to `tests/auth/test_oauth_routes.py`:
```python
def test_connexion_layout_has_linkedin_button():
from src.pages.connexion import layout
html_str = str(layout())
assert "Connexion avec LinkedIn" in html_str
assert "/auth/linkedin" in html_str
def test_connexion_layout_shows_oauth_error():
from src.pages.connexion import layout
assert "LinkedIn" in str(layout(error="oauth_failed"))
def test_inscription_layout_has_linkedin_button():
from src.pages.inscription import layout
html_str = str(layout())
assert "Connexion avec LinkedIn" in html_str
assert "/auth/linkedin" in html_str
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/auth/test_oauth_routes.py -k layout -v`
Expected: FAIL (button/text absent).
- [ ] **Step 3: Add a shared button helper and use it**
In `src/pages/connexion.py`, add the error messages and the button. Extend `ERROR_MESSAGES`:
```python
ERROR_MESSAGES = {
"invalid_credentials": "Identifiants invalides.",
"email_not_verified": "Vérifiez d'abord votre adresse email (consultez votre boîte de réception).",
"oauth_cancelled": "Connexion LinkedIn annulée.",
"oauth_failed": "Échec de la connexion via LinkedIn. Réessayez.",
}
```
Add this helper (above `layout`) in `connexion.py`:
```python
def linkedin_button():
return html.A(
"Connexion avec LinkedIn",
href="/auth/linkedin",
className="btn w-100 mb-2",
style={"backgroundColor": "rgb(10, 102, 194)", "color": "white"},
)
```
In `connexion.py` `layout`, insert a separator + button between the `html.Form(...)` and the `html.Hr()`:
```python
html.Form(
...
),
html.Div("ou", className="text-center text-muted my-2"),
linkedin_button(),
html.Hr(),
```
In `src/pages/inscription.py`, import the helper and place it likewise. At the top, after the existing imports:
```python
from src.pages.connexion import linkedin_button
```
In `inscription.py` `layout`, insert between the `html.Form(...)` and `html.Hr()`:
```python
html.Form(
...
),
html.Div("ou", className="text-center text-muted my-2"),
linkedin_button(),
html.Hr(),
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/auth/test_oauth_routes.py -k layout -v`
Expected: PASS (3 layout tests).
- [ ] **Step 5: Manual smoke check (no network)**
Run: `uv run python -c "from src.pages.connexion import layout; from src.pages.inscription import layout as l2; assert 'Connexion avec LinkedIn' in str(layout()) and 'Connexion avec LinkedIn' in str(l2()); print('ok')"`
Expected: prints `ok`.
- [ ] **Step 6: Commit**
```bash
git add src/pages/connexion.py src/pages/inscription.py tests/auth/test_oauth_routes.py
git commit -m "feat(auth): bouton Connexion avec LinkedIn sur connexion/inscription
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 6: Vérification finale
- [ ] **Step 1: Run the whole test suite touched by this work**
Run: `uv run pytest tests/auth -v`
Expected: all green.
- [ ] **Step 2: Confirm the docs/spec prerequisites are accurate**
Re-read `docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md` § Prérequis. Ensure the redirect URIs and env var names match what was implemented (`/auth/linkedin/callback`, `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
- [ ] **Step 3: No commit needed if nothing changed.** Otherwise commit doc fixes.
---
## Manual setup required before LinkedIn login works in production
(Not code — for the project admin.)
1. LinkedIn Developer Portal → create app → add product « Sign In with LinkedIn using OpenID Connect ».
2. Authorized redirect URLs:
- `http://localhost:8050/auth/linkedin/callback`
- `https://test.decp.info/auth/linkedin/callback`
- `https://decp.info/auth/linkedin/callback`
3. Copy Client ID / Secret into each environment's `.env` (`LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
4. Ensure `APP_BASE_URL` matches the deployment origin in each `.env`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
# Accès gratuit pour tous (`TOUS_ABONNES`) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Un drapeau d'environnement `TOUS_ABONNES` qui ouvre gratuitement les fonctionnalités réservées aux abonnés à tout utilisateur connecté, affiche un bandeau d'info sur `/compte/abonnement` et y grise les boutons « S'abonner ».
**Architecture:** L'accès est déjà centralisé dans `src/pages/_compte_shell.py::current_user_has_subscription()` (pilote menu + `account_guard`). On y branche le drapeau. La page `/compte/abonnement` lit le même drapeau pour afficher un bandeau et désactiver les boutons. Le contrôle bas-niveau `db.has_active_subscription()` reste inchangé (vrai abonnement payant), donc la redirection post-login et l'anti-double-abonnement gardent leur comportement.
**Tech Stack:** Python, Dash 3.4, Dash Bootstrap Components, pytest.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `src.utils`, `src.pages._compte_shell`).
- Convention drapeau booléen : `os.getenv("NOM", "False").lower() == "true"` (cf. `DEVELOPMENT` dans `src/utils/__init__.py`).
- Drapeau lu via `from src.utils import TOUS_ABONNES` **dans le corps** des fonctions (lecture paresseuse → testable par `monkeypatch.setattr`).
- Quand `TOUS_ABONNES` est absent / `false`, comportement actuel strictement inchangé.
- Texte exact du bandeau (verbatim) : « Les fonctionnalités normalement accessibles contre un abonnement de 20 € HT par mois sont accessibles à tous et toutes en attendant la validation de mon dossier pour recevoir des paiements. »
- Tests lancés avec `uv run pytest`.
- Messages de commit en français, terminés par la ligne `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
---
### Task 1: Déblocage de l'accès via `TOUS_ABONNES`
**Files:**
- Modify: `src/utils/__init__.py` (ajouter la constante près de `DEVELOPMENT`, ligne ~33)
- Modify: `src/pages/_compte_shell.py:41-46` (`current_user_has_subscription`)
- Modify: `.template.env` (documenter la variable)
- Test: `tests/test_compte_shell.py`
**Interfaces:**
- Consumes: rien (première tâche).
- Produces:
- `src.utils.TOUS_ABONNES: bool` — constante module, défaut `False`.
- `src.pages._compte_shell.current_user_has_subscription() -> bool` — renvoie `True` si l'utilisateur est authentifié **et** `TOUS_ABONNES`, sinon le comportement DB existant ; toujours `False` si non authentifié.
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter à la fin de `tests/test_compte_shell.py` :
```python
from unittest.mock import patch
def _fake_user(authenticated: bool):
user = type("U", (), {})()
user.is_authenticated = authenticated
user.id = 1
return user
def test_has_subscription_true_for_authenticated_when_tous_abonnes(monkeypatch):
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
with patch("src.pages._compte_shell.current_user", _fake_user(True)):
assert shell.current_user_has_subscription() is True
def test_has_subscription_false_for_anonymous_even_with_tous_abonnes(monkeypatch):
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
with patch("src.pages._compte_shell.current_user", _fake_user(False)):
assert shell.current_user_has_subscription() is False
def test_has_subscription_uses_db_when_flag_off(monkeypatch):
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
with patch("src.pages._compte_shell.current_user", _fake_user(True)), patch(
"src.subscriptions.db.has_active_subscription", return_value=False
) as mocked:
assert shell.current_user_has_subscription() is False
mocked.assert_called_once_with(1)
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/test_compte_shell.py -v`
Expected: les 3 nouveaux tests ÉCHOUENT (`test_has_subscription_true_...` échoue car `current_user_has_subscription` ne consulte pas encore `TOUS_ABONNES` ; `monkeypatch.setattr("src.utils.TOUS_ABONNES", True)` réussit seulement si l'attribut existe — sinon `AttributeError`, ce qui confirme aussi qu'il faut créer la constante).
- [ ] **Step 3: Ajouter la constante dans `src/utils/__init__.py`**
Juste après la ligne `DEVELOPMENT = os.getenv("DEVELOPMENT", "False").lower() == "true"` (ligne ~33) :
```python
# Accès gratuit temporaire à toutes les fonctionnalités d'abonné, le temps que
# la plateforme de paiement Frisbii valide la réception de paiements.
TOUS_ABONNES = os.getenv("TOUS_ABONNES", "False").lower() == "true"
```
- [ ] **Step 4: Brancher le drapeau dans `current_user_has_subscription`**
Remplacer la fonction (`src/pages/_compte_shell.py:41-46`) par :
```python
def current_user_has_subscription() -> bool:
from src.subscriptions import db
from src.utils import TOUS_ABONNES
if not current_user.is_authenticated:
return False
if TOUS_ABONNES:
return True
return db.has_active_subscription(current_user.id)
```
- [ ] **Step 5: Documenter la variable dans `.template.env`**
Ajouter une ligne (par ex. à la suite du bloc abonnement/Frisbii) :
```
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte
```
- [ ] **Step 6: Lancer les tests pour vérifier qu'ils passent**
Run: `uv run pytest tests/test_compte_shell.py -v`
Expected: les 8 tests PASSENT (5 existants + 3 nouveaux).
- [ ] **Step 7: Commit**
```bash
git add src/utils/__init__.py src/pages/_compte_shell.py .template.env tests/test_compte_shell.py
git commit -m "$(cat <<'EOF'
Ajoute le drapeau TOUS_ABONNES pour l'accès gratuit
Ouvre les fonctionnalités d'abonné à tout utilisateur connecté tant que
Frisbii n'a pas validé la réception de paiements.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Bandeau d'info et boutons « S'abonner » désactivés sur `/compte/abonnement`
**Files:**
- Modify: `src/pages/compte_abonnement.py` (`_plan_card` ~25-57, ajout `_tous_abonnes_banner`, `layout` ~198-221)
- Test: `tests/subscriptions/test_compte_abonnement.py`
**Interfaces:**
- Consumes: `src.utils.TOUS_ABONNES` (Task 1).
- Produces:
- `compte_abonnement._tous_abonnes_banner()` — renvoie un `dbc.Alert` (color `info`) contenant le texte verbatim si `TOUS_ABONNES`, sinon `None`.
- `_plan_card` rend un bouton désactivé (`className="btn btn-secondary disabled"`, `disabled=True`) quand `TOUS_ABONNES`, sinon le bouton primaire actuel.
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter à `tests/subscriptions/test_compte_abonnement.py` :
```python
def test_subscribe_buttons_disabled_when_tous_abonnes(monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
from src.pages import compte_abonnement
text = str(compte_abonnement._plan_cards(trial_for=lambda key: 2))
assert "btn-secondary disabled" in text
assert "btn-primary" not in text
def test_subscribe_buttons_active_when_flag_off(monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
from src.pages import compte_abonnement
text = str(compte_abonnement._plan_cards(trial_for=lambda key: 2))
assert "btn-primary" in text
def test_banner_present_when_tous_abonnes(monkeypatch):
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
from src.pages import compte_abonnement
text = str(compte_abonnement._tous_abonnes_banner())
assert "accessibles à tous et toutes" in text
def test_banner_absent_when_flag_off(monkeypatch):
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
from src.pages import compte_abonnement
assert compte_abonnement._tous_abonnes_banner() is None
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
Expected: les 4 nouveaux tests ÉCHOUENT (`_tous_abonnes_banner` n'existe pas → `AttributeError` ; le bouton reste `btn-primary`).
- [ ] **Step 3: Désactiver le bouton dans `_plan_card`**
Dans `src/pages/compte_abonnement.py::_plan_card`, remplacer le bloc `html.Button("S'abonner", ...)` (lignes ~48-50) par une lecture du drapeau. Ajouter en tête de fonction puis le bouton conditionnel :
```python
def _plan_card(meta: dict, trial: int | None, trial_used: bool):
from src.utils import TOUS_ABONNES
if trial_used:
```
et remplacer le `html.Button(...)` par :
```python
html.Button(
"S'abonner",
type="submit",
className=(
"btn btn-secondary disabled"
if TOUS_ABONNES
else "btn btn-primary"
),
disabled=TOUS_ABONNES,
),
```
- [ ] **Step 4: Ajouter le helper `_tous_abonnes_banner`**
Dans `src/pages/compte_abonnement.py`, ajouter (par ex. juste avant `def layout`) :
```python
def _tous_abonnes_banner():
from src.utils import TOUS_ABONNES
if not TOUS_ABONNES:
return None
return dbc.Alert(
"Les fonctionnalités normalement accessibles contre un abonnement de "
"20 € HT par mois sont accessibles à tous et toutes en attendant la "
"validation de mon dossier pour recevoir des paiements.",
color="info",
)
```
- [ ] **Step 5: Insérer le bandeau dans `layout`**
Dans `src/pages/compte_abonnement.py::layout`, remplacer la construction de `body` (ligne ~214) par une version qui place le bandeau en tête quand présent :
```python
body = [html.H2("Abonnement", className="mb-4")]
banner = _tous_abonnes_banner()
if banner is not None:
body.append(banner)
body.extend(_feedback(query))
if has_access and row is not None:
body.append(_active_view(row))
else:
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
```
- [ ] **Step 6: Lancer les tests pour vérifier qu'ils passent**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
Expected: tous les tests PASSENT (4 existants + 4 nouveaux).
- [ ] **Step 7: Lancer la suite des tests d'abonnement et du shell**
Run: `uv run pytest tests/subscriptions/ tests/test_compte_shell.py -v`
Expected: PASS (aucune régression).
- [ ] **Step 8: Commit**
```bash
git add src/pages/compte_abonnement.py tests/subscriptions/test_compte_abonnement.py
git commit -m "$(cat <<'EOF'
Bandeau TOUS_ABONNES et boutons S'abonner désactivés
Affiche un bandeau d'info sur /compte/abonnement et grise les boutons
S'abonner quand l'accès gratuit pour tous est activé.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)"
```
---
## Self-Review
**Spec coverage :**
- Drapeau d'env `TOUS_ABONNES` → Task 1, Steps 3 & 5.
- Accès aux fonctionnalités d'abonné pour tout connecté → Task 1, Step 4 (`current_user_has_subscription`, qui pilote menu + `account_guard`).
- Bandeau sur `/compte/abonnement` → Task 2, Steps 4-5.
- Boutons « S'abonner » désactivés et gris → Task 2, Step 3.
- `db.has_active_subscription` inchangé / redirection post-login inchangée → aucun changement (couvert par l'architecture ; non gating ailleurs vérifié dans la spec).
- Comportement inchangé si drapeau off → tests `_flag_off` dans les deux tâches.
**Placeholder scan :** aucun TODO/TBD ; tout le code et toutes les commandes sont explicites.
**Type consistency :** `current_user_has_subscription() -> bool`, `_tous_abonnes_banner() -> dbc.Alert | None`, `TOUS_ABONNES: bool` utilisés de façon cohérente entre tâches et tests.
@@ -0,0 +1,990 @@
# Sauvegarde des vues du Tableau — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Permettre aux abonné·es d'enregistrer des vues nommées (filtres + tris + colonnes) sur `/tableau`, de les appliquer en un clic, et de les gérer sur `/compte/vues`.
**Architecture:** Une vue = un nom + la query string `filtres`/`tris`/`colonnes` que `/tableau` produit et restaure déjà. Stockage serveur dans `users.sqlite` (nouveau module `src/saved_views/`). La logique métier (construction de query, validation, builders d'UI) vit dans des fonctions pures testables ; les callbacks Dash ne font que les câbler. Réutilisation du `restore_view_from_url` existant pour appliquer une vue (navigation vers `/tableau?<query>`).
**Tech Stack:** Python, Dash 3.4, Dash Bootstrap Components, SQLite (`users.sqlite` via `src.auth.db.get_conn()`), Flask-Login (`current_user`), pytest.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `src.saved_views.db`), jamais `saved_views.db`.
- Accès DB via `src.auth.db.get_conn()` (connexion thread-local sur `users.sqlite`).
- Contrôle d'abonnement via `src.pages._compte_shell.current_user_has_subscription()` (respecte `TOUS_ABONNES`). Ne jamais appeler `db.has_active_subscription()` directement pour le gating UI.
- Toute opération DB inclut `user_id` dans le `WHERE` (isolation entre comptes).
- Périmètre strict : `/tableau` uniquement. La colonne `table_name` vaut toujours `'tableau'`.
- Tests lancés avec `uv run pytest`.
- Format/lint : `prettier` (markdown) et `ruff` tournent en pre-commit ; committer du code déjà formaté.
---
## Structure des fichiers
| Fichier | Responsabilité |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/saved_views/__init__.py` (créer) | Marqueur de package. |
| `src/saved_views/db.py` (créer) | Schéma `saved_views` + CRUD (`init_schema`, `list_views`, `upsert`, `rename`, `delete`, `get`). |
| `src/saved_views/ui.py` (créer) | Fonctions **pures** d'UI/validation : `bar_style`, `clean_view_name`, `prepare_view_to_save`, `saved_views_items`, `views_table`. Aucune dépendance à un app Dash (pas de `register_page`/`callback`). |
| `src/utils/table.py` (modifier) | Ajouter `build_view_query()` ; refactorer `sync_url_and_reset_button` (dans `tableau.py`) pour l'utiliser. |
| `src/app.py` (modifier) | Appeler `saved_views.db.init_schema()` au démarrage. |
| `src/pages/tableau.py` (modifier) | Barre « vues » dans `table-menu` + 3 callbacks (visibilité, sauvegarde, remplissage du menu) qui câblent les helpers. |
| `src/pages/_compte_shell.py` (modifier) | Ajouter la section `vues` à `SECTIONS`. |
| `src/pages/compte_vues.py` (créer) | Page `/compte/vues` (liste/renommer/supprimer), gabarit `compte_admin.py`. |
| `tests/saved_views/__init__.py` (créer) | Package de tests. |
| `tests/saved_views/conftest.py` (créer) | Fixture `users_db_path` (copie de `tests/subscriptions/conftest.py`). |
| `tests/saved_views/test_db.py` (créer) | Tests unitaires du CRUD. |
| `tests/saved_views/test_ui.py` (créer) | Tests des fonctions pures d'UI/validation. |
| `tests/saved_views/test_build_view_query.py` (créer) | Tests de `build_view_query`. |
---
## Task 1: Module DB `src/saved_views/db.py`
**Files:**
- Create: `src/saved_views/__init__.py`
- Create: `src/saved_views/db.py`
- Create: `tests/saved_views/__init__.py`
- Create: `tests/saved_views/conftest.py`
- Create: `tests/saved_views/test_db.py`
- Modify: `src/app.py` (après `init_subscriptions(app.server)`)
**Interfaces:**
- Consumes: `src.auth.db.get_conn()`, `src.auth.db.init_schema()`, `src.auth.db.create_user(email, password_hash) -> int`, `src.auth.db.delete_user(user_id)`.
- Produces:
- `SCHEMA: str`
- `init_schema() -> None`
- `list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]`
- `upsert(user_id: int, table_name: str, name: str, query: str) -> None`
- `rename(view_id: int, user_id: int, new_name: str) -> None`
- `delete(view_id: int, user_id: int) -> None`
- `get(view_id: int, user_id: int) -> sqlite3.Row | None`
- [ ] **Step 1: Créer le package et le fichier de tests vide**
Créer `src/saved_views/__init__.py` (vide) et `tests/saved_views/__init__.py` (vide).
Créer `tests/saved_views/conftest.py` (copie de la fixture de `tests/subscriptions/conftest.py`) :
```python
import pytest
@pytest.fixture
def users_db_path(monkeypatch, tmp_path):
from src.auth.db import reset_conn_for_tests
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
reset_conn_for_tests()
yield db_path
reset_conn_for_tests()
```
- [ ] **Step 2: Écrire les tests qui échouent**
Créer `tests/saved_views/test_db.py` :
```python
from src.auth import db as auth_db
from src.saved_views import db
def _make_user(email="u@ex.fr"):
auth_db.init_schema()
return auth_db.create_user(email, "hash")
def test_init_schema_creates_table(users_db_path):
db.init_schema()
conn = auth_db.get_conn()
tables = {
row[0]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
assert "saved_views" in tables
def test_upsert_creates_and_lists(users_db_path):
db.init_schema()
uid = _make_user()
db.upsert(uid, "tableau", "Ma vue", "filtres=foo")
views = db.list_views(uid, "tableau")
assert len(views) == 1
assert views[0]["name"] == "Ma vue"
assert views[0]["query"] == "filtres=foo"
def test_upsert_same_name_overwrites(users_db_path):
db.init_schema()
uid = _make_user()
db.upsert(uid, "tableau", "Ma vue", "filtres=foo")
db.upsert(uid, "tableau", "Ma vue", "filtres=bar")
views = db.list_views(uid, "tableau")
assert len(views) == 1
assert views[0]["query"] == "filtres=bar"
def test_list_views_is_isolated_per_user(users_db_path):
db.init_schema()
uid1 = _make_user("a@ex.fr")
uid2 = _make_user("b@ex.fr")
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
assert db.list_views(uid2, "tableau") == []
def test_rename_only_affects_owner(users_db_path):
db.init_schema()
uid1 = _make_user("a@ex.fr")
uid2 = _make_user("b@ex.fr")
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
view_id = db.list_views(uid1, "tableau")[0]["id"]
db.rename(view_id, uid2, "Pirate") # mauvais propriétaire → no-op
assert db.get(view_id, uid1)["name"] == "Vue A"
db.rename(view_id, uid1, "Vue B")
assert db.get(view_id, uid1)["name"] == "Vue B"
def test_delete_only_affects_owner(users_db_path):
db.init_schema()
uid1 = _make_user("a@ex.fr")
uid2 = _make_user("b@ex.fr")
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
view_id = db.list_views(uid1, "tableau")[0]["id"]
db.delete(view_id, uid2) # mauvais propriétaire → no-op
assert db.get(view_id, uid1) is not None
db.delete(view_id, uid1)
assert db.get(view_id, uid1) is None
def test_views_deleted_on_user_cascade(users_db_path):
db.init_schema()
uid = _make_user()
db.upsert(uid, "tableau", "Vue A", "filtres=a")
auth_db.delete_user(uid)
assert db.list_views(uid, "tableau") == []
```
- [ ] **Step 3: Lancer les tests, vérifier l'échec**
Run: `uv run pytest tests/saved_views/test_db.py -v`
Expected: FAIL (ModuleNotFoundError: `src.saved_views.db`).
- [ ] **Step 4: Écrire `src/saved_views/db.py`**
```python
import sqlite3
from datetime import datetime, timezone
from src.auth.db import get_conn
SCHEMA = """
CREATE TABLE IF NOT EXISTS saved_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
table_name TEXT NOT NULL DEFAULT 'tableau',
name TEXT NOT NULL,
query TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (user_id, table_name, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_views_user
ON saved_views(user_id, table_name);
"""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_schema() -> None:
get_conn().executescript(SCHEMA)
def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]:
return (
get_conn()
.execute(
"SELECT * FROM saved_views WHERE user_id = ? AND table_name = ? "
"ORDER BY name COLLATE NOCASE",
(user_id, table_name),
)
.fetchall()
)
def get(view_id: int, user_id: int) -> sqlite3.Row | None:
return (
get_conn()
.execute(
"SELECT * FROM saved_views WHERE id = ? AND user_id = ?",
(view_id, user_id),
)
.fetchone()
)
def upsert(user_id: int, table_name: str, name: str, query: str) -> None:
now = _now()
get_conn().execute(
"INSERT INTO saved_views "
"(user_id, table_name, name, query, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(user_id, table_name, name) DO UPDATE SET "
"query = excluded.query, updated_at = excluded.updated_at",
(user_id, table_name, name, query, now, now),
)
def rename(view_id: int, user_id: int, new_name: str) -> None:
get_conn().execute(
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?",
(new_name, _now(), view_id, user_id),
)
def delete(view_id: int, user_id: int) -> None:
get_conn().execute(
"DELETE FROM saved_views WHERE id = ? AND user_id = ?",
(view_id, user_id),
)
```
- [ ] **Step 5: Lancer les tests, vérifier le succès**
Run: `uv run pytest tests/saved_views/test_db.py -v`
Expected: PASS (7 tests).
- [ ] **Step 6: Câbler `init_schema()` au démarrage dans `src/app.py`**
Juste après la ligne `init_subscriptions(app.server)` (≈ ligne 106), ajouter :
```python
from src.saved_views import db as saved_views_db # noqa: E402
saved_views_db.init_schema()
```
- [ ] **Step 7: Vérifier l'import de l'app**
Run: `uv run python -c "import src.app"`
Expected: aucune erreur (sortie vide ou logs normaux).
- [ ] **Step 8: Commit**
```bash
git add src/saved_views/__init__.py src/saved_views/db.py src/app.py \
tests/saved_views/__init__.py tests/saved_views/conftest.py \
tests/saved_views/test_db.py
git commit -m "feat: table saved_views et CRUD #95"
```
---
## Task 2: Helper `build_view_query` dans `src/utils/table.py`
Extrait la construction de query string aujourd'hui inline dans `sync_url_and_reset_button` (`tableau.py`), pour la réutiliser à la sauvegarde.
**Files:**
- Modify: `src/utils/table.py` (ajouter la fonction + imports si absents)
- Modify: `src/pages/tableau.py` (`sync_url_and_reset_button` utilise le helper)
- Create: `tests/saved_views/test_build_view_query.py`
**Interfaces:**
- Consumes: `src.utils.table.invert_columns(columns) -> list[str]`.
- Produces: `build_view_query(filter_query: str | None, sort_by: list | None, hidden_columns: list | None) -> str` — renvoie une query string (`urlencode`) avec les clés `filtres`, `tris` (JSON), `colonnes` (CSV des colonnes **visibles**). Renvoie `""` si aucun paramètre.
- [ ] **Step 1: Écrire les tests qui échouent**
Créer `tests/saved_views/test_build_view_query.py` :
```python
import urllib.parse
from src.utils.table import build_view_query
def test_empty_inputs_give_empty_string():
assert build_view_query(None, None, None) == ""
assert build_view_query("", [], []) == ""
def test_filter_only():
q = build_view_query("{objet} icontains route", None, None)
params = urllib.parse.parse_qs(q)
assert params["filtres"] == ["{objet} icontains route"]
assert "tris" not in params
assert "colonnes" not in params
def test_sort_is_json_encoded():
sort_by = [{"column_id": "montant", "direction": "desc"}]
q = build_view_query(None, sort_by, None)
params = urllib.parse.parse_qs(q)
import json
assert json.loads(params["tris"][0]) == sort_by
def test_hidden_columns_become_visible_csv():
# build_view_query reçoit les colonnes MASQUÉES et stocke les VISIBLES
q = build_view_query(None, None, ["objet"])
params = urllib.parse.parse_qs(q)
visible = params["colonnes"][0].split(",")
assert "objet" not in visible
assert len(visible) > 0
```
- [ ] **Step 2: Lancer les tests, vérifier l'échec**
Run: `uv run pytest tests/saved_views/test_build_view_query.py -v`
Expected: FAIL (ImportError: `build_view_query`).
- [ ] **Step 3: Ajouter `build_view_query` à `src/utils/table.py`**
Vérifier que le haut du fichier contient `import json` et `import urllib.parse` ; les ajouter sinon. Puis ajouter, à la fin du fichier (après `invert_columns`) :
```python
def build_view_query(filter_query, sort_by, hidden_columns) -> str:
"""
Construit la query string d'une vue Tableau (filtres + tris + colonnes),
identique à celle produite par le bouton « Partager la vue ».
hidden_columns : colonnes masquées ; on stocke les colonnes visibles.
"""
params = {}
if filter_query:
params["filtres"] = filter_query
if sort_by:
params["tris"] = json.dumps(sort_by)
if hidden_columns:
params["colonnes"] = ",".join(invert_columns(hidden_columns))
return urllib.parse.urlencode(params)
```
- [ ] **Step 4: Lancer les tests, vérifier le succès**
Run: `uv run pytest tests/saved_views/test_build_view_query.py -v`
Expected: PASS (4 tests).
- [ ] **Step 5: Refactorer `sync_url_and_reset_button` dans `src/pages/tableau.py`**
Dans `src/pages/tableau.py`, importer le helper en haut (ajouter `build_view_query` à l'import existant depuis `src.utils.table`).
Remplacer le corps de construction de l'URL (lignes ≈ 427-440, du `params = {}` jusqu'au `full_url = ...`) par :
```python
query_string = build_view_query(filter_query, sort_by, hidden_columns)
full_url = f"{base_url}?{query_string}" if query_string else base_url
```
Supprimer l'import devenu inutile s'il n'est plus utilisé ailleurs (vérifier `json`/`urllib` restent utilisés par d'autres callbacks — ils le sont, ne pas les retirer).
- [ ] **Step 6: Vérifier la non-régression du partage**
Run: `uv run python -c "import src.app"`
Expected: aucune erreur.
Run: `uv run pytest tests/saved_views -v`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/utils/table.py src/pages/tableau.py tests/saved_views/test_build_view_query.py
git commit -m "refactor: extrait build_view_query et le réutilise dans le partage #95"
```
---
## Task 3: Fonctions pures d'UI/validation `src/saved_views/ui.py`
**Files:**
- Create: `src/saved_views/ui.py`
- Create: `tests/saved_views/test_ui.py`
**Interfaces:**
- Consumes: `dash_bootstrap_components as dbc`, `dash.html`.
- Produces:
- `bar_style(has_subscription: bool) -> dict``{}` si abonné, `{"display": "none"}` sinon.
- `clean_view_name(name: str | None) -> str``name.strip()`, `""` si vide/None.
- `prepare_view_to_save(has_subscription: bool, name: str | None) -> tuple[str | None, str | None]` — renvoie `(clean_name, None)` si OK ; `(None, message)` si refus (non-abonné ou nom vide).
- `saved_views_items(views) -> list` — liste de `dbc.DropdownMenuItem` liens `href="/tableau?<query>"` (un par vue).
- `views_table(views) -> html.Div` — bloc de gestion pour `/compte/vues` (un `html.Div` par vue avec id pattern-matching pour Ouvrir/Renommer/Supprimer), ou message d'état vide.
- [ ] **Step 1: Écrire les tests qui échouent**
Créer `tests/saved_views/test_ui.py` :
```python
from src.saved_views import ui
class _Row(dict):
"""Imite un sqlite3.Row : accès par clé."""
def _view(view_id, name, query):
return _Row(id=view_id, name=name, query=query)
def test_bar_style_hidden_for_non_subscriber():
assert ui.bar_style(False) == {"display": "none"}
assert ui.bar_style(True) == {}
def test_clean_view_name_strips_and_empties():
assert ui.clean_view_name(" Ma vue ") == "Ma vue"
assert ui.clean_view_name(" ") == ""
assert ui.clean_view_name(None) == ""
def test_prepare_refuses_non_subscriber():
name, err = ui.prepare_view_to_save(False, "Ma vue")
assert name is None
assert err
def test_prepare_refuses_empty_name():
name, err = ui.prepare_view_to_save(True, " ")
assert name is None
assert err
def test_prepare_accepts_valid():
name, err = ui.prepare_view_to_save(True, " Ma vue ")
assert name == "Ma vue"
assert err is None
def test_saved_views_items_build_links():
items = ui.saved_views_items(
[_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")]
)
assert len(items) == 2
assert items[0].href == "/tableau?filtres=a"
assert items[0].children == "Vue A"
def test_views_table_empty_state():
out = ui.views_table([])
# un Div non vide (message d'état) sans item de suppression
assert out is not None
def test_views_table_lists_views():
out = ui.views_table([_view(1, "Vue A", "filtres=a")])
text = str(out)
assert "Vue A" in text
```
- [ ] **Step 2: Lancer les tests, vérifier l'échec**
Run: `uv run pytest tests/saved_views/test_ui.py -v`
Expected: FAIL (ModuleNotFoundError: `src.saved_views.ui`).
- [ ] **Step 3: Écrire `src/saved_views/ui.py`**
```python
import dash_bootstrap_components as dbc
from dash import html
def bar_style(has_subscription: bool) -> dict:
return {} if has_subscription else {"display": "none"}
def clean_view_name(name: str | None) -> str:
return (name or "").strip()
def prepare_view_to_save(
has_subscription: bool, name: str | None
) -> tuple[str | None, str | None]:
if not has_subscription:
return None, "Réservé aux abonné·es."
clean = clean_view_name(name)
if not clean:
return None, "Veuillez saisir un nom pour la vue."
return clean, None
def saved_views_items(views) -> list:
return [
dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}")
for view in views
]
def _view_row(view) -> html.Div:
view_id = view["id"]
return html.Div(
className="saved-view-row d-flex align-items-center gap-2 mb-2",
children=[
html.Span(view["name"], className="flex-grow-1"),
dbc.Button(
"Ouvrir",
href=f"/tableau?{view['query']}",
color="link",
size="sm",
),
dbc.Button(
"Renommer",
id={"type": "vue-rename-open", "index": view_id},
color="secondary",
outline=True,
size="sm",
),
dbc.Button(
"Supprimer",
id={"type": "vue-delete", "index": view_id},
color="danger",
outline=True,
size="sm",
),
],
)
def views_table(views) -> html.Div:
if not views:
return html.Div(
html.P(
"Vous n'avez pas encore de vue enregistrée. "
"Créez-en une depuis le Tableau, bouton « Sauvegarder la vue »."
)
)
return html.Div([_view_row(v) for v in views])
```
- [ ] **Step 4: Lancer les tests, vérifier le succès**
Run: `uv run pytest tests/saved_views/test_ui.py -v`
Expected: PASS (8 tests).
- [ ] **Step 5: Commit**
```bash
git add src/saved_views/ui.py tests/saved_views/test_ui.py
git commit -m "feat: helpers UI/validation des vues sauvegardées #95"
```
---
## Task 4: Intégration sur `/tableau`
Câble les helpers : barre « vues » (masquée par défaut), callback de visibilité (gating), callback de sauvegarde (avec modale), callback de remplissage du menu déroulant.
**Files:**
- Modify: `src/pages/tableau.py`
**Interfaces:**
- Consumes: `src.saved_views.db` (`upsert`, `list_views`), `src.saved_views.ui` (`bar_style`, `prepare_view_to_save`, `saved_views_items`), `src.utils.table.build_view_query`, `src.pages._compte_shell.current_user_has_subscription`, `flask_login.current_user`.
- Produces: composants d'id `saved-views-bar`, `btn-save-view`, `save-view-modal`, `save-view-name`, `btn-save-view-confirm`, `save-view-feedback`, `saved-views-menu`, `saved-views-refresh` (Store).
- [ ] **Step 1: Ajouter les imports en haut de `src/pages/tableau.py`**
```python
from flask_login import current_user
from src.pages._compte_shell import current_user_has_subscription
from src.saved_views import db as saved_views_db
from src.saved_views import ui as saved_views_ui
```
(`build_view_query` a déjà été ajouté à l'import `src.utils.table` en Task 2.)
- [ ] **Step 2: Ajouter la barre « vues » dans la `table-menu`**
Dans le `children` de la `html.Div(className="table-menu", ...)` (≈ lignes 154-265), juste après le bouton « Choisir les colonnes » (id `tableau_columns_open`), insérer :
```python
html.Div(
id="saved-views-bar",
style={"display": "none"},
className="d-inline-flex align-items-center gap-2",
children=[
dbc.Button(
"Sauvegarder la vue",
id="btn-save-view",
title="Enregistrer les filtres, tris et colonnes actuels sous un nom",
),
dbc.DropdownMenu(
id="saved-views-menu",
label="Mes vues",
children=[],
className="d-inline-block",
),
],
),
dcc.Store(id="saved-views-refresh"),
dbc.Modal(
id="save-view-modal",
is_open=False,
children=[
dbc.ModalHeader(dbc.ModalTitle("Sauvegarder la vue")),
dbc.ModalBody(
[
dbc.Label("Nom de la vue"),
dcc.Input(
id="save-view-name",
type="text",
className="form-control",
),
html.Div(id="save-view-feedback", className="mt-2"),
]
),
dbc.ModalFooter(
dbc.Button(
"Enregistrer",
id="btn-save-view-confirm",
color="primary",
)
),
],
),
```
- [ ] **Step 3: Ajouter le callback de visibilité (gating)**
À la fin de `src/pages/tableau.py`, ajouter :
```python
@callback(
Output("saved-views-bar", "style"),
Input("tableau_url", "pathname"),
)
def toggle_saved_views_bar(_pathname):
return saved_views_ui.bar_style(current_user_has_subscription())
```
- [ ] **Step 4: Ajouter le callback d'ouverture de la modale**
```python
@callback(
Output("save-view-modal", "is_open"),
Input("btn-save-view", "n_clicks"),
Input("btn-save-view-confirm", "n_clicks"),
State("save-view-modal", "is_open"),
prevent_initial_call=True,
)
def toggle_save_view_modal(_open, _confirm, is_open):
return not is_open
```
- [ ] **Step 5: Ajouter le callback de sauvegarde (avec contrôle serveur)**
```python
@callback(
Output("save-view-feedback", "children"),
Output("saved-views-refresh", "data"),
Input("btn-save-view-confirm", "n_clicks"),
State("save-view-name", "value"),
State("tableau_datatable", "filter_query"),
State("tableau_datatable", "sort_by"),
State("tableau_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def save_view(_n, name, filter_query, sort_by, hidden_columns):
has_sub = current_user_has_subscription()
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
if error:
return html.Span(error, style={"color": "red"}), no_update
query = build_view_query(filter_query, sort_by, hidden_columns)
saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
return (
html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}),
clean_name,
)
```
- [ ] **Step 6: Ajouter le callback de remplissage du menu déroulant**
```python
@callback(
Output("saved-views-menu", "children"),
Input("tableau_url", "pathname"),
Input("saved-views-refresh", "data"),
)
def populate_saved_views_menu(_pathname, _refresh):
if not current_user_has_subscription():
return []
views = saved_views_db.list_views(current_user.id, "tableau")
return saved_views_ui.saved_views_items(views)
```
- [ ] **Step 7: Vérifier l'import et la non-régression**
Run: `uv run python -c "import src.app"`
Expected: aucune erreur (pas d'erreur de callback dupliqué/composant manquant).
Run: `uv run pytest tests/saved_views -v`
Expected: PASS.
- [ ] **Step 8: Vérification manuelle (smoke test)**
Démarrer `uv run python run.py`, se connecter avec un compte abonné (ou `TOUS_ABONNES=true` dans `.env`), aller sur `/tableau` :
- la barre « Sauvegarder la vue » + « Mes vues » est visible ;
- appliquer un filtre, cliquer « Sauvegarder la vue », saisir un nom, Enregistrer → confirmation verte ;
- ouvrir « Mes vues » → la vue apparaît ; cliquer dessus applique le filtre.
- Se déconnecter → la barre disparaît.
- [ ] **Step 9: Commit**
```bash
git add src/pages/tableau.py
git commit -m "feat: UI sauvegarde et application des vues sur /tableau #95"
```
---
## Task 5: Page de gestion `/compte/vues`
**Files:**
- Modify: `src/pages/_compte_shell.py` (ajout section `vues`)
- Create: `src/pages/compte_vues.py`
- Create: `tests/saved_views/test_compte_vues.py`
**Interfaces:**
- Consumes: `src.pages._compte_shell` (`account_guard`, `account_shell`, `SECTIONS`), `src.saved_views.db` (`list_views`, `rename`, `delete`), `src.saved_views.ui.views_table`, `flask_login.current_user`.
- Produces: page enregistrée sur `/compte/vues` ; section `vues` dans `SECTIONS`.
- [ ] **Step 1: Écrire le test de la section (échoue)**
Créer `tests/saved_views/test_compte_vues.py` :
```python
from src.pages import _compte_shell as shell
def test_vues_section_is_gated_subscription():
section = next(s for s in shell.SECTIONS if s["key"] == "vues")
assert section["href"] == "/compte/vues"
assert section["require_subscription"] is True
def test_vues_hidden_without_subscription():
keys = {s["key"] for s in shell.visible_sections(has_subscription=False)}
assert "vues" not in keys
def test_vues_visible_with_subscription():
keys = {s["key"] for s in shell.visible_sections(has_subscription=True)}
assert "vues" in keys
```
- [ ] **Step 2: Lancer le test, vérifier l'échec**
Run: `uv run pytest tests/saved_views/test_compte_vues.py -v`
Expected: FAIL (`StopIteration` : section `vues` absente).
- [ ] **Step 3: Ajouter la section dans `src/pages/_compte_shell.py`**
Dans la liste `SECTIONS`, après l'entrée `filtres`, ajouter :
```python
{
"key": "vues",
"label": "Mes vues",
"href": "/compte/vues",
"require_subscription": True,
},
```
- [ ] **Step 4: Lancer le test, vérifier le succès**
Run: `uv run pytest tests/saved_views/test_compte_vues.py -v`
Expected: PASS (3 tests).
- [ ] **Step 5: Créer la page `src/pages/compte_vues.py`**
```python
import dash_bootstrap_components as dbc
from dash import (
ALL,
Input,
Output,
State,
callback,
ctx,
html,
no_update,
register_page,
)
from flask_login import current_user
from src.pages._compte_shell import account_guard, account_shell
from src.saved_views import db as saved_views_db
from src.saved_views import ui as saved_views_ui
register_page(
__name__,
path="/compte/vues",
title="Mes vues | decp.info",
name="Mes vues",
description="Gérez vos vues enregistrées du tableau des marchés.",
)
def _content():
views = saved_views_db.list_views(current_user.id, "tableau")
return html.Div(
[
html.H2("Mes vues"),
html.P(
"Les vues que vous enregistrez depuis le Tableau apparaissent ici. "
"Cliquez sur « Ouvrir » pour appliquer une vue."
),
html.Div(saved_views_ui.views_table(views), id="vues-list"),
]
)
def layout(**_):
guard = account_guard("/compte/vues", require_subscription=True)
if guard is not None:
return guard
return account_shell("vues", _content())
@callback(
Output("vues-list", "children"),
Input({"type": "vue-delete", "index": ALL}, "n_clicks"),
prevent_initial_call=True,
)
def delete_view(n_clicks):
if not ctx.triggered_id or not any(n_clicks):
return no_update
saved_views_db.delete(ctx.triggered_id["index"], current_user.id)
views = saved_views_db.list_views(current_user.id, "tableau")
return saved_views_ui.views_table(views)
```
- [ ] **Step 6: Ajouter le renommage (modale partagée)**
Ajouter en bas de `src/pages/compte_vues.py` une modale de renommage et ses callbacks. Compléter `_content()` pour inclure la modale :
Dans `_content()`, ajouter à la liste des enfants (après `vues-list`) :
```python
dbc.Modal(
id="vue-rename-modal",
is_open=False,
children=[
dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")),
dbc.ModalBody(
dbc.Input(id="vue-rename-input", type="text"),
),
dbc.ModalFooter(
dbc.Button("Renommer", id="vue-rename-confirm", color="primary")
),
],
),
dbc.Input(id="vue-rename-id", type="hidden"),
```
Puis les callbacks :
```python
@callback(
Output("vue-rename-modal", "is_open"),
Output("vue-rename-id", "value"),
Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"),
Input("vue-rename-confirm", "n_clicks"),
State("vue-rename-modal", "is_open"),
prevent_initial_call=True,
)
def toggle_rename_modal(_open, _confirm, is_open):
if isinstance(ctx.triggered_id, dict) and any(_open):
return True, str(ctx.triggered_id["index"])
return False, no_update
@callback(
Output("vues-list", "children", allow_duplicate=True),
Input("vue-rename-confirm", "n_clicks"),
State("vue-rename-id", "value"),
State("vue-rename-input", "value"),
prevent_initial_call=True,
)
def rename_view(_n, view_id, new_name):
clean = saved_views_ui.clean_view_name(new_name)
if not view_id or not clean:
return no_update
saved_views_db.rename(int(view_id), current_user.id, clean)
views = saved_views_db.list_views(current_user.id, "tableau")
return saved_views_ui.views_table(views)
```
- [ ] **Step 7: Vérifier l'import et les tests**
Run: `uv run python -c "import src.app"`
Expected: aucune erreur.
Run: `uv run pytest tests/saved_views -v`
Expected: PASS.
- [ ] **Step 8: Vérification manuelle (smoke test)**
Avec un compte abonné, aller sur `/compte/vues` : la liste des vues s'affiche ; « Supprimer » retire une vue ; « Renommer » ouvre la modale et met à jour le nom. Vérifier qu'un·e non-abonné·e est redirigé·e vers `/compte/abonnement`.
- [ ] **Step 9: Commit**
```bash
git add src/pages/_compte_shell.py src/pages/compte_vues.py tests/saved_views/test_compte_vues.py
git commit -m "feat: page /compte/vues (liste, renommer, supprimer) #95"
```
---
## Task 6: Vérification finale
- [ ] **Step 1: Lancer toute la suite**
Run: `uv run pytest`
Expected: PASS (les tests Selenium peuvent nécessiter Chrome ; au minimum `tests/saved_views`, `tests/test_compte_shell.py`, `tests/subscriptions` doivent passer).
- [ ] **Step 2: Vérifier le formatage**
Run: `uv run ruff format --check src/saved_views src/pages/compte_vues.py`
Expected: déjà formaté (sinon lancer `uv run ruff format` et committer).
- [ ] **Step 3: Commit final si nécessaire**
```bash
git add -A
git commit -m "chore: formatage vues sauvegardées #95"
```
---
## Self-Review
**Couverture de la spec :**
- Stockage `saved_views` dans `users.sqlite` → Task 1. ✓
- Vue = nom + query (`filtres`/`tris`/`colonnes`), réutilise le mécanisme existant → Task 2 (`build_view_query`) + Task 4. ✓
- Bouton « Sauvegarder la vue » + modale de nommage → Task 4. ✓
- Menu déroulant des vues, application par navigation → Task 4 (`saved_views_items`, liens `/tableau?<query>` + `restore_view_from_url` existant). ✓
- Masquage pour non-abonné·es + contrôle serveur → Task 4 (`toggle_saved_views_bar`, `save_view` re-vérifie l'abonnement). ✓
- Gestion `/compte/vues` (liste, renommer, supprimer), section gatée → Task 5. ✓
- Isolation par `user_id`, cascade → Task 1 (tests). ✓
- Hors périmètre titulaire/acheteur, colonne `table_name` réservée → respecté (`table_name="tableau"` partout). ✓
- Tests DB + gating → Tasks 1, 3, 5. ✓
**Scan placeholders :** aucun TBD/TODO ; tout le code est fourni.
**Cohérence des types/noms :** `build_view_query(filter_query, sort_by, hidden_columns)` cohérent Task 2↔4 ; `upsert(user_id, table_name, name, query)`, `list_views(user_id, table_name)`, `rename(view_id, user_id, new_name)`, `delete(view_id, user_id)` cohérents Task 1↔4↔5 ; `prepare_view_to_save(has_subscription, name) -> (clean_name, error)` cohérent Task 3↔4 ; ids des composants pattern-matching (`vue-delete`, `vue-rename-open`) cohérents `ui.py``compte_vues.py`.
@@ -0,0 +1,302 @@
# Charte graphique des boutons — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer les couleurs de boutons dérivées du thème Simplex (danger mauve, secondary gris illisible) par une charte à trois rôles — `primary` terracotta, `secondary` gris ardoise, `danger` rouge — où la couleur encode la fonction et le remplissage l'emphase.
**Architecture:** Override CSS ciblant exclusivement les classes `.btn-*` dans `src/assets/css/style.css` (jamais les variables `--bs-*` racine, pour ne pas affecter alertes/badges). Le code applicatif continue d'utiliser `color="primary|secondary|danger"` + `outline=True|False` natifs de dash-bootstrap-components. Puis audit des boutons existants pour conformer leur usage à la charte.
**Tech Stack:** Dash 3.4, dash-bootstrap-components, Bootstrap 5 (thème Simplex), CSS, pytest + Selenium (DashComposite).
## Global Constraints
- Override **uniquement** les sélecteurs `.btn-*` ; ne **jamais** modifier les variables racine `--bs-primary`, `--bs-danger`, `--bs-secondary` (alertes/badges doivent rester inchangés). Source : spec « Hors périmètre ».
- Imports applicatifs toujours préfixés `src.` (ex. `src.pages.recherche`).
- Palette exacte : terracotta `#b33821`, danger rouge `#c0392b`, secondary texte `#344054` / bord `#5a6570`, disabled bord `#ccc` / texte `#666`.
- `border-radius: 3px` et typographie `Inter` poids 400 conservés sur les boutons.
- Focus clavier visible (`:focus-visible`) sur tous les boutons restylés (accessibilité).
- Respecter `prefers-reduced-motion` pour toute transition de survol.
- Tests : `rtk pytest` (Selenium, nécessite Chrome/Chromium). `DEVELOPMENT=true` est positionné automatiquement.
---
### Task 1: Override CSS des trois rôles de boutons
Redéfinit l'apparence des classes `.btn-primary`, `.btn-outline-primary`, `.btn-secondary`, `.btn-outline-secondary`, `.btn-danger`, `.btn-outline-danger` dans la feuille de style applicative, en conservant l'API dbc native. Ajoute un test Selenium de non-régression vérifiant que la feuille est bien appliquée au bouton primaire de la page d'accueil (publique).
**Files:**
- Modify: `src/assets/css/style.css` (bloc « Base Button Styles » autour des lignes 42-79)
- Test: `tests/test_boutons.py` (créer)
**Interfaces:**
- Consumes: rien (première tâche).
- Produces: classes CSS restylées `.btn-primary`, `.btn-outline-primary`, `.btn-secondary`, `.btn-outline-secondary`, `.btn-danger`, `.btn-outline-danger`. Aucun symbole Python.
- [ ] **Step 1: Écrire le test de non-régression (échoue d'abord car couleur cible non garantie)**
Créer `tests/test_boutons.py`. Le bouton « Rechercher » de la page d'accueil (`/`, public, `className="btn btn-primary"` dans `src/pages/recherche.py:56`) doit être rendu terracotta plein (le dégradé existant a `rgb(179, 56, 33)` en couleur médiane → on vérifie une composante rouge dominante et un fond non transparent).
```python
from dash.testing.composite import DashComposite
def _rgb_tuple(css_color: str) -> tuple[int, int, int]:
"""Parse 'rgb(r, g, b)' ou 'rgba(r, g, b, a)' en (r, g, b)."""
inner = css_color[css_color.index("(") + 1 : css_color.index(")")]
parts = [p.strip() for p in inner.split(",")]
return (int(parts[0]), int(parts[1]), int(parts[2]))
def test_btn_primary_is_terracotta(dash_duo: DashComposite):
from src.app import app
dash_duo.start_server(app)
dash_duo.wait_for_element("a.btn.btn-primary, button.btn.btn-primary", timeout=10)
btn = dash_duo.find_element("a.btn.btn-primary, button.btn.btn-primary")
# Le dégradé terracotta est posé via background-image ; la couleur de
# repli background-color ne doit pas être le bleu Bootstrap par défaut.
bg_image = btn.value_of_css_property("background-image")
color = btn.value_of_css_property("color")
assert "gradient" in bg_image # dégradé terracotta appliqué
r, g, b = _rgb_tuple(color)
assert (r, g, b) == (255, 255, 255) # texte blanc
```
- [ ] **Step 2: Lancer le test pour vérifier l'état initial**
Run: `rtk pytest tests/test_boutons.py::test_btn_primary_is_terracotta -v`
Expected: PASS si le `.btn-primary` actuel s'applique déjà (dégradé + texte blanc déjà présents lignes 51-65). Ce test sert de **garde de non-régression** : il doit rester vert après le refactor CSS. S'il échoue ici, c'est que la feuille n'est pas chargée → investiguer avant de continuer.
- [ ] **Step 3: Réécrire le bloc boutons dans `style.css`**
Remplacer le bloc commenté/partiel « Base Button Styles » (lignes ~42-79 : le commentaire mort, `button.btn.btn-primary, button.show-hide { … }`, son `:hover`, et `button[disabled]`) par la charte complète ci-dessous. Conserver le dégradé terracotta existant pour `.btn-primary` et le `button.show-hide` (qui réutilise ce style).
```css
/* ==========================================================================
Boutons — charte à 3 rôles (couleur = fonction, remplissage = emphase)
Voir docs/superpowers/specs/2026-06-30-charte-boutons-design.md
Override scopé aux classes .btn-* uniquement (n'affecte pas les alertes).
========================================================================== */
:root {
--btn-terracotta: #b33821;
--btn-terracotta-text: #fff;
--btn-secondary-text: #344054;
--btn-secondary-border: #5a6570;
--btn-secondary-hover-bg: #f1f3f5;
--btn-danger: #c0392b;
--btn-danger-text: #fff;
}
/* Base commune : rayon + typo */
.btn {
border-radius: 3px;
font-family: "Inter", sans-serif;
font-weight: 400;
}
/* --- PRIMARY plein : action principale validante (1 par contexte) --- */
button.btn.btn-primary,
a.btn.btn-primary,
button.show-hide {
display: block;
outline: 0;
color: var(--btn-terracotta-text);
border: 0;
background-image: linear-gradient(
rgb(209, 96, 73),
rgb(179, 56, 33) 26%,
rgb(159, 36, 22)
);
}
button.btn.btn-primary:hover,
a.btn.btn-primary:hover,
button.show-hide:hover {
color: var(--btn-terracotta-text);
background-image: linear-gradient(
rgb(239, 126, 103),
rgb(209, 86, 63) 26%,
rgb(189, 66, 52)
);
}
/* --- PRIMARY outline : action affirmative de moindre emphase --- */
.btn.btn-outline-primary {
color: var(--btn-terracotta);
border: 1px solid var(--btn-terracotta);
background-color: transparent;
background-image: none;
}
.btn.btn-outline-primary:hover,
.btn.btn-outline-primary:focus-visible {
color: var(--btn-terracotta-text);
background-color: var(--btn-terracotta);
border-color: var(--btn-terracotta);
}
/* --- SECONDARY : action neutre / alternative (gris ardoise) --- */
.btn.btn-secondary,
.btn.btn-outline-secondary {
color: var(--btn-secondary-text);
border: 1px solid var(--btn-secondary-border);
background-color: transparent;
background-image: none;
}
.btn.btn-secondary:hover,
.btn.btn-secondary:focus-visible,
.btn.btn-outline-secondary:hover,
.btn.btn-outline-secondary:focus-visible {
color: var(--btn-secondary-text);
background-color: var(--btn-secondary-hover-bg);
border-color: var(--btn-secondary-border);
}
/* --- DANGER outline : action destructive dans le flux courant --- */
.btn.btn-outline-danger {
color: var(--btn-danger);
border: 1px solid var(--btn-danger);
background-color: transparent;
background-image: none;
}
.btn.btn-outline-danger:hover,
.btn.btn-outline-danger:focus-visible {
color: var(--btn-danger-text);
background-color: var(--btn-danger);
border-color: var(--btn-danger);
}
/* --- DANGER plein : confirmation finale destructive (modale) --- */
.btn.btn-danger {
color: var(--btn-danger-text);
border: 1px solid var(--btn-danger);
background-color: var(--btn-danger);
background-image: none;
}
.btn.btn-danger:hover,
.btn.btn-danger:focus-visible {
color: var(--btn-danger-text);
background-color: #a93226;
border-color: #a93226;
}
/* --- État désactivé commun (conserve l'existant) --- */
.btn[disabled],
button[disabled] {
border-color: #ccc;
color: #666;
background-image: none;
}
@media (prefers-reduced-motion: no-preference) {
.btn {
transition: background-color 0.15s ease, color 0.15s ease,
border-color 0.15s ease;
}
}
```
- [ ] **Step 4: Relancer le test de non-régression**
Run: `rtk pytest tests/test_boutons.py::test_btn_primary_is_terracotta -v`
Expected: PASS (le `.btn-primary` conserve son dégradé terracotta et son texte blanc).
- [ ] **Step 5: Vérifier l'absence de régression sur la suite complète**
Run: `rtk pytest`
Expected: PASS (aucune régression introduite par le CSS).
- [ ] **Step 6: Vérification visuelle des trois rôles**
Lancer l'app (`python run.py`), se connecter, ouvrir `/compte/vues`. Vérifier :
- « Renommer » : gris ardoise lisible (texte `#344054`, bord `#5a6570`), plus de gris clair illisible ;
- « Supprimer » : rouge `#c0392b` en outline, plus aucun mauve ; au survol, plein rouge texte blanc ;
- focus clavier (Tab) : anneau/fond visible sur chaque bouton.
Prendre une capture pour comparaison avec la capture initiale.
- [ ] **Step 7: Commit** (différé — voir note d'intégration en fin de plan : spec + plan + code committés ensemble)
---
### Task 2: Audit et conformation des boutons existants
Passe en revue les ~46 occurrences `color=…` et 8 `outline=True` pour s'assurer qu'elles respectent la charte : une seule action `primary` pleine par contexte, destructif outline par défaut sauf confirmation finale en modale (plein rouge), neutre en `secondary` outline.
**Files:**
- Inspect: `src/saved_views/ui.py`, `src/pages/compte_vues.py`, `src/pages/compte_abonnement.py`, `src/pages/compte_abonnement_mes_infos.py`, `src/pages/mot_de_passe_oublie.py`, `src/pages/recherche.py`, `src/pages/tableau.py`, `src/app.py`
- Modify: uniquement les fichiers dont un bouton enfreint la charte (à déterminer pendant l'audit)
- Test: `tests/test_boutons.py` (réutilise la garde de non-régression de Task 1)
**Interfaces:**
- Consumes: classes CSS restylées de Task 1.
- Produces: aucun symbole ; usages de boutons conformes à la charte.
- [ ] **Step 1: Recenser les boutons et leur rôle**
Run: `grep -rn 'dbc.Button\|className="btn' src/ --include=*.py`
Pour chaque bouton, noter (page, libellé, `color`, `outline`) et confronter à la charte :
| Cas | Attendu |
| ------------------------------------------------------------------------------------- | --------------------------------- |
| Action principale d'un écran/formulaire (Rechercher, Enregistrer, Envoyer, S'abonner) | `color="primary"` plein |
| Déclencheur destructif dans une liste (Supprimer, Se désabonner) | `color="danger", outline=True` |
| Confirmation destructive finale en modale | `color="danger"` plein |
| Action neutre (Renommer, Annuler, navigation) | `color="secondary", outline=True` |
Vérifier en particulier : aucun écran ne doit présenter **deux** boutons `primary` pleins en concurrence.
- [ ] **Step 2: Corriger les boutons non conformes**
Éditer uniquement les boutons divergents. Exemple de correction type (n'appliquer que si un cas réel est trouvé) :
```python
# Avant — déclencheur de suppression en plein rouge dans une liste
dbc.Button("Supprimer", id={"type": "vue-delete", "index": vid}, color="danger")
# Après — outline par défaut hors confirmation finale
dbc.Button("Supprimer", id={"type": "vue-delete", "index": vid}, color="danger", outline=True)
```
Si l'audit ne révèle aucune divergence (les usages de `src/saved_views/ui.py` sont déjà conformes : Renommer = `secondary`+outline, Supprimer = `danger`+outline ; la confirmation modale `vue-rename-confirm` = `primary`), documenter « aucun changement nécessaire » dans le message de commit et passer au Step 3.
- [ ] **Step 3: Vérifier la non-régression**
Run: `rtk pytest`
Expected: PASS.
- [ ] **Step 4: Vérification visuelle multi-pages**
Lancer l'app, parcourir `/compte/vues`, `/compte/abonnement`, `/` et `/tableau`. Confirmer que chaque bouton respecte son rôle et qu'il n'y a pas deux `primary` pleins concurrents par écran.
- [ ] **Step 5: Commit** (différé — voir note d'intégration ci-dessous)
---
## Note d'intégration
Sur demande explicite de l'utilisateur, le **spec + le plan + le code** sont
committés **ensemble** (pas de commit intermédiaire). Après validation des deux
tâches, faire un unique commit :
```bash
git add docs/superpowers/specs/2026-06-30-charte-boutons-design.md \
docs/superpowers/plans/2026-06-30-charte-boutons.md \
src/assets/css/style.css tests/test_boutons.py
# + tout fichier page modifié pendant l'audit (Task 2)
git commit -m "feat: charte graphique des boutons (primary/secondary/danger) #<issue>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
Branche courante : `dev` (auto-déploie sur test.decp.info). Ne pas pousser sans
demande explicite.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,754 @@
# Panneau admin — éditeur générique de tables Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer les pages admin dédiées (`/admin/user/<id>`, `/admin/journal` + formulaire de changement de statut) par une unique page `/admin` : un sélecteur de table SQLite + une `dash_table.DataTable` filtrable/triable/paginée en natif dont les cellules sont éditables directement.
**Architecture:** Un registre statique de tables autorisées (`src/admin/tables.py`) porte toute la logique de validation/écriture, testable sans Dash. La page (`src/pages/admin/liste.py`) ne fait que du câblage : un callback unique, déclenché soit par le changement de table (recharge les données), soit par une édition de cellule (diff `data`/`data_previous`, valide, écrit, logue).
**Tech Stack:** Dash 3.4 (`dash_table.DataTable`, `editable`, `dropdown`), dash-bootstrap-components, sqlite3 brut (pas d'ORM).
**Spec:** `docs/superpowers/specs/2026-07-03-admin-table-editor-design.md`
## Global Constraints
- Tables éditables : `users` (jamais `password_hash` — colonne totalement exclue de `SELECT`/affichage), `subscriptions`, `subscriber_state`. `admin_actions` est consultable dans le même sélecteur mais en lecture seule (aucune colonne éditable).
- Colonnes jamais éditables, quelle que soit la table : la clé primaire, `created_at`, `updated_at`. Colonnes explicitement exclues de l'édition même si elles ne sont ni PK ni timestamp : `frisbii_customer_handle`, `frisbii_subscription_handle`, `user_id` (FK).
- Nom de table et de colonne **toujours** validés contre le registre `TABLES` avant toute requête SQL — jamais de nom interpolé directement depuis une valeur venant du client sans passer par ce registre.
- Chaque colonne éditable a un type attendu (`int`, `float`, `str`) ; une valeur qui ne convertit pas proprement est rejetée avant écriture (rien n'est écrit, une alerte s'affiche).
- `dash_table.DataTable` : `filter_action="native"`, `sort_action="native"`, `page_action="native"`, `page_size=20` partout dans cette page.
- `is_admin()` (déjà en place, `src/admin/guard.py`) garde la page : non-admin → composant 404 (`not_admin()`), jamais de redirection.
- Chaque édition de cellule réussie est loguée via `log_action()` (déjà en place, `src/admin/db.py`) avec `action=f"edit_{table}"`, `target_user_id` dérivé par table, `details=f"{column}: {old!r} → {new!r}"`.
- Run tests with `uv run pytest` (venv activation via le Bash tool ne met pas PATH à jour de façon fiable ici).
- `sqlite3` : connexions autocommit (`isolation_level=None`) — aucun `conn.commit()` dans les nouvelles fonctions DB, comme partout ailleurs dans le projet.
- `tests/users.test.sqlite` est committé dans git et partagé pour toute la session de tests Selenium (`USERS_DB_PATH` fixé globalement dans `pyproject.toml`) — toute ligne créée par un test Selenium doit être supprimée en `finally`, avec vérification `git status --short tests/users.test.sqlite` vide après un run complet.
---
### Task 1: Registre des tables (`src/admin/tables.py`)
**Files:**
- Create: `src/admin/tables.py`
- Test: `tests/admin/test_tables.py`
**Interfaces:**
- Produces: `TableConfig` (dataclass), `TABLES: dict[str, TableConfig]`
- Produces: `get_rows(table: str) -> list[dict]`
- Produces: `set_cell(table: str, pk_value, column: str, value) -> None` (lève `ValueError` si table/colonne/valeur invalide)
- Produces: `find_changed_cell(data: list[dict], data_previous: list[dict] | None) -> tuple[int, str, object, object] | None``(row_index, column, old_value, new_value)` de la première cellule modifiée, ou `None`.
- Consumes: `get_conn()` de `src.auth.db` (déjà existant), `SUBSCRIPTION_STATUSES` de `src.subscriptions.db` (déjà existant, valeurs `("active", "trial", "cancelled", "expired", "pending")`), `PLANS` de `src.subscriptions.plans` (déjà existant, dict avec les clés `"simple"`, `"soutien"`).
- [ ] **Step 1: Write the failing tests**
Create `tests/admin/test_tables.py`:
```python
import pytest
from src.admin import tables
def test_get_rows_users_excludes_password_hash(users_db_path):
from src.auth import db as auth_db
auth_db.init_schema()
auth_db.create_user("a@ex.fr", "secret-hash")
rows = tables.get_rows("users")
assert rows[0]["email"] == "a@ex.fr"
assert "password_hash" not in rows[0]
def test_set_cell_rejects_unknown_table(users_db_path):
with pytest.raises(ValueError):
tables.set_cell("not_a_table", 1, "email", "x@ex.fr")
def test_set_cell_rejects_non_editable_column(users_db_path):
from src.auth import db as auth_db
auth_db.init_schema()
uid = auth_db.create_user("a@ex.fr", "hash")
with pytest.raises(ValueError):
tables.set_cell("users", uid, "id", "999")
def test_set_cell_rejects_invalid_dropdown_value(users_db_path):
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
auth_db.init_schema()
sub_db.init_schema()
uid = auth_db.create_user("a@ex.fr", "hash")
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
with pytest.raises(ValueError):
tables.set_cell("subscriptions", sub_id, "status", "not_a_status")
def test_set_cell_rejects_bad_type(users_db_path):
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
auth_db.init_schema()
sub_db.init_schema()
uid = auth_db.create_user("a@ex.fr", "hash")
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
with pytest.raises(ValueError):
tables.set_cell("subscriptions", sub_id, "prix_ht", "not-a-number")
def test_set_cell_writes_valid_value(users_db_path):
from src.auth import db as auth_db
auth_db.init_schema()
uid = auth_db.create_user("a@ex.fr", "hash")
tables.set_cell("users", uid, "siret", "12345678900011")
rows = tables.get_rows("users")
assert rows[0]["siret"] == "12345678900011"
def test_set_cell_coerces_numeric_type(users_db_path):
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
auth_db.init_schema()
sub_db.init_schema()
uid = auth_db.create_user("a@ex.fr", "hash")
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
tables.set_cell("subscriptions", sub_id, "prix_ht", "30")
rows = tables.get_rows("subscriptions")
assert rows[0]["prix_ht"] == 30.0
def test_find_changed_cell_detects_single_diff():
data = [{"id": 1, "email": "new@ex.fr"}]
data_previous = [{"id": 1, "email": "old@ex.fr"}]
result = tables.find_changed_cell(data, data_previous)
assert result == (0, "email", "old@ex.fr", "new@ex.fr")
def test_find_changed_cell_returns_none_when_identical():
data = [{"id": 1, "email": "a@ex.fr"}]
data_previous = [{"id": 1, "email": "a@ex.fr"}]
assert tables.find_changed_cell(data, data_previous) is None
def test_find_changed_cell_returns_none_when_previous_is_none():
assert tables.find_changed_cell([{"id": 1}], None) is None
def test_target_user_id_per_table():
assert tables.TABLES["users"].target_user_id({"id": 7}) == 7
assert tables.TABLES["subscriptions"].target_user_id({"user_id": 9}) == 9
assert tables.TABLES["subscriber_state"].target_user_id({"user_id": 3}) == 3
assert tables.TABLES["admin_actions"].target_user_id({"id": 1}) is None
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/admin/test_tables.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'src.admin.tables'`
- [ ] **Step 3: Implement `src/admin/tables.py`**
```python
from dataclasses import dataclass
from typing import Callable
from src.auth.db import get_conn
from src.subscriptions.db import SUBSCRIPTION_STATUSES
from src.subscriptions.plans import PLANS
@dataclass(frozen=True)
class TableConfig:
columns: list[str]
editable_columns: frozenset[str]
pk: str
column_types: dict[str, type]
dropdowns: dict[str, list[str]]
target_user_id: Callable[[dict], int | None]
TABLES: dict[str, TableConfig] = {
"users": TableConfig(
columns=[
"id",
"email",
"email_verified",
"siret",
"pending_email",
"created_at",
"updated_at",
],
editable_columns=frozenset(
{"email", "email_verified", "siret", "pending_email"}
),
pk="id",
column_types={
"email": str,
"email_verified": int,
"siret": str,
"pending_email": str,
},
dropdowns={"email_verified": ["0", "1"]},
target_user_id=lambda row: row["id"],
),
"subscriptions": TableConfig(
columns=[
"id",
"user_id",
"frisbii_customer_handle",
"frisbii_subscription_handle",
"plan",
"prix_ht",
"status",
"current_period_end",
"created_at",
"updated_at",
],
editable_columns=frozenset(
{"plan", "prix_ht", "status", "current_period_end"}
),
pk="id",
column_types={
"plan": str,
"prix_ht": float,
"status": str,
"current_period_end": str,
},
dropdowns={
"status": list(SUBSCRIPTION_STATUSES),
"plan": list(PLANS.keys()),
},
target_user_id=lambda row: row["user_id"],
),
"subscriber_state": TableConfig(
columns=[
"user_id",
"trial_used",
"votes_balance",
"votes_last_credited_at",
"updated_at",
],
editable_columns=frozenset(
{"trial_used", "votes_balance", "votes_last_credited_at"}
),
pk="user_id",
column_types={
"trial_used": int,
"votes_balance": int,
"votes_last_credited_at": str,
},
dropdowns={"trial_used": ["0", "1"]},
target_user_id=lambda row: row["user_id"],
),
"admin_actions": TableConfig(
columns=["id", "admin_email", "action", "target_user_id", "details", "created_at"],
editable_columns=frozenset(),
pk="id",
column_types={},
dropdowns={},
target_user_id=lambda row: None,
),
}
def get_rows(table: str) -> list[dict]:
cfg = TABLES[table]
cols_sql = ", ".join(cfg.columns)
rows = get_conn().execute(f"SELECT {cols_sql} FROM {table}").fetchall()
return [dict(row) for row in rows]
def _coerce_value(table: str, column: str, value):
cfg = TABLES[table]
if column not in cfg.editable_columns:
raise ValueError(f"Colonne non éditable : {column}")
if column in cfg.dropdowns and str(value) not in cfg.dropdowns[column]:
raise ValueError(f"Valeur non autorisée pour {column} : {value!r}")
expected_type = cfg.column_types[column]
try:
if expected_type is int:
return int(value)
if expected_type is float:
return float(value)
return str(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Valeur invalide pour {column} : {value!r}") from exc
def set_cell(table: str, pk_value, column: str, value) -> None:
if table not in TABLES:
raise ValueError(f"Table inconnue : {table}")
cfg = TABLES[table]
coerced = _coerce_value(table, column, value)
get_conn().execute(
f"UPDATE {table} SET {column} = ? WHERE {cfg.pk} = ?", (coerced, pk_value)
)
def find_changed_cell(
data: list[dict], data_previous: list[dict] | None
) -> tuple[int, str, object, object] | None:
if data_previous is None or len(data) != len(data_previous):
return None
for i, (new_row, old_row) in enumerate(zip(data, data_previous)):
for col, new_val in new_row.items():
old_val = old_row.get(col)
if new_val != old_val:
return i, col, old_val, new_val
return None
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/admin/test_tables.py -v`
Expected: 10 passed
- [ ] **Step 5: Commit**
```bash
git add src/admin/tables.py tests/admin/test_tables.py
git commit -m "feat(admin): add whitelisted table registry and cell validation/write logic"
```
---
### Task 2: Page éditeur générique + suppression du code obsolète
**Files:**
- Modify: `src/pages/admin/liste.py` (réécrit entièrement)
- Modify: `src/pages/admin/_shell.py` (retire `admin_nav`)
- Modify: `src/auth/setup.py:47-53` (retire l'enregistrement du blueprint `admin_bp`)
- Modify: `tests/admin/conftest.py` (retire les fixtures Flask-app devenues inutiles, garde `users_db_path`)
- Delete: `src/pages/admin/detail.py`
- Delete: `src/pages/admin/journal.py`
- Delete: `src/admin/routes.py`
- Delete: `tests/admin/test_routes.py`
- Delete: `tests/admin/test_pages.py` (recréé dans la Task 3 avec le nouveau flux — le contenu actuel teste des routes qui n'existent plus)
**Interfaces:**
- Consumes : `TABLES`, `get_rows`, `set_cell`, `find_changed_cell` (Task 1) ; `is_admin()` (`src/admin/guard.py`, inchangé) ; `log_action()` (`src/admin/db.py`, inchangé) ; `not_admin()` (`src/pages/admin/_shell.py`).
- [ ] **Step 1: Simplifier `src/pages/admin/_shell.py`**
Remplacer tout le contenu par :
```python
from dash import html
def not_admin():
return html.Div(
html.H2("404", id="admin-404-heading"), className="py-5 text-center"
)
```
(`admin_nav` disparaît : il n'y a plus qu'une seule page, plus de navigation entre sous-pages.)
- [ ] **Step 2: Réécrire `src/pages/admin/liste.py`**
Remplacer tout le contenu par :
```python
import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, ctx, dash_table, html, no_update, register_page
from flask_login import current_user
from src.admin.db import log_action
from src.admin.guard import is_admin
from src.admin.tables import TABLES, find_changed_cell, get_rows, set_cell
from src.pages.admin._shell import not_admin
register_page(
__name__,
path="/admin",
title="Panneau admin | colibre",
name="Admin",
description="Panneau d'administration interne.",
)
DEFAULT_TABLE = "users"
def _columns_for(table: str):
cfg = TABLES[table]
return [
{
"name": col,
"id": col,
"editable": col in cfg.editable_columns,
**({"presentation": "dropdown"} if col in cfg.dropdowns else {}),
}
for col in cfg.columns
]
def _dropdown_for(table: str):
cfg = TABLES[table]
return {
col: {"options": [{"label": v, "value": v} for v in values]}
for col, values in cfg.dropdowns.items()
}
def layout(**_):
if not is_admin():
return not_admin()
return dbc.Container(
[
html.H2("Panneau admin"),
html.Div(id="admin-alerts"),
dbc.Select(
id="admin-table-select",
options=[{"label": name, "value": name} for name in TABLES],
value=DEFAULT_TABLE,
className="mb-3",
style={"maxWidth": "300px"},
),
dash_table.DataTable(
id="admin-table",
columns=_columns_for(DEFAULT_TABLE),
data=get_rows(DEFAULT_TABLE),
dropdown=_dropdown_for(DEFAULT_TABLE),
editable=True,
filter_action="native",
sort_action="native",
page_action="native",
page_size=20,
),
],
fluid=True,
className="py-4",
)
@callback(
Output("admin-table", "data"),
Output("admin-table", "columns"),
Output("admin-table", "dropdown"),
Output("admin-alerts", "children"),
Input("admin-table-select", "value"),
Input("admin-table", "data"),
State("admin-table", "data_previous"),
prevent_initial_call=True,
)
def _update_table(selected_table, data, data_previous):
if ctx.triggered_id == "admin-table-select":
return (
get_rows(selected_table),
_columns_for(selected_table),
_dropdown_for(selected_table),
None,
)
change = find_changed_cell(data, data_previous)
if change is None:
return no_update, no_update, no_update, None
row_index, column, old_value, new_value = change
pk_value = data[row_index][TABLES[selected_table].pk]
try:
set_cell(selected_table, pk_value, column, new_value)
except ValueError as exc:
return (
no_update,
no_update,
no_update,
dbc.Alert(str(exc), color="danger", dismissable=True),
)
target_user_id = TABLES[selected_table].target_user_id(data[row_index])
log_action(
current_user.email,
f"edit_{selected_table}",
target_user_id,
f"{column}: {old_value!r}{new_value!r}",
)
return (
no_update,
no_update,
no_update,
dbc.Alert("Modification enregistrée.", color="success", dismissable=True),
)
```
Note d'implémentation : quand une écriture échoue (type invalide, colonne non éditable, valeur hors liste), le callback renvoie `no_update` pour `data` plutôt que de réécrire activement l'ancienne valeur — cela évite tout risque de boucle de déclenchement (le callback a `data` à la fois en `Input` et `Output`). La cellule affichée côté navigateur garde alors la valeur tapée (invalide) jusqu'à ce que l'admin resélectionne la table (ce qui recharge tout depuis la base) ; l'alerte rouge signale explicitement que rien n'a été écrit.
- [ ] **Step 3: Supprimer le code obsolète**
```bash
git rm src/pages/admin/detail.py src/pages/admin/journal.py src/admin/routes.py tests/admin/test_routes.py tests/admin/test_pages.py
```
- [ ] **Step 4: Retirer l'enregistrement du blueprint dans `src/auth/setup.py`**
Supprimer ces lignes (actuellement `src/auth/setup.py:51-53`, juste après l'enregistrement de `auth_bp`) :
```python
from src.admin.routes import admin_bp
app.register_blueprint(admin_bp)
```
- [ ] **Step 5: Nettoyer `tests/admin/conftest.py`**
Remplacer tout le contenu par (seule la fixture encore utilisée — par `tests/admin/test_tables.py` et `tests/admin/test_guard.py` — est conservée) :
```python
import pytest
@pytest.fixture
def users_db_path(monkeypatch, tmp_path):
from src.auth.db import reset_conn_for_tests
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
reset_conn_for_tests()
yield db_path
reset_conn_for_tests()
```
- [ ] **Step 6: Vérifier que le reste de la suite passe toujours**
Run: `uv run pytest tests/admin/ -v`
Expected: tous les tests de `test_tables.py` et `test_guard.py` passent (pas de `test_pages.py`/`test_routes.py` à ce stade, supprimés à l'étape 3 — recréés Task 3)
- [ ] **Step 7: Vérification manuelle du rendu (sans navigateur)**
Run:
```bash
uv run python -c "
import os
os.environ.setdefault('USERS_DB_PATH', 'tests/users.test.sqlite')
os.environ.setdefault('SECRET_KEY', 'x')
os.environ['ADMIN_EMAIL'] = 'admin@ex.fr'
import src.app
from unittest.mock import patch
with src.app.app.server.test_request_context():
import src.pages.admin.liste as liste
admin = type('U', (), {'is_authenticated': True, 'email': 'admin@ex.fr'})()
with patch('src.admin.guard.current_user', admin):
print(type(liste.layout()))
non_admin = type('U', (), {'is_authenticated': True, 'email': 'autre@ex.fr'})()
with patch('src.admin.guard.current_user', non_admin):
print(type(liste.layout()))
"
git status --short tests/users.test.sqlite
```
Expected : deux lignes `<class '...Container.Container'>` puis `<class '...Div'>`, aucune trace d'erreur ; `git status --short tests/users.test.sqlite` ne renvoie rien (fichier inchangé).
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat(admin): replace dedicated pages with a generic table editor at /admin"
```
---
### Task 3: Couverture Selenium bout-en-bout
**Files:**
- Create: `tests/admin/test_pages.py`
**Interfaces:**
- Consumes : `src.app.app`, `src.auth.db`, `src.subscriptions.db` (inchangés).
**Note sur le mécanisme d'édition testé :** la colonne `subscriptions.status` est une cellule "dropdown" (`presentation: "dropdown"`), dont l'interaction Selenium est plus fragile à automatiser de façon fiable qu'une cellule texte standard (widget de sélection non natif, rendu par dash_table). Ce test exerce donc l'édition sur `subscriptions.prix_ht` (cellule texte standard, éditable en cliquant/tapant/tabulant) pour prouver que tout le pipeline fonctionne (clic → édition → callback → écriture DB → audit). La logique de validation spécifique aux colonnes "dropdown" (`status`, `plan`, `email_verified`, `trial_used`) est déjà couverte sans navigateur par `test_set_cell_rejects_invalid_dropdown_value` (Task 1).
**Note sur les sélecteurs CSS de cellule :** `dash_table.DataTable` rend chaque cellule avec les attributs `data-dash-row` et `data-dash-column` (documentés, stables). Si le rendu réel diverge de ce qui est écrit ci-dessous (versions de Dash), inspecter le DOM réellement produit et ajuster les sélecteurs — l'important est : cliquer précisément dans la cellule ciblée, remplacer sa valeur, puis tabuler/cliquer ailleurs pour déclencher la mise à jour de la prop `data`.
- [ ] **Step 1: Écrire le fichier de test**
Create `tests/admin/test_pages.py`:
```python
import uuid
from dash.testing.composite import DashComposite
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import Select
from werkzeug.security import generate_password_hash
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
PASSWORD = "s3cretpass!"
def _unique_email(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}@ex.fr"
def _make_verified_user(email: str) -> int:
auth_db.init_schema()
uid = auth_db.create_user(email, generate_password_hash(PASSWORD))
auth_db.set_email_verified(uid)
return uid
def _cleanup_user(user_id: int) -> None:
conn = auth_db.get_conn()
conn.execute("DELETE FROM admin_actions WHERE target_user_id = ?", (user_id,))
conn.execute("DELETE FROM subscriptions WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM subscriber_state WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
# tests/users.test.sqlite est committé dans git et partagé pour toute la
# session Selenium (USERS_DB_PATH fixé globalement dans pyproject.toml).
# Les DELETE seuls laissent le fichier byte-diffé : sqlite_sequence
# (compteur AUTOINCREMENT) n'est jamais remis à zéro par un DELETE.
conn.execute(
"UPDATE sqlite_sequence SET seq = 0 "
"WHERE name IN ('users', 'subscriptions', 'admin_actions')"
)
def _login(dash_duo: DashComposite, email: str):
dash_duo.driver.get(dash_duo.server_url + "/connexion")
dash_duo.wait_for_element("input[name=email]", timeout=8).send_keys(email)
dash_duo.driver.find_element("css selector", "input[name=password]").send_keys(
PASSWORD
)
dash_duo.driver.find_element("css selector", "button[type=submit]").click()
def test_admin_anonymous_gets_404(dash_duo: DashComposite):
from src.app import app
dash_duo.start_server(app)
dash_duo.driver.get(dash_duo.server_url + "/admin")
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
def test_admin_non_admin_gets_404(dash_duo: DashComposite, monkeypatch):
from src.app import app
monkeypatch.setenv("ADMIN_EMAIL", "admin-only@ex.fr")
email = _unique_email("regular")
uid = _make_verified_user(email)
try:
dash_duo.start_server(app)
_login(dash_duo, email)
# Cet utilisateur n'a pas d'abonnement : une connexion réussie
# redirige vers /compte/abonnement (voir _post_login_url dans
# src/auth/routes.py). Ça confirme que le login a bien réussi avant
# de vérifier /admin (sinon ce test serait indiscernable de
# test_admin_anonymous_gets_404 en cas de régression du login).
dash_duo.wait_for_text_to_equal("h2", "Abonnement", timeout=8)
assert "/connexion" not in dash_duo.driver.current_url
dash_duo.driver.get(dash_duo.server_url + "/admin")
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
finally:
_cleanup_user(uid)
def test_admin_full_flow(dash_duo: DashComposite, monkeypatch):
from src.app import app
admin_email = _unique_email("admin")
monkeypatch.setenv("ADMIN_EMAIL", admin_email)
admin_uid = _make_verified_user(admin_email)
target_email = _unique_email("target")
target_uid = _make_verified_user(target_email)
sub_db.init_schema()
_handle, sub_id = sub_db.create_pending(target_uid, "cust-e2e", "simple", 20.0)
sub_db.set_status(sub_id, "active")
try:
dash_duo.start_server(app)
_login(dash_duo, admin_email)
dash_duo.driver.get(dash_duo.server_url + "/admin")
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
assert target_email in dash_duo.driver.page_source # table users, par défaut
select = Select(dash_duo.wait_for_element("#admin-table-select", timeout=8))
select.select_by_value("subscriptions")
dash_duo.wait_for_element(
"td[data-dash-column='prix_ht'][data-dash-row='0']", timeout=8
)
cell = dash_duo.driver.find_element(
"css selector", "td[data-dash-column='prix_ht'][data-dash-row='0']"
)
cell.click()
active_input = dash_duo.driver.switch_to.active_element
active_input.send_keys(Keys.CONTROL, "a")
active_input.send_keys("30")
active_input.send_keys(Keys.TAB)
dash_duo.wait_for_text_to_equal(
"#admin-alerts .alert-success", "Modification enregistrée.", timeout=8
)
row = sub_db.get_current(target_uid)
assert row["prix_ht"] == 30.0
select = Select(
dash_duo.driver.find_element("css selector", "#admin-table-select")
)
select.select_by_value("admin_actions")
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
assert "edit_subscriptions" in dash_duo.driver.page_source
assert "prix_ht" in dash_duo.driver.page_source
finally:
_cleanup_user(target_uid)
_cleanup_user(admin_uid)
```
- [ ] **Step 2: Lancer les tests**
Run: `uv run pytest tests/admin/test_pages.py -v`
Expected: 3 passed. Si le clic/édition de cellule ne déclenche pas la mise à jour attendue, inspecter le DOM réel (`dash_duo.driver.page_source` ou les outils de dev du navigateur en mode non-headless) et ajuster les sélecteurs de `test_admin_full_flow` en conséquence — la structure ci-dessus est le point de départ, pas une garantie absolue selon la version exacte de `dash_table`.
- [ ] **Step 3: Vérifier `tests/users.test.sqlite` inchangé**
Run: `git status --short tests/users.test.sqlite`
Expected: aucune sortie.
- [ ] **Step 4: Lancer la suite admin complète + suite globale**
Run: `uv run pytest tests/admin/ -v`
Expected: tous les tests passent (test_tables.py, test_guard.py, test_pages.py).
Run: `uv run pytest`
Expected: aucune régression sur le reste de la suite.
- [ ] **Step 5: Commit**
```bash
git add tests/admin/test_pages.py
git commit -m "test(admin): add end-to-end Selenium coverage for the generic table editor"
```
---
## Self-Review Notes
- **Spec coverage :** registre de tables + validation/coercition + audit → Task 1 ; page unique, callback de sélection/édition, suppression des pages/route obsolètes, nettoyage `setup.py`/`conftest.py` → Task 2 ; couverture Selenium (accès anonyme/non-admin, flux d'édition complet, audit consultable) → Task 3. `password_hash` jamais sélectionnée (Task 1, `get_rows`/`columns` du registre `users`). Colonnes jamais éditables (PK, timestamps, handles Frisbii, `user_id`) → absentes de `editable_columns` dans le registre (Task 1), revalidées côté serveur dans le callback (Task 2). Pas de tâche pour l'ajout/suppression de lignes ni pour d'autres tables — explicitement hors périmètre du spec.
- **Cohérence des types :** `TableConfig`, `TABLES`, `get_rows`, `set_cell`, `find_changed_cell` (Task 1) sont importés tels quels dans `liste.py` (Task 2) sans renommage. `TableConfig.target_user_id` est un `Callable[[dict], int | None]` dans les deux tâches.
- **Écart noté par rapport au libellé du spec** ("la cellule revient à son ancienne valeur" en cas d'échec) : le callback renvoie `no_update` plutôt que de réécrire activement l'ancienne valeur, pour éviter tout risque de boucle de déclenchement sur une prop qui est à la fois `Input` et `Output` du même callback. L'intégrité des données est préservée de façon identique (rien n'est écrit en base en cas d'échec) ; seul le retour visuel immédiat diffère (l'alerte rouge est explicite, la cellule garde la saisie invalide jusqu'au rechargement de la table). Documenté dans Task 2, Step 2.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,935 @@
# Cartes /acheteur et /titulaire : afficher la contrepartie Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Sur `/acheteurs/<id>` et `/titulaires/<id>`, remplacer la carte Plotly à point unique (basée sur l'annuaire) par une carte dash-leaflet clusterisée qui affiche l'organisme consulté **et** sa contrepartie (titulaires pour un acheteur, acheteurs pour un titulaire).
**Architecture:** Extraction d'une fonction partagée `build_org_markers` (déjà dupliquée en boucle dans `get_geographic_maps` d'`/observatoire`) puis nouvelle fonction `get_org_location_map` qui assemble deux couches `dl.GeoJSON` clusterisées (une par type d'organisme) avec cadrage automatique (`bounds`). Un nouveau callback par page, déclenché par l'URL et le filtre Année, appelle cette fonction ; l'ancien callback annuaire perd la responsabilité de la carte.
**Tech Stack:** Python, Polars, DuckDB, Dash, `dash-leaflet` (`dl`), `dash-leaflet.express` (`dlx`), `dash-extensions` (`Namespace`).
## Global Constraints
- Couleurs des marqueurs : acheteur `#E69F00` (orange), titulaire `#56B4E9` (bleu ciel) — identiques à `/observatoire`.
- La carte suit le filtre **Année** de la page (`acheteur_year`/`titulaire_year`), comme les tableaux Top titulaires/acheteurs.
- Cadrage automatique (`fitBounds`) sur l'organisme + toutes ses contreparties, avec padding léger (`boundsOptions={"padding": [30, 30], "maxZoom": 12}`).
- Repli sur une vue France fixe (`center=[46.6, 2.2]`, `zoom=5`) si aucune coordonnée exploitable n'est disponible (dataset sans colonnes longitude/latitude, ou organisme sans marché géolocalisé) — jamais d'exception.
- Pas de découpage par région/DOM-TOM ni de bascule chloroplèthe sur `/acheteur`/`/titulaire` (réservé à `/observatoire`).
- Les contrôles de zoom +/- natifs de `dl.Map` (Leaflet) doivent rester actifs — ne jamais passer `zoomControl=False`.
- Imports internes toujours via `src.` (ex. `from src.figures import ...`), jamais `figures.py` en import relatif nu.
- Lancer les tests avec `uv run pytest` (l'activation du venv via l'outil Bash n'est pas fiable dans cet environnement).
---
## File Structure
- **Modify `src/figures.py`** : ajoute `ORG_COLORS`, `build_org_markers`, `get_org_location_map` ; met à jour `get_geographic_maps` et `make_clusters_map` pour réutiliser `build_org_markers`/`ORG_COLORS` ; supprime `point_on_map` (devient mort après migration des deux pages).
- **Modify `src/pages/acheteur.py`** : nouveau callback `update_acheteur_map` ; `update_acheteur_infos` perd la construction de la carte.
- **Modify `src/pages/titulaire.py`** : nouveau callback `update_titulaire_map` ; `update_titulaire_infos` perd la construction de la carte.
- **Test `tests/test_figures.py`** : tests unitaires de `build_org_markers` et `get_org_location_map`.
---
### Task 1: Extraire `build_org_markers` et l'utiliser dans `get_geographic_maps`
**Files:**
- Modify: `src/figures.py:410-551` (fonction `get_geographic_maps`), `src/figures.py:582-633` (fonction `make_clusters_map`)
- Test: `tests/test_figures.py` (ajout en fin de fichier)
**Interfaces:**
- Produces: `ORG_COLORS: dict[str, str]` (clés `"acheteur"`/`"titulaire"`) ; `build_org_markers(lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]) -> list[dict]` où chaque `dict` a les clés `"lat"`, `"lon"`, `"tooltip"`, `"marker_color"`. Renvoie `[]` (sans exception) si les colonnes `{org_type}_longitude`/`{org_type}_latitude` sont absentes du `LazyFrame`.
- [ ] **Step 1: Écrire les tests (qui vont échouer, `build_org_markers` n'existe pas encore)**
Ajouter à la fin de `tests/test_figures.py` :
```python
def test_build_org_markers_groups_and_counts():
from src.figures import build_org_markers
lff = pl.LazyFrame(
[
{
"uid": "u1",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR A",
},
{
"uid": "u2",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR A",
},
{
"uid": "u3",
"acheteur_longitude": -1.68,
"acheteur_latitude": 48.11,
"acheteur_nom": "ACHETEUR B",
},
]
)
markers = build_org_markers(lff, "acheteur")
assert len(markers) == 2
marker_a = next(m for m in markers if m["tooltip"].startswith("ACHETEUR A"))
assert marker_a["tooltip"] == "ACHETEUR A (2 marchés)"
assert marker_a["lat"] == 48.85
assert marker_a["lon"] == 2.35
assert marker_a["marker_color"] == "#E69F00"
def test_build_org_markers_filters_null_coordinates():
from src.figures import build_org_markers
lff = pl.LazyFrame(
[
{
"uid": "u1",
"acheteur_longitude": None,
"acheteur_latitude": None,
"acheteur_nom": "ACHETEUR A",
},
{
"uid": "u2",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR B",
},
]
)
markers = build_org_markers(lff, "acheteur")
assert len(markers) == 1
assert markers[0]["tooltip"] == "ACHETEUR B (1 marchés)"
def test_build_org_markers_missing_columns_returns_empty():
from src.figures import build_org_markers
lff = pl.LazyFrame([{"uid": "u1", "acheteur_nom": "ACHETEUR A"}])
assert build_org_markers(lff, "acheteur") == []
assert build_org_markers(lff, "titulaire") == []
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/test_figures.py -k build_org_markers -v`
Expected: FAIL avec `ImportError: cannot import name 'build_org_markers'`
- [ ] **Step 3: Ajouter `ORG_COLORS` et `build_org_markers`, refactoriser `get_geographic_maps` et `make_clusters_map`**
Dans `src/figures.py`, insérer juste avant `def get_geographic_maps(dff: pl.DataFrame) -> list[dbc.Col] | list:` (ligne 410) :
```python
ORG_COLORS = {
"acheteur": "#E69F00", # orange
"titulaire": "#56B4E9", # bleu ciel
}
def build_org_markers(
lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]
) -> list[dict]:
"""Regroupe les marchés par point géographique pour un type d'organisme.
Renvoie [] (sans exception) si les colonnes longitude/latitude de ce
type sont absentes du LazyFrame (ex: tests/test.parquet).
"""
lon_col = f"{org_type}_longitude"
lat_col = f"{org_type}_latitude"
nom_col = f"{org_type}_nom"
available = set(lff.collect_schema().names())
if lon_col not in available or lat_col not in available:
return []
lff_org = (
lff.select("uid", lon_col, lat_col, nom_col)
.group_by(lon_col, lat_col, nom_col)
.len("nb_marches")
.filter(pl.col(lat_col).is_not_null() & pl.col(lon_col).is_not_null())
)
return [
{
"lat": row[lat_col],
"lon": row[lon_col],
"tooltip": f"{row[nom_col]} ({row['nb_marches']} marchés)",
"marker_color": ORG_COLORS[org_type],
}
for row in lff_org.collect().to_dicts()
]
```
Dans le corps de `get_geographic_maps` (fonction interne `make_map_data`), remplacer le bloc actuel :
```python
else:
_map_type: str = "clusters"
for org_type in ["acheteur", "titulaire"]:
lff_org = (
lff.select(
"uid",
f"{org_type}_longitude",
f"{org_type}_latitude",
f"{org_type}_nom",
)
.group_by(
f"{org_type}_longitude",
f"{org_type}_latitude",
f"{org_type}_nom",
)
.len("nb_marches")
.filter(
pl.col(f"{org_type}_latitude").is_not_null()
& pl.col(f"{org_type}_longitude").is_not_null()
)
)
markers = []
# Couleurs accessibles (Okabe-Ito)
colors = {
"acheteur": "#E69F00", # orange
"titulaire": "#56B4E9", # bleu ciel
}
for row in lff_org.collect().to_dicts():
markers.append(
{
"lat": row[f"{org_type}_latitude"],
"lon": row[f"{org_type}_longitude"],
"tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
"marker_color": colors[org_type],
}
)
dfs.append(markers)
```
par :
```python
else:
_map_type: str = "clusters"
for org_type in ["acheteur", "titulaire"]:
dfs.append(build_org_markers(lff, org_type))
```
Dans `make_clusters_map`, remplacer :
```python
# Couleurs
color_acheteur = region_acheteurs[0]["marker_color"]
color_titulaire = region_titulaires[0]["marker_color"]
```
par (évite un `IndexError` maintenant que `build_org_markers` peut renvoyer une liste vide) :
```python
# Couleurs
color_acheteur = ORG_COLORS["acheteur"]
color_titulaire = ORG_COLORS["titulaire"]
```
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
Run: `uv run pytest tests/test_figures.py -v`
Expected: PASS (tous les tests, y compris les 3 nouveaux)
- [ ] **Step 5: Lancer la suite complète pour vérifier l'absence de régression**
Run: `uv run pytest`
Expected: tous les tests passent (identique à avant la modification)
- [ ] **Step 6: Commit**
```bash
git add src/figures.py tests/test_figures.py
git commit -m "$(cat <<'EOF'
refactor(figures): extraire build_org_markers pour réutilisation
EOF
)"
```
---
### Task 2: Ajouter `get_org_location_map`
**Files:**
- Modify: `src/figures.py` (nouvelle fonction, à la suite de `make_clusters_map`, ligne 633)
- Test: `tests/test_figures.py`
**Interfaces:**
- Consumes: `ORG_COLORS`, `build_org_markers` (Task 1)
- Produces: `get_org_location_map(dff: pl.DataFrame, home_type: Literal["acheteur", "titulaire"], map_id: str) -> dl.Map`
- [ ] **Step 1: Écrire les tests (qui vont échouer, la fonction n'existe pas encore)**
Ajouter à la fin de `tests/test_figures.py` :
```python
def test_get_org_location_map_bounds_cover_home_and_counterpart():
import dash_leaflet as dl
from src.figures import get_org_location_map
dff = pl.DataFrame(
[
{
"uid": "u1",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR A",
"titulaire_longitude": -1.68,
"titulaire_latitude": 48.11,
"titulaire_nom": "TITULAIRE A",
}
]
)
leaflet_map = get_org_location_map(dff, "acheteur", "test-map")
assert isinstance(leaflet_map, dl.Map)
assert leaflet_map.bounds == [[48.11, -1.68], [48.85, 2.35]]
geojson_layers = [c for c in leaflet_map.children if isinstance(c, dl.GeoJSON)]
assert {layer.id for layer in geojson_layers} == {
"test-map-acheteur",
"test-map-titulaire",
}
def test_get_org_location_map_defaults_to_france_view_without_coordinates():
from src.figures import get_org_location_map
dff = pl.DataFrame(
[{"uid": "u1", "acheteur_nom": "ACHETEUR A", "titulaire_nom": "TITULAIRE A"}]
)
leaflet_map = get_org_location_map(dff, "acheteur", "test-map")
assert leaflet_map.center == [46.6, 2.2]
assert leaflet_map.zoom == 5
```
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
Run: `uv run pytest tests/test_figures.py -k get_org_location_map -v`
Expected: FAIL avec `ImportError: cannot import name 'get_org_location_map'`
- [ ] **Step 3: Implémenter `get_org_location_map`**
Dans `src/figures.py`, ajouter à la suite de `make_clusters_map` (après la ligne `return leaflet_map` qui la termine) :
```python
def get_org_location_map(
dff: pl.DataFrame,
home_type: Literal["acheteur", "titulaire"],
map_id: str,
) -> dl.Map:
"""Carte cluster (dash-leaflet) d'un organisme et de sa contrepartie.
Affiche les marchés de `home_type` (fiche /acheteur ou /titulaire
consultée) ainsi que ceux de son type complémentaire, clusterisés et
colorés comme sur /observatoire. Cadrage automatique (fitBounds) sur
l'ensemble des points ; repli sur une vue France fixe si aucun point
n'est disponible.
"""
counterpart_type: Literal["acheteur", "titulaire"] = (
"titulaire" if home_type == "acheteur" else "acheteur"
)
lff = dff.lazy()
markers_by_type = {
"acheteur": build_org_markers(lff, "acheteur"),
"titulaire": build_org_markers(lff, "titulaire"),
}
ns = Namespace("dash_clientside", "leaflet")
point_to_layer = ns("pointToLayer")
cluster_to_layer = ns("clusterToLayer")
layers: list = [dl.TileLayer()]
all_points: list[tuple[float, float]] = []
# Ordre fixe (titulaire puis acheteur) pour que l'organisme consulté
# soit toujours peint au-dessus de sa contrepartie, comme sur /observatoire.
for org_type in ("titulaire", "acheteur"):
markers = markers_by_type[org_type]
if not markers:
continue
all_points.extend((m["lat"], m["lon"]) for m in markers)
layers.append(
dl.GeoJSON(
data=dlx.dicts_to_geojson(markers),
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"{map_id}-{org_type}",
options={"fillColor": ORG_COLORS[org_type]},
)
)
map_kwargs: dict = {}
if all_points:
lats = [lat for lat, _ in all_points]
lons = [lon for _, lon in all_points]
map_kwargs["bounds"] = [[min(lats), min(lons)], [max(lats), max(lons)]]
map_kwargs["boundsOptions"] = {"padding": [30, 30], "maxZoom": 12}
else:
map_kwargs["center"] = [46.6, 2.2]
map_kwargs["zoom"] = 5
return dl.Map(
layers,
style={"width": "100%", "height": "300px"},
id=map_id,
**map_kwargs,
)
```
Note : `home_type` et `counterpart_type` ne sont pas utilisés pour changer le contenu affiché (les deux types sont toujours construits et affichés) — `home_type` sert uniquement à documenter l'intention de l'appelant et pourrait être utilisé plus tard pour un style différencié. Garder le paramètre tel quel (il est requis par l'appelant pour construire les colonnes de la requête, cf. Task 3/4).
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
Run: `uv run pytest tests/test_figures.py -v`
Expected: PASS (tous les tests, y compris les 2 nouveaux)
- [ ] **Step 5: Commit**
```bash
git add src/figures.py tests/test_figures.py
git commit -m "$(cat <<'EOF'
feat(figures): ajouter get_org_location_map (carte cluster organisme + contrepartie)
EOF
)"
```
---
### Task 3: Brancher la carte sur `/acheteurs/<id>`
**Files:**
- Modify: `src/pages/acheteur.py`
**Interfaces:**
- Consumes: `get_org_location_map` (Task 2), `_acheteur_scope(pathname, ach_year) -> tuple[str, list]` (déjà existant, `src/pages/acheteur.py:50`), `schema.names()`, `query_marches` (déjà importés)
- [ ] **Step 1: Mettre à jour l'import de `src.figures`**
Dans `src/pages/acheteur.py`, remplacer :
```python
from src.figures import (
DataTable,
get_distance_histogram,
get_top_org_table,
make_card,
make_column_picker,
point_on_map,
)
```
par :
```python
from src.figures import (
DataTable,
get_distance_histogram,
get_org_location_map,
get_top_org_table,
make_card,
make_column_picker,
)
```
- [ ] **Step 2: Retirer la construction de la carte de `update_acheteur_infos`**
Remplacer le callback existant (celui avec les `Output` `acheteur_siret`, `acheteur_nom`, `acheteur_commune`, `acheteur_map`, `acheteur_departement`, `acheteur_region`, `acheteur_lien_annuaire`) :
```python
@callback(
Output(component_id="acheteur_siret", component_property="children"),
Output(component_id="acheteur_nom", component_property="children"),
Output(component_id="acheteur_commune", component_property="children"),
Output(component_id="acheteur_map", component_property="children"),
Output(component_id="acheteur_departement", component_property="children"),
Output(component_id="acheteur_region", component_property="children"),
Output(component_id="acheteur_lien_annuaire", component_property="href"),
Input(component_id="acheteur_url", component_property="pathname"),
)
def update_acheteur_infos(url):
acheteur_siret = url.split("/")[-1]
# if len(acheteur_siret) != 14:
# acheteur_siret = (
# f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
# )
data = get_annuaire_data(acheteur_siret)
data_etablissement = data.get("matching_etablissements") if data else None
if data_etablissement:
data_etablissement = data_etablissement[0]
# Extraction du code département à partir du code postal
code_postal = data_etablissement.get("code_postal", "")
departement_code = code_postal[:2] if code_postal else None
# Création de la carte avec le code département pour un centrage approprié
acheteur_map = point_on_map(
data_etablissement["latitude"],
data_etablissement["longitude"],
departement_code,
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
)
raison_sociale = data["nom_raison_sociale"]
libelle_commune = data_etablissement["libelle_commune"]
else:
acheteur_map = html.Div()
code_departement, nom_departement, nom_region = "", "", ""
departement = ""
lien_annuaire = ""
raison_sociale = ""
libelle_commune = ""
return (
acheteur_siret,
raison_sociale,
libelle_commune,
acheteur_map,
departement,
nom_region,
lien_annuaire,
)
```
par :
```python
@callback(
Output(component_id="acheteur_siret", component_property="children"),
Output(component_id="acheteur_nom", component_property="children"),
Output(component_id="acheteur_commune", component_property="children"),
Output(component_id="acheteur_departement", component_property="children"),
Output(component_id="acheteur_region", component_property="children"),
Output(component_id="acheteur_lien_annuaire", component_property="href"),
Input(component_id="acheteur_url", component_property="pathname"),
)
def update_acheteur_infos(url):
acheteur_siret = url.split("/")[-1]
data = get_annuaire_data(acheteur_siret)
data_etablissement = data.get("matching_etablissements") if data else None
if data_etablissement:
data_etablissement = data_etablissement[0]
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
)
raison_sociale = data["nom_raison_sociale"]
libelle_commune = data_etablissement["libelle_commune"]
else:
code_departement, nom_departement, nom_region = "", "", ""
departement = ""
lien_annuaire = ""
raison_sociale = ""
libelle_commune = ""
return (
acheteur_siret,
raison_sociale,
libelle_commune,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="acheteur_map", component_property="children"),
Input(component_id="acheteur_url", component_property="pathname"),
Input(component_id="acheteur_year", component_property="value"),
)
def update_acheteur_map(pathname, ach_year):
where_sql, params = _acheteur_scope(pathname, ach_year)
geo_columns = [
col
for col in [
"uid",
"acheteur_longitude",
"acheteur_latitude",
"acheteur_nom",
"titulaire_longitude",
"titulaire_latitude",
"titulaire_nom",
]
if col in schema.names()
]
dff = query_marches(where_sql, params, columns=geo_columns)
return get_org_location_map(dff, "acheteur", "acheteur_map_leaflet")
```
- [ ] **Step 3: Vérifier qu'il n'y a plus de référence à `point_on_map` dans le fichier**
Run: `grep -n "point_on_map" src/pages/acheteur.py`
Expected: aucune sortie
- [ ] **Step 4: Lancer la suite de tests complète**
Run: `uv run pytest`
Expected: tous les tests passent
- [ ] **Step 5: Vérification manuelle dans le navigateur**
Run: `python run.py` (dans un terminal séparé, avec le `.venv` activé et un `.env` pointant vers un jeu de données de production contenant des colonnes longitude/latitude)
Naviguer vers `/acheteurs/<un-siret-existant>` :
- La carte doit afficher des marqueurs orange (acheteur) et bleus (titulaires), clusterisés.
- Les boutons de zoom +/- doivent être visibles sur la carte.
- Changer le filtre Année doit mettre à jour la carte.
Arrêter le serveur (`Ctrl+C`) une fois la vérification faite.
- [ ] **Step 6: Commit**
```bash
git add src/pages/acheteur.py
git commit -m "$(cat <<'EOF'
feat(acheteur): afficher les titulaires sur la carte de la fiche acheteur
EOF
)"
```
---
### Task 4: Brancher la carte sur `/titulaires/<id>` et supprimer `point_on_map`
**Files:**
- Modify: `src/pages/titulaire.py`
- Modify: `src/figures.py:184-254` (suppression de `point_on_map`)
**Interfaces:**
- Consumes: `get_org_location_map` (Task 2), `_titulaire_scope(pathname, titulaire_year) -> tuple[str, list]` (déjà existant, `src/pages/titulaire.py:49`)
- [ ] **Step 1: Mettre à jour l'import de `src.figures`**
Dans `src/pages/titulaire.py`, remplacer :
```python
from src.figures import (
DataTable,
get_distance_histogram,
get_top_org_table,
make_column_picker,
point_on_map,
)
```
par :
```python
from src.figures import (
DataTable,
get_distance_histogram,
get_org_location_map,
get_top_org_table,
make_column_picker,
)
```
- [ ] **Step 2: Retirer la construction de la carte de `update_titulaire_infos`**
Remplacer le callback existant (celui avec les `Output` `titulaire_siret`, `titulaire_nom`, `titulaire_commune`, `titulaire_map`, `titulaire_departement`, `titulaire_region`, `titulaire_lien_annuaire`, `titulaire_activite_libelle`) :
```python
@callback(
Output(component_id="titulaire_siret", component_property="children"),
Output(component_id="titulaire_nom", component_property="children"),
Output(component_id="titulaire_commune", component_property="children"),
Output(component_id="titulaire_map", component_property="children"),
Output(component_id="titulaire_departement", component_property="children"),
Output(component_id="titulaire_region", component_property="children"),
Output(component_id="titulaire_lien_annuaire", component_property="href"),
Output(component_id="titulaire_activite_libelle", component_property="children"),
Input(component_id="titulaire_url", component_property="pathname"),
)
def update_titulaire_infos(url):
titulaire_siret = url.split("/")[-1]
if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
activite_libelle_row = DF_TITULAIRES.filter(
pl.col("titulaire_id") == titulaire_siret
).select("titulaire_activite_libelle")
activite_libelle = (
activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
)
else:
activite_libelle = ""
data = get_annuaire_data(titulaire_siret)
data_etablissement = data.get("matching_etablissements") if data else None
if data_etablissement:
data_etablissement = data_etablissement[0]
# Extraction du code département à partir du code postal
code_postal = data_etablissement.get("code_postal", "")
departement_code = code_postal[:2] if code_postal else None
# Création de la carte avec le code département pour un centrage approprié
titulaire_map = point_on_map(
data_etablissement["latitude"],
data_etablissement["longitude"],
departement_code,
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
)
raison_sociale = data["nom_raison_sociale"]
libelle_commune = data_etablissement["libelle_commune"]
else:
titulaire_map = html.Div()
code_departement, nom_departement, nom_region = "", "", ""
departement = ""
lien_annuaire = ""
raison_sociale = html.Span(
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
)
libelle_commune = ""
return (
titulaire_siret,
raison_sociale,
libelle_commune,
titulaire_map,
departement,
nom_region,
lien_annuaire,
activite_libelle,
)
```
par :
```python
@callback(
Output(component_id="titulaire_siret", component_property="children"),
Output(component_id="titulaire_nom", component_property="children"),
Output(component_id="titulaire_commune", component_property="children"),
Output(component_id="titulaire_departement", component_property="children"),
Output(component_id="titulaire_region", component_property="children"),
Output(component_id="titulaire_lien_annuaire", component_property="href"),
Output(component_id="titulaire_activite_libelle", component_property="children"),
Input(component_id="titulaire_url", component_property="pathname"),
)
def update_titulaire_infos(url):
titulaire_siret = url.split("/")[-1]
if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
activite_libelle_row = DF_TITULAIRES.filter(
pl.col("titulaire_id") == titulaire_siret
).select("titulaire_activite_libelle")
activite_libelle = (
activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
)
else:
activite_libelle = ""
data = get_annuaire_data(titulaire_siret)
data_etablissement = data.get("matching_etablissements") if data else None
if data_etablissement:
data_etablissement = data_etablissement[0]
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
)
raison_sociale = data["nom_raison_sociale"]
libelle_commune = data_etablissement["libelle_commune"]
else:
code_departement, nom_departement, nom_region = "", "", ""
departement = ""
lien_annuaire = ""
raison_sociale = html.Span(
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
)
libelle_commune = ""
return (
titulaire_siret,
raison_sociale,
libelle_commune,
departement,
nom_region,
lien_annuaire,
activite_libelle,
)
@callback(
Output(component_id="titulaire_map", component_property="children"),
Input(component_id="titulaire_url", component_property="pathname"),
Input(component_id="titulaire_year", component_property="value"),
)
def update_titulaire_map(pathname, titulaire_year):
where_sql, params = _titulaire_scope(pathname, titulaire_year)
geo_columns = [
col
for col in [
"uid",
"acheteur_longitude",
"acheteur_latitude",
"acheteur_nom",
"titulaire_longitude",
"titulaire_latitude",
"titulaire_nom",
]
if col in schema.names()
]
dff = query_marches(where_sql, params, columns=geo_columns)
return get_org_location_map(dff, "titulaire", "titulaire_map_leaflet")
```
- [ ] **Step 3: Supprimer `point_on_map` (devenue inutilisée) de `src/figures.py`**
Vérifier d'abord qu'il n'y a plus aucun appelant :
Run: `grep -rn "point_on_map" src/`
Expected: aucune sortie
Puis supprimer la fonction complète dans `src/figures.py` (actuellement lignes 184-254, entre `def point_on_map(lat, lon, departement_code=None):` et le `return html.Div(...)` qui la clôt, juste avant `class DataTable(dash_table.DataTable):`) :
```python
def point_on_map(lat, lon, departement_code=None):
"""Fonction améliorée utilisant les codes départementaux pour la détection de région.
Args:
lat: Coordonnée de latitude
lon: Coordonnée de longitude
departement_code: Code du département (ex: '75', '971', etc.)
Returns:
html.Div contenant la carte, ou div vide si invalide
"""
# Validation des coordonnées
try:
lat = float(lat)
lon = float(lon)
except (TypeError, ValueError):
return html.Div() # Div vide pour les coordonnées invalides
# Vérification que les coordonnées sont valides
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return html.Div()
# Si aucun code département n'est fourni, retourner une div vide
if not departement_code:
return html.Div()
# Détermination de la région en utilisant le code département
# Logique identique à get_geographic_maps
if departement_code in ["971", "972", "973", "974", "976"]:
region_key = departement_code # Département d'outre-mer
elif len(departement_code) == 2: # Département métropolitain
region_key = "Hexagone"
else:
return html.Div() # Format de code département invalide
# Paramètres de carte par région (réutilisés de get_geographic_maps)
regions = {
"Hexagone": {"center": [46.6, 2.2], "zoom": 5},
"971": {"center": [16.23, -61.55], "zoom": 9}, # Guadeloupe
"972": {"center": [14.64, -61.02], "zoom": 10}, # Martinique
"973": {"center": [3.93, -53.12], "zoom": 7}, # Guyane
"974": {"center": [-21.11, 55.53], "zoom": 9}, # La Réunion
"976": {"center": [-12.82, 45.16], "zoom": 10}, # Mayotte
}
settings = regions.get(region_key, regions["Hexagone"])
# Création de la carte
fig = px.scatter_map(
lat=[lat],
lon=[lon],
height=300,
# width=400,
color=[1],
zoom=settings["zoom"],
)
fig.update_traces(marker=dict(size=10))
# Configuration de la carte (interactive - zoomable)
fig.update_layout(
map_style="light", # Fond de carte clair
margin={"r": 0, "t": 0, "l": 0, "b": 0},
mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]},
mapbox_zoom=settings["zoom"],
coloraxis_showscale=False,
)
return html.Div(
dcc.Graph(figure=fig, config={"displayModeBar": False}),
)
```
Supprimer l'intégralité de ce bloc (la ligne vide qui le sépare de `class DataTable` peut rester telle quelle).
- [ ] **Step 4: Lancer la suite de tests complète**
Run: `uv run pytest`
Expected: tous les tests passent
- [ ] **Step 5: Vérification manuelle dans le navigateur**
Run: `python run.py` (avec un jeu de données de production contenant des colonnes longitude/latitude)
Naviguer vers `/titulaires/<un-siret-existant>` :
- La carte doit afficher un marqueur bleu (titulaire) et des marqueurs orange (acheteurs), clusterisés.
- Les boutons de zoom +/- doivent être visibles.
- Changer le filtre Année doit mettre à jour la carte.
Arrêter le serveur (`Ctrl+C`) une fois la vérification faite.
- [ ] **Step 6: Commit**
```bash
git add src/pages/titulaire.py src/figures.py
git commit -m "$(cat <<'EOF'
feat(titulaire): afficher les acheteurs sur la carte de la fiche titulaire
Supprime point_on_map, devenue inutilisée après migration des deux pages
vers get_org_location_map.
EOF
)"
```
@@ -0,0 +1,849 @@
# Refonte du tunnel d'abonnement — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rendre l'offre d'abonnement visible aux visiteurs non connectés et raccourcir le tunnel d'inscription/abonnement.
**Architecture:** Les composants « cards » d'abonnement quittent la page authentifiée `/compte/abonnement` pour la page publique `/a-propos/abonnement`, complétés d'un unique bouton « Je m'abonne » dont la cible dépend de l'état de connexion. Le choix du plan (simple/soutien) migre dans le formulaire `mes-infos`. La validation d'email connecte automatiquement l'utilisateur et le dépose dans `mes-infos`.
**Tech Stack:** Dash 3.4, Dash Bootstrap Components, Flask + Flask-Login, SQLite (users.sqlite), pytest.
## Global Constraints
- Interface et libellés **en français**.
- Imports du code applicatif toujours préfixés `src.` (ex. `src.pages.compte.abonnement`).
- Lancer les tests avec `uv run pytest` (l'activation de venv via Bash n'est pas fiable ici).
- **Soumission de formulaire native** : seul un `dcc.Input`/`dbc.Input` portant un
attribut `name` est soumis dans le POST d'un `html.Form`. Une interaction Dash
contrôlée (clic sur carte, `RadioItems`…) n'est **pas** soumise nativement — la
relier à un `dcc.Input(type="hidden", name=...)` via callback. `html.Input` n'existe
pas en Dash 3.4.
- Boutons : classes Bootstrap existantes (`btn btn-primary`, `btn btn-outline-danger`…), cohérentes avec le reste du site.
- Chaque commit doit passer `uv run pytest` (au minimum les tests touchés).
---
### Task 1 : `linkedin_button` paramétrable + inscription vers le tunnel
Le bouton LinkedIn est réutilisé par `/connexion` et `/inscription`. On lui ajoute un
paramètre `next_url` optionnel pour que l'inscription via LinkedIn finisse dans
`mes-infos` (au lieu du fallback `/compte/admin`).
**Files:**
- Modify: `src/pages/connexion.py` (fonction `linkedin_button`, lignes 31-37)
- Modify: `src/pages/inscription.py` (appel `linkedin_button()`, ligne 73)
- Test: `tests/test_linkedin_button.py` (créer)
**Interfaces:**
- Produces: `linkedin_button(next_url: str | None = None) -> html.A`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/test_linkedin_button.py
def test_linkedin_button_default_has_no_next():
from src.pages.connexion import linkedin_button
html_str = str(linkedin_button())
assert "href='/auth/linkedin'" in html_str
assert "next=" not in html_str
def test_linkedin_button_with_next_appends_query():
from src.pages.connexion import linkedin_button
html_str = str(linkedin_button("/compte/abonnement/mes-infos"))
assert "/auth/linkedin?next=/compte/abonnement/mes-infos" in html_str
def test_inscription_linkedin_targets_mes_infos():
from src.pages import inscription
assert "/auth/linkedin?next=/compte/abonnement/mes-infos" in str(
inscription.layout()
)
```
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
Run: `uv run pytest tests/test_linkedin_button.py -v`
Expected: FAIL (`linkedin_button()` prend 0 argument / le `next` n'est pas présent)
- [ ] **Step 3 : Implémenter**
Dans `src/pages/connexion.py`, remplacer la fonction `linkedin_button` :
```python
def linkedin_button(next_url: str | None = None):
href = "/auth/linkedin"
if next_url:
href += f"?next={next_url}"
return html.A(
"Connexion avec LinkedIn",
href=href,
className="btn w-100 mb-2",
style={"backgroundColor": "rgb(10, 102, 194)", "color": "white"},
)
```
Dans `src/pages/inscription.py`, ligne 73, remplacer `linkedin_button(),` par :
```python
linkedin_button("/compte/abonnement/mes-infos"),
```
- [ ] **Step 4 : Lancer le test pour vérifier le succès**
Run: `uv run pytest tests/test_linkedin_button.py -v`
Expected: PASS (3 tests)
- [ ] **Step 5 : Commit**
```bash
git add src/pages/connexion.py src/pages/inscription.py tests/test_linkedin_button.py
git commit -m "feat(abonnement): linkedin_button paramétrable par next, inscription vers mes-infos"
```
---
### Task 2 : Auto-login à la validation d'email
`verify_email` doit connecter automatiquement l'utilisateur et le rediriger vers
`mes-infos` au lieu de `/connexion?verified=1`.
**Files:**
- Modify: `src/auth/routes.py` (fonction `verify_email`, lignes 92-101)
- Test: `tests/auth/test_verify_email.py` (modifier le test du token valide)
**Interfaces:**
- Consumes: `db.get_user_by_id`, `db.set_email_verified`, `tokens.consume_verification_token`, `User`, `login_user` (déjà importés dans le module).
- [ ] **Step 1 : Adapter le test pour qu'il échoue**
Dans `tests/auth/test_verify_email.py`, remplacer `test_verify_email_valid_token_marks_user_verified` par :
```python
def test_verify_email_valid_token_logs_in_and_redirects_to_mes_infos(
client, users_db_path
):
db.init_schema()
uid = db.create_user("a@b.c", "hash")
token = tokens.create_verification_token(uid)
resp = client.get(f"/auth/verify-email?token={token}")
assert resp.status_code == 302
assert "/compte/abonnement/mes-infos" in resp.headers["Location"]
assert db.get_user_by_id(uid)["email_verified"] == 1
with client.session_transaction() as sess:
assert sess.get("_user_id") == str(uid)
```
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
Run: `uv run pytest tests/auth/test_verify_email.py -v`
Expected: FAIL (Location vaut encore `/connexion?verified=1`, session vide)
- [ ] **Step 3 : Implémenter**
Dans `src/auth/routes.py`, dans `verify_email`, remplacer les deux dernières lignes
(`db.set_email_verified(user_id)` + `return redirect("/connexion?verified=1")`) par :
```python
db.set_email_verified(user_id)
login_user(User(db.get_user_by_id(user_id)), remember=True)
return redirect("/compte/abonnement/mes-infos")
```
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/auth/test_verify_email.py -v`
Expected: PASS (4 tests — les 3 autres restent inchangés)
- [ ] **Step 5 : Commit**
```bash
git add src/auth/routes.py tests/auth/test_verify_email.py
git commit -m "feat(auth): auto-login à la validation d'email, redirection vers mes-infos"
```
---
### Task 3 : Page publique `/a-propos/abonnement` (cards + bouton « Je m'abonne »)
On installe dans la page publique : les cards informatives (sans bouton par card),
l'explainer, et le bouton unique conditionnel. On retire la sous-section
« Fonctionnalités incluses » des CGU (doublon avec l'explainer).
**Files:**
- Modify: `src/pages/a_propos/abonnement.py`
- Test: `tests/test_abonnement_public.py` (créer)
**Interfaces:**
- Produces:
- `_plan_card(meta: dict, trial: int | None) -> dbc.Card`
- `_plan_cards(trial_for=plans.trial_days) -> dbc.Row`
- `_explainer() -> dbc.Row`
- `_subscribe_button(authenticated: bool, has_active_subscription: bool, tous_abonnes: bool) -> html.Div`
- `abonnement_features` et `subscription_terms` restent exportés (déjà consommés par `compte/abonnement_mes_infos.py`).
- [ ] **Step 1 : Écrire les tests qui échouent**
```python
# tests/test_abonnement_public.py
import pytest
@pytest.fixture(autouse=True)
def _plan_env(monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
def test_plan_cards_informative_no_button():
from src.pages.a_propos import abonnement as page
text = str(page._plan_cards(trial_for=lambda key: 2))
assert "Abonnement" in text
assert "Abonnement de soutien" in text
assert "2 jours d'essai gratuit" in text
assert "S'abonner" not in text
def test_subscribe_button_visitor_goes_to_inscription():
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(False, False, False))
assert "Je m'abonne" in text
assert "href='/inscription'" in text
def test_subscribe_button_authenticated_no_sub_goes_to_mes_infos():
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(True, False, False))
assert "href='/compte/abonnement/mes-infos'" in text
def test_subscribe_button_active_sub_manages():
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(True, True, False))
assert "Gérer mon abonnement" in text
assert "href='/compte/abonnement'" in text
def test_subscribe_button_disabled_when_tous_abonnes():
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(False, False, True))
assert "disabled" in text
def test_cgu_terms_trimmed_of_features_section():
from src.pages.a_propos import abonnement as page
text = str(page.subscription_terms)
assert "Fonctionnalités incluses" not in text
assert "Résiliation" in text
assert "Tarifs" in text
```
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
Run: `uv run pytest tests/test_abonnement_public.py -v`
Expected: FAIL (`_plan_cards`/`_subscribe_button` inexistants ; « Fonctionnalités incluses » encore présent)
- [ ] **Step 3 : Implémenter**
Dans `src/pages/a_propos/abonnement.py` :
3a. Ajouter les imports en tête :
```python
import dash_bootstrap_components as dbc
from dash import dcc, html, register_page
from flask_login import current_user
from src.pages._apropos_shell import apropos_shell
from src.subscriptions import db as sub_db
from src.subscriptions import plans
from src.utils import TOUS_ABONNES
from src.utils.seo import META_CONTENT
```
3b. Ajouter les composants cards (avant `subscription_terms`) :
```python
def _plan_card(meta: dict, trial: int | None):
badge = (
html.Div(f"{trial} jours d'essai gratuit", className="mb-3") if trial else None
)
return dbc.Card(
dbc.CardBody(
[
html.H4(meta["label"], className="mb-1"),
html.P(
f"{meta['prix_ht']} € HT / mois "
f"({str(int(meta['prix_ht']) * 1.2).replace('.0', '')} € TTC)",
className="text-muted mb-3",
),
html.P(meta["description"], className="mb-3"),
badge,
],
className="p-4",
),
className="h-100",
)
def _plan_cards(trial_for=plans.trial_days):
cards = []
for key in ("simple", "soutien"):
meta = plans.plan_meta(key)
if meta:
cards.append(_plan_card(meta, trial_for(key)))
return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-4")
def _explainer():
col_left = dbc.Col(
[html.H4("Fonctionnalités réservées aux abonné·es :"), abonnement_features],
md=6,
style={
"borderRight": "1px solid var(--bs-border-color)",
"paddingRight": "2rem",
},
)
col_right = dbc.Col(
[
html.H4("Ce que les abonnements permettent"),
html.Ul(
[
html.Li(
"passer plus de temps à développer colibre et moins de temps "
"à chercher des missions"
),
html.Li(
"rédaction d'études à partir des données, par exemple sur les "
"acheteurs dont les données sont introuvables et les raisons "
"de cette non-publication."
),
html.Li(
"fédération des bonnes volontés souhaitant militer pour une "
"législation plus ambitieuse sur la transparence de la "
"commande publique."
),
]
),
],
md=6,
style={"paddingLeft": "2rem"},
)
return dbc.Row([col_left, col_right], className="align-items-start pt-2 mb-4")
def _subscribe_button(
authenticated: bool, has_active_subscription: bool, tous_abonnes: bool
):
if tous_abonnes:
return html.Div(
[
dbc.Alert(
"Les fonctionnalités normalement accessibles contre un abonnement "
"de 20 € HT par mois sont accessibles à tous et toutes en attendant "
"la validation de mon dossier pour recevoir des paiements.",
color="info",
),
html.A(
"Je m'abonne",
href="#",
className="btn btn-secondary disabled",
),
],
className="text-center my-4",
)
if authenticated and has_active_subscription:
label, href = "Gérer mon abonnement", "/compte/abonnement"
elif authenticated:
label, href = "Je m'abonne", "/compte/abonnement/mes-infos"
else:
label, href = "Je m'abonne", "/inscription"
return html.Div(
html.A(label, href=href, className="btn btn-primary btn-lg"),
className="text-center my-4",
)
```
3c. Dans `subscription_terms`, **supprimer** le bloc « Fonctionnalités incluses »
(le `html.H4("Fonctionnalités incluses")`, les deux `dcc.Markdown` qui l'entourent
et l'insertion de `abonnement_features`). Garder `abonnement_features` **défini** au
niveau module (il sert à `_explainer`). Le reste des CGU est inchangé.
3d. Remplacer `layout` :
```python
def layout(**_):
authenticated = current_user.is_authenticated
has_active = authenticated and sub_db.has_active_subscription(current_user.id)
body = html.Div(
[
_plan_cards(),
_explainer(),
_subscribe_button(authenticated, has_active, TOUS_ABONNES),
subscription_terms,
]
)
return apropos_shell("abonnement", body)
```
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/test_abonnement_public.py -v`
Expected: PASS (6 tests)
- [ ] **Step 5 : Commit**
```bash
git add src/pages/a_propos/abonnement.py tests/test_abonnement_public.py
git commit -m "feat(abonnement): page publique avec cards et bouton Je m'abonne conditionnel"
```
---
### Task 4 : `/compte/abonnement` non-abonné → bouton « M'abonner » / « Me réabonner »
La branche non-abonné n'affiche plus de cards mais un bouton dont le libellé dépend de
`has_used_trial`. On supprime les composants cards devenus inutilisés ici et on migre
les tests obsolètes.
**Files:**
- Modify: `src/pages/compte/abonnement.py`
- Modify: `tests/subscriptions/test_compte_abonnement.py`
**Interfaces:**
- Consumes: `db.has_used_trial(user_id) -> bool` (existant).
- Produces: `_reabo_button(has_used_trial: bool) -> html.Div`
- [ ] **Step 1 : Écrire / migrer les tests**
Dans `tests/subscriptions/test_compte_abonnement.py`, **supprimer** ces 4 tests
(devenus obsolètes : les cards et leurs boutons ont quitté ce module) :
`test_plan_cards_present_when_no_subscription`, `test_plan_cards_no_trial_when_trial_used`,
`test_subscribe_buttons_disabled_when_tous_abonnes`, `test_subscribe_buttons_active_when_flag_off`.
**Ajouter** :
```python
def test_reabo_button_never_subscribed():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._reabo_button(has_used_trial=False))
assert "M'abonner" in text
assert "Me réabonner" not in text
assert "href='/a-propos/abonnement'" in text
def test_reabo_button_former_subscriber():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._reabo_button(has_used_trial=True))
assert "Me réabonner" in text
assert "href='/a-propos/abonnement'" in text
```
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
Expected: FAIL (`_reabo_button` inexistant)
- [ ] **Step 3 : Implémenter**
Dans `src/pages/compte/abonnement.py` :
3a. Supprimer l'import `from src.pages.a_propos.abonnement import abonnement_features`.
3b. Supprimer les fonctions `_plan_card`, `_plan_cards`, `_explainer` (devenues
inutilisées ici — elles vivent désormais dans `a_propos/abonnement.py`).
3c. Ajouter :
```python
def _reabo_button(has_used_trial: bool):
label = "Me réabonner" if has_used_trial else "M'abonner"
return html.Div(
[
html.P(
"Abonnez-vous pour accéder aux fonctionnalités réservées "
"aux abonné·es.",
className="mb-3",
),
html.A(
label,
href="/a-propos/abonnement",
className="btn btn-primary",
),
],
className="mb-4",
)
```
3d. Dans `layout`, la branche `else` (celle qui faisait
`body.extend([_plan_cards(trial_used=trial_used), _explainer()])`) devient :
```python
else:
if row is not None and row["status"] == "expired":
body.append(
dbc.Alert(
"Votre abonnement a expiré.", color="warning", className="mb-4"
)
)
body.append(_reabo_button(db.has_used_trial(current_user.id)))
```
La ligne `trial_used = db.has_used_trial(...)` en tête de `layout` n'est plus utilisée
que… nulle part → **la supprimer** (lignes ~290-292 : la variable `trial_used`).
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
Expected: PASS (les tests `_active_view`, `_tous_abonnes_banner`, `_show_active_view` + les 2 nouveaux)
- [ ] **Step 5 : Vérifier l'absence de régression d'import**
Run: `uv run pytest tests/test_compte_pages.py tests/subscriptions/ -v`
Expected: PASS (aucun module ne référence plus `compte.abonnement._plan_cards`)
- [ ] **Step 6 : Commit**
```bash
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
git commit -m "feat(abonnement): /compte/abonnement non-abonné affiche M'abonner/Me réabonner"
```
---
### Task 5 : CTA de `/connexion` vers l'offre d'abonnement
Le bouton du bas de `/connexion` pointe vers `/a-propos/abonnement` au lieu de
`/inscription`.
**Files:**
- Modify: `src/pages/connexion.py` (bloc final du `layout`, lignes 91-96)
- Test: `tests/test_connexion_page.py` (créer)
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/test_connexion_page.py
def test_connexion_cta_points_to_abonnement():
from src.pages import connexion
text = str(connexion.layout())
assert "/a-propos/abonnement" in text
assert "Voir les abonnements" in text
```
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
Run: `uv run pytest tests/test_connexion_page.py -v`
Expected: FAIL (le CTA pointe encore vers `/inscription`)
- [ ] **Step 3 : Implémenter**
Dans `src/pages/connexion.py`, remplacer le dernier bloc du `layout` (le
`html.A("Créer un compte avec mon adresse email", href="/inscription", ...)`) par :
```python
html.A(
"Pas encore de compte ? Voir les abonnements",
href="/a-propos/abonnement",
className="btn btn-primary w-100",
),
```
- [ ] **Step 4 : Lancer le test pour vérifier le succès**
Run: `uv run pytest tests/test_connexion_page.py -v`
Expected: PASS
- [ ] **Step 5 : Commit**
```bash
git add src/pages/connexion.py tests/test_connexion_page.py
git commit -m "feat(abonnement): CTA connexion vers /a-propos/abonnement"
```
---
### Task 6 : Sélection de la formule par cartes cliquables dans `mes-infos`
Le formulaire `mes-infos` n'exige plus de paramètre `?plan=`. La formule se choisit en
cliquant l'une des deux cartes (réutilisées de la page publique). Aucune sélection par
défaut : un texte invite l'utilisateur ; la carte choisie prend un fond teinté. Un
callback recopie la valeur dans un champ caché natif soumis dans le POST.
**Files:**
- Modify: `src/pages/compte/abonnement_mes_infos.py`
- Modify: `src/assets/css/style.css`
- Test: `tests/subscriptions/test_mes_infos_plan.py` (créer)
**Interfaces:**
- Consumes: `_plan_card` (Task 3, depuis `src.pages.a_propos.abonnement`),
`plans.plan_meta`, `plans.trial_days`, `sub_db.has_used_trial`.
- Produces:
- `_selectable_cards(trial_for) -> dbc.Row`
- `_selection_state(selected: str) -> tuple[str, str, str, str]` (valeur cachée, classe carte simple, classe carte soutien, classe invite) — pur, testable
- callback `_select_plan` (déclenché par `n_clicks` des cartes via `ctx.triggered_id`)
- `_toggle_submit(retractation, cgu, plan) -> bool` (désactive tant qu'une case ou la formule manque)
- [ ] **Step 1 : Écrire les tests qui échouent**
```python
# tests/subscriptions/test_mes_infos_plan.py
import pytest
@pytest.fixture(autouse=True)
def _plan_env(monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
def test_selectable_cards_render_both_plans():
from src.pages.compte import abonnement_mes_infos as m
text = str(m._selectable_cards(trial_for=lambda key: 2))
assert "plan-card-simple" in text
assert "plan-card-soutien" in text
assert "plan-selectable" in text
assert "Abonnement de soutien" in text
def test_selection_state_simple():
from src.pages.compte import abonnement_mes_infos as m
value, cls_simple, cls_soutien, cls_invite = m._selection_state("simple")
assert value == "simple"
assert "selected" in cls_simple
assert "selected" not in cls_soutien
assert cls_invite == "d-none"
def test_selection_state_soutien():
from src.pages.compte import abonnement_mes_infos as m
value, cls_simple, cls_soutien, _ = m._selection_state("soutien")
assert value == "soutien"
assert "selected" in cls_soutien
assert "selected" not in cls_simple
def test_submit_disabled_without_plan():
from src.pages.compte import abonnement_mes_infos as m
assert m._toggle_submit(["ok"], ["ok"], "") is True
assert m._toggle_submit(["ok"], ["ok"], "simple") is False
assert m._toggle_submit([], ["ok"], "simple") is True
```
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
Expected: FAIL (`_selectable_cards` / `_selection_state` inexistants)
- [ ] **Step 3 : Implémenter**
3a. Dans `src/pages/compte/abonnement_mes_infos.py`, compléter les imports :
ajouter `ctx` à l'import `from dash import ...`, ajouter `_plan_card` à l'import
existant depuis `src.pages.a_propos.abonnement`, et ajouter :
```python
from src.subscriptions import db as sub_db
from src.subscriptions import plans
```
3b. Ajouter les helpers + callback de sélection (par ex. juste après `_csrf_input`) :
```python
def _trial_for(user_id):
used = sub_db.has_used_trial(user_id)
return lambda key: None if used else plans.trial_days(key)
def _selectable_cards(trial_for):
cols = []
for key in ("simple", "soutien"):
meta = plans.plan_meta(key)
if not meta:
continue
cols.append(
dbc.Col(
html.Div(
_plan_card(meta, trial_for(key)),
id=f"plan-card-{key}",
n_clicks=0,
className="plan-selectable",
),
md=6,
)
)
return dbc.Row(cols, className="g-4 mb-2")
def _selection_state(selected):
base = "plan-selectable"
return (
selected,
f"{base} selected" if selected == "simple" else base,
f"{base} selected" if selected == "soutien" else base,
"d-none",
)
@callback(
Output("inf-plan-hidden", "value"),
Output("plan-card-simple", "className"),
Output("plan-card-soutien", "className"),
Output("inf-plan-invite", "className"),
Input("plan-card-simple", "n_clicks"),
Input("plan-card-soutien", "n_clicks"),
prevent_initial_call=True,
)
def _select_plan(_n_simple, _n_soutien):
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
return _selection_state(selected)
```
3c. Modifier le callback `_toggle_submit` existant pour exiger aussi une formule :
```python
@callback(
Output("inf-submit", "disabled"),
Input("inf-cb-retractation", "value"),
Input("inf-cb-cgu", "value"),
Input("inf-plan-hidden", "value"),
)
def _toggle_submit(retractation, cgu, plan):
return not (retractation and cgu and plan)
```
3d. Dans `layout`, **supprimer** la garde `?plan=` (lignes 53-55) :
```python
plan = query.get("plan", "")
if not plan:
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
```
3e. Dans le `html.Form` (`children=`), **remplacer** la ligne
`dcc.Input(type="hidden", name="plan", value=plan),` par l'invite, les cartes
sélectionnables et le champ caché vide :
```python
children=[
_csrf_input(),
html.Div(
"Choisissez votre formule :",
id="inf-plan-invite",
className="fw-bold mb-2",
),
_selectable_cards(_trial_for(current_user.id)),
dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value=""),
dbc.Row([col1, col2], className="g-4 mb-4"),
checkboxes,
html.Button(
"Ajout d'une carte de paiement",
id="inf-submit",
type="submit",
className="btn btn-primary",
disabled=True,
),
],
```
3f. Ajouter à la fin de `src/assets/css/style.css` :
```css
.plan-selectable {
cursor: pointer;
}
.plan-selectable .card {
transition: background-color 0.15s, border-color 0.15s;
}
.plan-selectable.selected .card {
background-color: var(--bs-primary-bg-subtle);
border-color: var(--bs-primary);
}
```
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
Expected: PASS (4 tests)
- [ ] **Step 5 : Vérifier qu'aucun test existant ne dépendait de la garde `?plan=`**
Run: `uv run pytest tests/subscriptions/ tests/test_page_loads.py -v`
Expected: PASS
- [ ] **Step 6 : Commit**
```bash
git add src/pages/compte/abonnement_mes_infos.py src/assets/css/style.css tests/subscriptions/test_mes_infos_plan.py
git commit -m "feat(abonnement): sélection de formule par cartes cliquables dans mes-infos"
```
---
### Task 7 : Vérification de bout en bout
**Files:** aucun (validation).
- [ ] **Step 1 : Suite complète**
Run: `uv run pytest`
Expected: PASS (aucune régression). Si des tests Selenium échouent faute de Chrome,
les relever mais valider au moins les tests unitaires et de routes touchés.
- [ ] **Step 2 : Revue manuelle du parcours** (skill `verify` recommandé)
Dérouler : `/a-propos/abonnement` (déconnecté) → « Je m'abonne » → `/inscription`
signup → lien email → auto-login → `mes-infos` (radios présents, défaut « simple »)
→ soumission → checkout. Puis, connecté sans abonnement : `/compte/abonnement`
montre « M'abonner ».
---
## Self-Review (auteur)
**Couverture spec :**
- §1 page publique cards/explainer/bouton/trim CGU → Task 3 ✓
- §2 bouton conditionnel (4 états) → Task 3 (`_subscribe_button`) ✓
- §3 `/compte/abonnement` M'abonner/Me réabonner via `has_used_trial` → Task 4 ✓
- §4 mes-infos cartes cliquables + gating submit + suppression garde `?plan=` → Task 6 ✓
- §5 `verify_email` auto-login → Task 2 ✓
- §6 CTA connexion → Task 5 ✓
- §7 `linkedin_button(next)` + inscription → Task 1 ✓
**Placeholders :** aucun ; tout le code est fourni.
**Cohérence des types :** `_subscribe_button(bool, bool, bool)`, `_reabo_button(bool)`,
`_plan_cards(trial_for)` / `_plan_card(meta, trial)` (Task 3, réutilisé par Task 6),
`_selectable_cards(trial_for) -> dbc.Row`, `_selection_state(str) -> tuple[str,str,str,str]`,
`_toggle_submit(retractation, cgu, plan) -> bool` — noms et signatures cohérents entre
tâches et tests.
@@ -0,0 +1,432 @@
# Changer de méthode de paiement (issue #108) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Permettre à un·e abonné·e en essai ou actif de changer sa carte bancaire depuis `/compte/abonnement`, via la page hébergée Frisbii dédiée (`hosted_page_links.payment_info` de l'objet subscription).
**Architecture:** Une fonction client Frisbii récupère l'abonnement et construit l'URL de la page hébergée avec `accept_url`/`cancel_url`. Une route Flask (symétrique à `/subscriptions/add-payment` et `/subscriptions/cancel`) sert de point d'entrée POST et redirige (303) vers cette URL. Un bouton sur `/compte/abonnement` déclenche cette route ; les retours (`?carte=succes`/`?carte=annule`) affichent un message dans `_feedback()`.
**Tech Stack:** Python, Flask, Dash + Dash Bootstrap Components, httpx (déjà en place dans `src/subscriptions/client.py`), pytest + monkeypatch pour les tests.
## Global Constraints
- Le bouton n'apparaît que pour `row["status"] in ("trial", "active")` — pas `pending` (garde son bouton "Ajouter une méthode de paiement" existant), pas `cancelled` (rien à facturer, chemin logique = reprendre un abonnement).
- Réutiliser `client.get_subscription` déjà existant (`src/subscriptions/client.py:136`), ne pas dupliquer l'appel HTTP.
- Suivre le pattern CSRF + `html.Form` POST déjà utilisé pour "Me désabonner" / "Ajouter une méthode de paiement" dans `src/pages/compte/abonnement.py`.
- Aucun nouveau webhook/callback : Frisbii associe la nouvelle méthode de paiement à l'abonnement de son côté.
---
### Task 1: `client.get_payment_info_url`
**Files:**
- Modify: `src/subscriptions/client.py`
- Test: `tests/subscriptions/test_client.py`
**Interfaces:**
- Consumes: `get_subscription(subscription_handle: str) -> dict` (déjà défini à `src/subscriptions/client.py:136`), qui retourne le JSON brut de `GET /v1/subscription/{handle}` — notamment `hosted_page_links: {"payment_info": "<url>"}`.
- Produces: `get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str`, utilisé par la route du Task 2.
- [ ] **Step 1: Write the failing test**
Ajouter à la fin de `tests/subscriptions/test_client.py` :
```python
def test_get_payment_info_url_appends_query_params(fake_httpx):
fake_httpx["queue"].append(
fake_httpx["Response"](
200,
{
"handle": "abo-1-1",
"hosted_page_links": {
"payment_info": "https://checkout.reepay.com/#/subscription/pay/en_GB/x/abo-1-1"
},
},
)
)
url = client.get_payment_info_url(
"abo-1-1", "https://app/compte/abonnement?carte=succes",
"https://app/compte/abonnement?carte=annule",
)
assert url.startswith(
"https://checkout.reepay.com/#/subscription/pay/en_GB/x/abo-1-1"
)
assert "accept_url=https%3A%2F%2Fapp%2Fcompte%2Fabonnement%3Fcarte%3Dsucces" in url
assert "cancel_url=https%3A%2F%2Fapp%2Fcompte%2Fabonnement%3Fcarte%3Dannule" in url
def test_get_payment_info_url_merges_existing_query_string(fake_httpx):
fake_httpx["queue"].append(
fake_httpx["Response"](
200,
{
"handle": "abo-1-2",
"hosted_page_links": {
"payment_info": "https://checkout.reepay.com/pay/abo-1-2?locale=fr"
},
},
)
)
url = client.get_payment_info_url("abo-1-2", "https://app/ok", "https://app/ko")
assert "locale=fr" in url
assert "accept_url=https%3A%2F%2Fapp%2Fok" in url
assert "cancel_url=https%3A%2F%2Fapp%2Fko" in url
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/subscriptions/test_client.py -k payment_info_url -v`
Expected: FAIL with `AttributeError: module 'src.subscriptions.client' has no attribute 'get_payment_info_url'`
- [ ] **Step 3: Write minimal implementation**
Dans `src/subscriptions/client.py`, ajouter l'import en haut du fichier (avec les autres imports) :
```python
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
```
Puis, juste après `get_subscription` (après la ligne `return _call("GET", f"/v1/subscription/{subscription_handle}")`) :
```python
def get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str:
sub = get_subscription(sub_handle)
url = sub["hosted_page_links"]["payment_info"]
parts = urlsplit(url)
query = dict(parse_qsl(parts.query))
query["accept_url"] = accept_url
query["cancel_url"] = cancel_url
return urlunsplit(parts._replace(query=urlencode(query)))
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/subscriptions/test_client.py -k payment_info_url -v`
Expected: PASS (2 tests)
- [ ] **Step 5: Run the full client test file to check no regression**
Run: `uv run pytest tests/subscriptions/test_client.py -v`
Expected: All PASS
- [ ] **Step 6: Commit**
```bash
git add src/subscriptions/client.py tests/subscriptions/test_client.py
git commit -m "feat(abonnement): client.get_payment_info_url pour le changement de carte Frisbii"
```
---
### Task 2: Route `POST /subscriptions/change-payment-method`
**Files:**
- Modify: `src/subscriptions/routes.py`
- Test: `tests/subscriptions/test_routes.py`
**Interfaces:**
- Consumes: `client.get_payment_info_url(sub_handle, accept_url, cancel_url) -> str` (Task 1) ; `db.get_current(user_id) -> sqlite3.Row | None` (déjà existant, colonne `frisbii_subscription_handle`) ; `client.FrisbiiError` (déjà existant).
- Produces: route Flask `POST /subscriptions/change-payment-method`, utilisée par le bouton du Task 3. Redirige (303) vers l'URL Frisbii en cas de succès, 400 si pas d'abonnement, redirect 302 vers `/compte/abonnement?error=frisbii` en cas d'erreur API.
- [ ] **Step 1: Write the failing tests**
Ajouter à la fin de `tests/subscriptions/test_routes.py` :
```python
def test_change_payment_method_redirects_to_hosted_url(logged_in_client, monkeypatch):
client, uid = logged_in_client
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
monkeypatch.setattr(
frisbii_client,
"get_payment_info_url",
lambda h, accept_url, cancel_url: f"https://pay.test/{h}?a={accept_url}&c={cancel_url}",
)
resp = client.post("/subscriptions/change-payment-method")
assert resp.status_code == 303
assert resp.headers["Location"].startswith(f"https://pay.test/{handle}")
assert "carte%3Dsucces" in resp.headers["Location"] or "carte=succes" in resp.headers["Location"]
assert "carte%3Dannule" in resp.headers["Location"] or "carte=annule" in resp.headers["Location"]
def test_change_payment_method_without_subscription(logged_in_client):
client, _ = logged_in_client
resp = client.post("/subscriptions/change-payment-method")
assert resp.status_code == 400
def test_change_payment_method_api_error_redirects(logged_in_client, monkeypatch):
client, uid = logged_in_client
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
def boom(h, accept_url, cancel_url):
raise frisbii_client.FrisbiiError(500, "boom")
monkeypatch.setattr(frisbii_client, "get_payment_info_url", boom)
resp = client.post("/subscriptions/change-payment-method")
assert resp.status_code == 302
assert "error=frisbii" in resp.headers["Location"]
def test_change_payment_method_requires_login(sub_app):
resp = sub_app.test_client().post("/subscriptions/change-payment-method")
assert resp.status_code in (302, 401)
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/subscriptions/test_routes.py -k change_payment_method -v`
Expected: FAIL — `test_change_payment_method_without_subscription` gets a 404 (route doesn't exist yet) instead of 400, others fail similarly.
- [ ] **Step 3: Write minimal implementation**
Dans `src/subscriptions/routes.py`, ajouter la route juste après `add_payment_callback()` (avant `cancel()`) :
```python
@subscriptions_bp.route("/subscriptions/change-payment-method", methods=["POST"])
@login_required
def change_payment_method():
base = os.getenv("APP_BASE_URL", "")
row = db.get_current(current_user.id)
if row is None or not row["frisbii_subscription_handle"]:
return "Aucun abonnement actif", 400
try:
url = client.get_payment_info_url(
row["frisbii_subscription_handle"],
f"{base}/compte/abonnement?carte=succes",
f"{base}/compte/abonnement?carte=annule",
)
except client.FrisbiiError:
logger.exception("Échec de récupération du lien de paiement Frisbii")
return redirect("/compte/abonnement?error=frisbii")
return redirect(url, code=303)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/subscriptions/test_routes.py -k change_payment_method -v`
Expected: PASS (4 tests)
- [ ] **Step 5: Run the full routes test file to check no regression**
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
Expected: All PASS
- [ ] **Step 6: Commit**
```bash
git add src/subscriptions/routes.py tests/subscriptions/test_routes.py
git commit -m "feat(abonnement): route /subscriptions/change-payment-method"
```
---
### Task 3: Bouton et messages de retour sur `/compte/abonnement`
**Files:**
- Modify: `src/pages/compte/abonnement.py`
- Test: `tests/subscriptions/test_compte_abonnement.py`
**Interfaces:**
- Consumes: rien de nouveau — utilise `_csrf_input()` déjà défini dans ce fichier (`src/pages/compte/abonnement.py:18`).
- Produces: `_active_view(row)` inclut désormais le bouton pour `status in ("trial", "active")` ; `_feedback(query)` gère `query.get("carte")`.
- [ ] **Step 1: Write the failing tests**
Ajouter à la fin de `tests/subscriptions/test_compte_abonnement.py` :
```python
def test_active_view_shows_change_payment_method_for_active():
from src.pages.compte import abonnement as compte_abonnement
row = {
"plan": "simple",
"status": "active",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
text = str(compte_abonnement._active_view(row))
assert "Changer de méthode de paiement" in text
assert "/subscriptions/change-payment-method" in text
def test_active_view_shows_change_payment_method_for_trial():
from src.pages.compte import abonnement as compte_abonnement
row = {
"plan": "simple",
"status": "trial",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
assert "Changer de méthode de paiement" in str(
compte_abonnement._active_view(row)
)
def test_active_view_hides_change_payment_method_for_cancelled():
from src.pages.compte import abonnement as compte_abonnement
row = {
"plan": "simple",
"status": "cancelled",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
assert "Changer de méthode de paiement" not in str(
compte_abonnement._active_view(row)
)
def test_active_view_hides_change_payment_method_for_pending():
from src.pages.compte import abonnement as compte_abonnement
row = {"plan": "simple", "status": "pending", "current_period_end": None}
text = str(compte_abonnement._active_view(row))
assert "Changer de méthode de paiement" not in text
assert "Ajouter une méthode de paiement" in text
def test_feedback_carte_succes():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._feedback({"carte": "succes"}))
assert "Méthode de paiement mise à jour." in text
def test_feedback_carte_annule():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._feedback({"carte": "annule"}))
assert "Modification annulée." in text
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
Expected: FAIL — the 6 new tests fail (button/messages absent), the 8 pre-existing tests in this file still PASS.
- [ ] **Step 3: Write minimal implementation**
Dans `src/pages/compte/abonnement.py`, modifier `_active_view` : remplacer le bloc
```python
if row["status"] in ("pending", "trial", "active"):
blocks.append(
html.Button(
"Me désabonner",
id="resiliation-trigger",
n_clicks=0,
className="btn btn-outline-danger mt-3",
)
)
return html.Div(blocks)
```
par :
```python
if row["status"] in ("trial", "active"):
blocks.append(
html.Form(
method="POST",
action="/subscriptions/change-payment-method",
children=[
_csrf_input(),
html.Button(
"Changer de méthode de paiement",
type="submit",
className="btn btn-outline-secondary mt-3 me-2",
),
],
style={"display": "inline-block"},
)
)
if row["status"] in ("pending", "trial", "active"):
blocks.append(
html.Button(
"Me désabonner",
id="resiliation-trigger",
n_clicks=0,
className="btn btn-outline-danger mt-3",
)
)
return html.Div(blocks)
```
Puis modifier `_feedback` : remplacer
```python
if query.get("error") == "frisbii":
out.append(
dbc.Alert(
"Une erreur est survenue avec le service de paiement.", color="primary"
)
)
return out
```
par :
```python
if query.get("carte") == "succes":
out.append(dbc.Alert("Méthode de paiement mise à jour.", color="success"))
if query.get("carte") == "annule":
out.append(dbc.Alert("Modification annulée.", color="secondary"))
if query.get("error") == "frisbii":
out.append(
dbc.Alert(
"Une erreur est survenue avec le service de paiement.", color="primary"
)
)
return out
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
Expected: All 14 tests PASS (8 pre-existing + 6 new)
- [ ] **Step 5: Run the full subscriptions test suite to check no regression**
Run: `uv run pytest tests/subscriptions/ -v`
Expected: All PASS
- [ ] **Step 6: Commit**
```bash
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
git commit -m "feat(abonnement): bouton changer de méthode de paiement sur /compte/abonnement"
```
---
### Task 4: Vérification manuelle en local
**Files:** aucun (vérification uniquement)
- [ ] **Step 1: Lancer l'app en local**
Run: `uv run run.py`
- [ ] **Step 2: Créer un abonnement actif de test et vérifier l'affichage du bouton**
Se connecter, créer un abonnement (trial ou active selon état de la base de test), aller sur `/compte/abonnement`, vérifier que le bouton "Changer de méthode de paiement" est visible, et absent une fois l'abonnement résilié (statut `cancelled`).
- [ ] **Step 3: Vérifier le clic (sans compte Frisbii réel si `FRISBII_API_KEY` n'est pas configurée en local)**
Si `FRISBII_API_KEY` n'est pas renseignée, le clic déclenchera une `FrisbiiError` (requête échouée) → vérifier la redirection vers `/compte/abonnement?error=frisbii` et l'affichage du message d'erreur existant. Si une clé de test Frisbii est disponible, vérifier la redirection réelle vers la page hébergée et le retour via `?carte=succes`/`?carte=annule`.
- [ ] **Step 4: Run full test suite**
Run: `uv run pytest`
Expected: All PASS
Pas de commit pour cette tâche (vérification uniquement).
@@ -0,0 +1,827 @@
# Configurer son abonnement (#109) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Permettre à un·e abonné·e `active`, `trial` ou `pending` de changer de formule (simple ↔ soutien) et de mettre à jour ses infos de facturation depuis `/compte/abonnement`, et afficher le prix (HT + TTC) de la formule courante.
**Architecture:** On réutilise la page `/compte/abonnement/mes-infos` en la « flexibilisant » via un `mode` dérivé de l'état de l'abonnement (`configure` vs `subscribe`), plutôt que de créer une page dupliquée (impossible de partager les `id` de composants Dash entre deux pages). Un nouveau bouton « Configurer mon abonnement » sur `/compte/abonnement` y renvoie. Le changement de formule passe par une nouvelle route `/subscriptions/update` qui appelle `update_customer` puis, si la formule change, un nouvel endpoint client `change_subscription` (`PUT /v1/subscription/{handle}`), sans redirection vers un checkout.
**Tech Stack:** Dash 3.4, Dash Bootstrap Components, Flask (blueprint `subscriptions`), httpx (client Frisbii/Reepay), pytest.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `src.subscriptions.client`), jamais `subscriptions.client`.
- `suppress_callback_exceptions=True` est déjà activé (`src/app.py:88`) : les composants rendus conditionnellement (cases à cocher absentes en mode `configure`) ne cassent pas les callbacks — un callback dont un `Input` n'existe pas ne se déclenche simplement pas.
- TVA France = 20 % : prix TTC = `round(prix_ht * 1.2, 2)`, formaté avec `:g` pour éviter les artefacts flottants (ex. `28.8`, pas `28.799999999999997`).
- Effet du changement de formule : `timing="renewal"` (prochaine échéance) pour `active`/`trial` ; `timing="immediate"` pour `pending` (pas d'échéance à laquelle rattacher un renouvellement).
- Avant `git add`/`git commit`, exécuter `pre-commit` (ruff formate ; prettier reformate le markdown). Terminer chaque message de commit par `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- Lancer les tests avec `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici). Chaque tâche ne lance QUE son fichier de test ; la suite complète (`uv run pytest`, sans chemin) uniquement à la toute fin.
---
### Task 1: Endpoint client `change_subscription`
**Files:**
- Modify: `src/subscriptions/client.py` (ajouter la fonction en fin de fichier)
- Test: `tests/subscriptions/test_client.py`
**Interfaces:**
- Produces: `client.change_subscription(sub_handle: str, plan_handle: str, timing: str = "renewal") -> dict` — appelle `PUT /v1/subscription/{sub_handle}` avec le corps `{"timing": timing, "plan": plan_handle}`.
- [ ] **Step 1: Write the failing test**
Ajouter dans `tests/subscriptions/test_client.py` :
```python
def test_change_subscription_sends_timing_and_plan(fake_httpx):
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-1"}))
client.change_subscription("abo-1-1", "plan_soutien", timing="renewal")
call = fake_httpx["calls"][0]
assert call["method"] == "PUT"
assert call["url"] == "https://api.test/v1/subscription/abo-1-1"
assert call["json"] == {"timing": "renewal", "plan": "plan_soutien"}
def test_change_subscription_immediate_timing(fake_httpx):
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-2"}))
client.change_subscription("abo-1-2", "plan_simple", timing="immediate")
assert fake_httpx["calls"][0]["json"]["timing"] == "immediate"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/subscriptions/test_client.py::test_change_subscription_sends_timing_and_plan -v`
Expected: FAIL with `AttributeError: module 'src.subscriptions.client' has no attribute 'change_subscription'`
- [ ] **Step 3: Write minimal implementation**
Ajouter en fin de `src/subscriptions/client.py` :
```python
def change_subscription(
sub_handle: str, plan_handle: str, timing: str = "renewal"
) -> dict:
return _call(
"PUT",
f"/v1/subscription/{sub_handle}",
json={"timing": timing, "plan": plan_handle},
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/subscriptions/test_client.py -v`
Expected: PASS (tous, dont les deux nouveaux)
- [ ] **Step 5: Commit**
```bash
pre-commit run --files src/subscriptions/client.py tests/subscriptions/test_client.py
git add src/subscriptions/client.py tests/subscriptions/test_client.py
git commit -m "feat(abonnement): client.change_subscription (PUT /v1/subscription) (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: Route `/subscriptions/update`
**Files:**
- Modify: `src/subscriptions/routes.py` (ajouter la route après `change_payment_method`, avant `cancel`)
- Test: `tests/subscriptions/test_routes.py`
**Interfaces:**
- Consumes: `client.change_subscription(...)` (Task 1), `client.update_customer`, `db.get_current`, `plans.resolve_handle`, `auth_db.set_siret`.
- Produces: route `POST /subscriptions/update` — met à jour le customer Frisbii et, si la formule diffère, appelle `change_subscription` ; redirige `303` vers `/compte/abonnement?maj=succes`.
Comportement : valide d'abord le plan (400 si inconnu), puis vérifie l'abonnement (400 si aucun), met à jour les infos de facturation, change la formule seulement si le handle diffère. `timing="immediate"` si `status == "pending"`, sinon `"renewal"`.
- [ ] **Step 1: Write the failing tests**
Ajouter dans `tests/subscriptions/test_routes.py` :
```python
def test_update_changes_plan_and_redirects(logged_in_client, monkeypatch):
client, uid = logged_in_client
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
captured = {}
def fake_change(sub_handle, plan_handle, timing="renewal"):
captured.update(sub_handle=sub_handle, plan_handle=plan_handle, timing=timing)
return {}
monkeypatch.setattr(frisbii_client, "change_subscription", fake_change)
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
assert resp.status_code == 303
assert "maj=succes" in resp.headers["Location"]
assert captured["sub_handle"] == handle
assert captured["plan_handle"] == "plan_soutien"
assert captured["timing"] == "renewal"
def test_update_pending_uses_immediate_timing(logged_in_client, monkeypatch):
client, uid = logged_in_client
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
captured = {}
monkeypatch.setattr(
frisbii_client,
"change_subscription",
lambda sub_handle, plan_handle, timing="renewal": captured.update(timing=timing),
)
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
assert resp.status_code == 303
assert captured["timing"] == "immediate"
def test_update_same_plan_skips_change_subscription(logged_in_client, monkeypatch):
client, uid = logged_in_client
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
called = []
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
monkeypatch.setattr(
frisbii_client,
"change_subscription",
lambda *a, **k: called.append(a),
)
resp = client.post("/subscriptions/update", data={"plan": "simple"})
assert resp.status_code == 303
assert "maj=succes" in resp.headers["Location"]
assert called == [], "formule inchangée : change_subscription ne doit pas être appelé"
def test_update_without_subscription_returns_400(logged_in_client):
client, _ = logged_in_client
resp = client.post("/subscriptions/update", data={"plan": "simple"})
assert resp.status_code == 400
def test_update_unknown_plan_returns_400(logged_in_client, monkeypatch):
client, uid = logged_in_client
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
resp = client.post("/subscriptions/update", data={"plan": "bidon"})
assert resp.status_code == 400
def test_update_api_error_redirects(logged_in_client, monkeypatch):
client, uid = logged_in_client
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
def boom(h, d):
raise frisbii_client.FrisbiiError(500, "boom")
monkeypatch.setattr(frisbii_client, "update_customer", boom)
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
assert resp.status_code == 302
assert "error=frisbii" in resp.headers["Location"]
def test_update_requires_login(sub_app):
resp = sub_app.test_client().post("/subscriptions/update", data={"plan": "simple"})
assert resp.status_code in (302, 401)
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/subscriptions/test_routes.py::test_update_changes_plan_and_redirects -v`
Expected: FAIL avec `404` (route inexistante)
- [ ] **Step 3: Write the route**
Dans `src/subscriptions/routes.py`, insérer après la fonction `change_payment_method` (avant `cancel`) :
```python
@subscriptions_bp.route("/subscriptions/update", methods=["POST"])
@login_required
def update():
new_plan_key = request.form.get("plan") or ""
new_handle = plans.resolve_handle(new_plan_key)
if new_handle is None:
return "Plan inconnu", 400
row = db.get_current(current_user.id)
if row is None or not row["frisbii_subscription_handle"]:
return "Aucun abonnement à mettre à jour", 400
cust = _customer_handle(current_user.id)
siret = (request.form.get("siret") or "").strip()
billing: dict = {
"email": current_user.email,
"first_name": request.form.get("first_name", ""),
"last_name": request.form.get("last_name", ""),
"address": request.form.get("address", ""),
"city": request.form.get("city", ""),
"postal_code": request.form.get("postal_code", ""),
"country": request.form.get("country", "FR"),
}
if request.form.get("address2"):
billing["address2"] = request.form["address2"]
if request.form.get("company"):
billing["company"] = request.form["company"]
try:
if siret:
auth_db.set_siret(current_user.id, siret)
client.update_customer(cust, billing)
if new_handle != plans.resolve_handle(row["plan"]):
timing = "immediate" if row["status"] == "pending" else "renewal"
client.change_subscription(
row["frisbii_subscription_handle"], new_handle, timing
)
except client.FrisbiiError:
logger.exception("Échec de mise à jour de l'abonnement Frisbii")
return redirect("/compte/abonnement?error=frisbii")
return redirect("/compte/abonnement?maj=succes", code=303)
```
(Aucun import à ajouter : `client`, `db`, `plans`, `auth_db`, `logger`, `current_user`, `request`, `redirect` sont déjà importés dans ce fichier.)
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
Expected: PASS (tous)
- [ ] **Step 5: Commit**
```bash
pre-commit run --files src/subscriptions/routes.py tests/subscriptions/test_routes.py
git add src/subscriptions/routes.py tests/subscriptions/test_routes.py
git commit -m "feat(abonnement): route /subscriptions/update (maj formule + facturation) (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 3: Page `/compte/abonnement` — prix, bouton « Configurer », message de succès
**Files:**
- Modify: `src/pages/compte/abonnement.py` (`_active_view`, `_feedback`, + nouveau helper `_price_text`)
- Test: `tests/subscriptions/test_compte_abonnement.py`
**Interfaces:**
- Produces: `_price_text(meta: dict) -> str | None` ; `_active_view` affiche le prix sous le libellé et un lien « Configurer mon abonnement » (statuts `pending`/`trial`/`active`) ; `_feedback` gère `maj=succes`.
- [ ] **Step 1: Write the failing tests**
Ajouter dans `tests/subscriptions/test_compte_abonnement.py` :
```python
def test_price_text_rounds_ttc():
from src.pages.compte import abonnement as compte_abonnement
assert compte_abonnement._price_text({"prix_ht": 20}) == "20 € HT / mois (24 € TTC)"
assert compte_abonnement._price_text({"prix_ht": 50}) == "50 € HT / mois (60 € TTC)"
assert compte_abonnement._price_text({"label": "x"}) is None
def test_active_view_shows_price():
from src.pages.compte import abonnement as compte_abonnement
row = {
"plan": "simple",
"status": "active",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
assert "24 € TTC" in str(compte_abonnement._active_view(row))
def test_active_view_shows_configure_button_for_active():
from src.pages.compte import abonnement as compte_abonnement
row = {
"plan": "simple",
"status": "active",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
text = str(compte_abonnement._active_view(row))
assert "Configurer mon abonnement" in text
assert "href='/compte/abonnement/mes-infos'" in text
def test_active_view_shows_configure_button_for_pending():
from src.pages.compte import abonnement as compte_abonnement
row = {"plan": "simple", "status": "pending", "current_period_end": None}
assert "Configurer mon abonnement" in str(compte_abonnement._active_view(row))
def test_active_view_hides_configure_button_for_cancelled():
from src.pages.compte import abonnement as compte_abonnement
row = {
"plan": "simple",
"status": "cancelled",
"current_period_end": "2099-01-01T00:00:00+00:00",
}
assert "Configurer mon abonnement" not in str(compte_abonnement._active_view(row))
def test_feedback_maj_succes():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._feedback({"maj": "succes"}))
assert "Votre abonnement a été mis à jour." in text
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py::test_price_text_rounds_ttc -v`
Expected: FAIL avec `AttributeError: ... has no attribute '_price_text'`
- [ ] **Step 3a: Add `_price_text` helper**
Dans `src/pages/compte/abonnement.py`, ajouter avant `_active_view` :
```python
def _price_text(meta: dict) -> str | None:
ht = meta.get("prix_ht")
if ht is None:
return None
return f"{ht} € HT / mois ({round(ht * 1.2, 2):g} € TTC)"
```
- [ ] **Step 3b: Show price in `_active_view`**
Dans `_active_view`, remplacer :
```python
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
end = format_date_french(row["current_period_end"])
blocks = [html.H3(meta["label"], className="mb-3")]
```
par :
```python
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
end = format_date_french(row["current_period_end"])
blocks = [html.H3(meta["label"], className="mb-1")]
price = _price_text(meta)
if price:
blocks.append(html.P(price, className="text-muted mb-3"))
```
- [ ] **Step 3c: Add « Configurer mon abonnement » button**
Toujours dans `_active_view`, juste avant le bloc `if row["status"] in ("trial", "active"):` (celui du formulaire « Changer de méthode de paiement »), insérer :
```python
if row["status"] in ("pending", "trial", "active"):
blocks.append(
html.A(
"Configurer mon abonnement",
href="/compte/abonnement/mes-infos",
className="btn btn-outline-primary mt-3 me-2",
)
)
```
- [ ] **Step 3d: Handle `maj=succes` in `_feedback`**
Dans `_feedback`, ajouter avant `return out` :
```python
if query.get("maj") == "succes":
out.append(dbc.Alert("Votre abonnement a été mis à jour.", color="success"))
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
Expected: PASS (tous, y compris les tests existants qui doivent rester verts)
- [ ] **Step 5: Commit**
```bash
pre-commit run --files src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
git commit -m "feat(abonnement): prix + bouton Configurer sur /compte/abonnement (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 4: `mes-infos` — mode configure/subscribe
**Files:**
- Modify: `src/pages/compte/abonnement_mes_infos.py`
- Test: `tests/subscriptions/test_mes_infos_plan.py`
**Interfaces:**
- Produces:
- `_mode_for(row) -> str``"configure"` si `row` et `row["status"]` ∈ {active, trial, pending}, sinon `"subscribe"`.
- `_submit_button(mode: str) -> html.Button``id="inf-submit"`, libellé + `disabled` selon le mode.
- `_selectable_cards(trial_for, selected=None)` — la card dont la clé == `selected` reçoit la classe `selected`.
- `_consent_checklists() -> list` et `_legal_note()` — extraits de l'ancien bloc `checkboxes`.
- `layout()` rend un `dcc.Store(id="inf-sub-info")` et un `html.Div(id="inf-change-hint")` dans les deux modes (Task 5 s'en sert).
- [ ] **Step 1: Write the failing tests**
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
```python
def test_mode_for_derives_from_status():
from src.pages.compte import abonnement_mes_infos as m
for status in ("active", "trial", "pending"):
assert m._mode_for({"status": status}) == "configure"
assert m._mode_for({"status": "cancelled"}) == "subscribe"
assert m._mode_for({"status": "expired"}) == "subscribe"
assert m._mode_for(None) == "subscribe"
def test_submit_button_subscribe_mode():
from src.pages.compte import abonnement_mes_infos as m
text = str(m._submit_button("subscribe"))
assert "Ajouter une carte de paiement" in text
assert "disabled" in text
def test_submit_button_configure_mode():
from src.pages.compte import abonnement_mes_infos as m
btn = m._submit_button("configure")
text = str(btn)
assert "Mettre à jour mon abonnement" in text
assert btn.disabled is False
def test_selectable_cards_preselects_current_plan():
from src.pages.compte import abonnement_mes_infos as m
text = str(m._selectable_cards(trial_for=lambda key: None, selected="soutien"))
# la card soutien est marquée sélectionnée, pas la card simple
assert "plan-selectable selected" in text
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_mode_for_derives_from_status -v`
Expected: FAIL avec `AttributeError: ... has no attribute '_mode_for'`
- [ ] **Step 3a: Add import + helpers**
En haut de `src/pages/compte/abonnement_mes_infos.py`, ajouter l'import :
```python
from src.utils.frontend import format_date_french
```
Ajouter ces helpers (par ex. après `_trial_for`) :
```python
def _mode_for(row) -> str:
if row is not None and row["status"] in ("active", "trial", "pending"):
return "configure"
return "subscribe"
def _submit_button(mode: str):
label = (
"Mettre à jour mon abonnement"
if mode == "configure"
else "Ajouter une carte de paiement"
)
return html.Button(
label,
id="inf-submit",
type="submit",
className="btn btn-primary",
disabled=(mode == "subscribe"),
)
```
- [ ] **Step 3b: Add `selected` param to `_selectable_cards`**
Remplacer la signature et le corps de `_selectable_cards` :
```python
def _selectable_cards(trial_for, selected=None):
cols = []
for key in ("simple", "soutien"):
meta = plans.plan_meta(key)
if not meta:
continue
base = "plan-selectable selected" if key == selected else "plan-selectable"
cols.append(
dbc.Col(
html.Div(
_plan_card(meta, trial_for(key)),
id=f"plan-card-{key}",
n_clicks=0,
className=base,
),
md=6,
)
)
return dbc.Row(cols, className="g-4 mb-2")
```
- [ ] **Step 3c: Extract legal note + consent checklists**
Remplacer le bloc `checkboxes = html.Div([...])` (dans `layout`) par deux helpers au niveau module :
```python
def _legal_note():
return dcc.Markdown(
"""\\* Champ obligatoire
Si vous préférez régler par virement bancaire et une facturation annuelle plutôt qu'un réglement mensuel automatique par carte bancaire, [contactez-moi](/a-propos/contact)."""
)
def _consent_checklists():
return [
dcc.Checklist(
id="inf-cb-retractation",
options=[
{
"label": "Je renonce à mon droit de rétractation légal de 14 jours.",
"value": "ok",
}
],
value=[],
className="mb-2",
),
dcc.Checklist(
id="inf-cb-cgu",
options=[
{
"label": [
"J'ai lu et accepte les ",
html.A(
"conditions générales d'utilisation du service",
href="#",
id="inf-cgu-link",
style={"cursor": "pointer"},
),
".",
],
"value": "ok",
}
],
value=[],
className="mb-4",
),
]
```
- [ ] **Step 3d: Rewire `layout()`**
Au début de `layout`, après le `guard`, calculer le mode et les valeurs dérivées :
```python
row = sub_db.get_current(current_user.id)
mode = _mode_for(row)
selected = row["plan"] if mode == "configure" else None
echeance = (
format_date_french(row["current_period_end"])
if mode == "configure" and row["current_period_end"]
else None
)
sub_info = (
{"current_plan": selected, "status": row["status"], "echeance": echeance}
if mode == "configure"
else {}
)
```
Remplacer le `html.Form(...)` existant par :
```python
form = html.Form(
method="POST",
action="/subscriptions/subscribe"
if mode == "subscribe"
else "/subscriptions/update",
children=[
_csrf_input(),
html.Div(
"Choisissez votre formule :"
if mode == "subscribe"
else "Votre formule :",
id="inf-plan-invite",
className="fw-bold mb-2",
),
_selectable_cards(_trial_for(current_user.id), selected=selected),
html.Div(id="inf-change-hint", className="d-none"),
dcc.Store(id="inf-sub-info", data=sub_info),
dcc.Input(
type="hidden", id="inf-plan-hidden", name="plan", value=selected or ""
),
dbc.Row([col1, col2], className="g-4 mb-4"),
_legal_note(),
*(_consent_checklists() if mode == "subscribe" else []),
_submit_button(mode),
],
)
```
Enfin, ne rendre la modale CGU qu'en mode `subscribe`. Remplacer le `return account_shell(...)` final par :
```python
return account_shell(
"abonnement",
html.Div(
[
html.H2("Mes informations de facturation", className="mb-4"),
dbc.Alert(
"Informations récupérées depuis le prestataire de paiement, vous pouvez les modifier si besoin.",
color="info",
className="mb-4",
)
if prefill
else None,
form,
_cgu_modal() if mode == "subscribe" else None,
]
),
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
Expected: PASS (nouveaux + existants `test_selectable_cards_render_both_plans`, `test_selection_state_*`, `test_submit_disabled_without_plan`)
- [ ] **Step 5: Commit**
```bash
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
git commit -m "feat(abonnement): mode configure sur mes-infos (formule préactivée, sans cases) (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 5: `mes-infos` — hint « prochaine échéance »
**Files:**
- Modify: `src/pages/compte/abonnement_mes_infos.py` (`_change_hint` + callback `_select_plan`)
- Test: `tests/subscriptions/test_mes_infos_plan.py`
**Interfaces:**
- Consumes: `dcc.Store(id="inf-sub-info")` et `html.Div(id="inf-change-hint")` rendus en Task 4.
- Produces: `_change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]``(className, children)` du hint ; le callback `_select_plan` renvoie désormais 6 sorties (les 4 existantes + className/children du hint).
Règles : affiché uniquement si `sub_info` a un `current_plan`, `status` ∈ {active, trial}, et `selected != current_plan`. Sinon masqué (`d-none`, texte vide). `pending` → toujours masqué.
- [ ] **Step 1: Write the failing tests**
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
```python
def test_change_hint_shown_when_plan_differs():
from src.pages.compte import abonnement_mes_infos as m
cls, txt = m._change_hint(
"soutien",
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
)
assert cls != "d-none"
assert "prochaine échéance : 1er janvier" in txt
def test_change_hint_hidden_when_same_plan():
from src.pages.compte import abonnement_mes_infos as m
cls, txt = m._change_hint(
"simple",
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
)
assert cls == "d-none"
assert txt == ""
def test_change_hint_hidden_for_pending():
from src.pages.compte import abonnement_mes_infos as m
cls, _ = m._change_hint(
"soutien",
{"current_plan": "simple", "status": "pending", "echeance": None},
)
assert cls == "d-none"
def test_change_hint_hidden_in_subscribe_mode():
from src.pages.compte import abonnement_mes_infos as m
cls, _ = m._change_hint("soutien", {})
assert cls == "d-none"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_change_hint_shown_when_plan_differs -v`
Expected: FAIL avec `AttributeError: ... has no attribute '_change_hint'`
- [ ] **Step 3a: Add `_change_hint` helper**
Dans `src/pages/compte/abonnement_mes_infos.py`, ajouter :
```python
def _change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]:
sub_info = sub_info or {}
current = sub_info.get("current_plan")
if (
not current
or sub_info.get("status") not in ("active", "trial")
or selected == current
):
return "d-none", ""
echeance = sub_info.get("echeance")
return (
"text-muted mt-2",
f"Le changement d'abonnement sera appliqué à la prochaine échéance : {echeance}.",
)
```
- [ ] **Step 3b: Extend the `_select_plan` callback**
Remplacer le callback `_select_plan` et son décorateur par :
```python
@callback(
Output("inf-plan-hidden", "value"),
Output("plan-card-simple", "className"),
Output("plan-card-soutien", "className"),
Output("inf-plan-invite", "className"),
Output("inf-change-hint", "className"),
Output("inf-change-hint", "children"),
Input("plan-card-simple", "n_clicks"),
Input("plan-card-soutien", "n_clicks"),
State("inf-sub-info", "data"),
prevent_initial_call=True,
)
def _select_plan(_n_simple, _n_soutien, sub_info):
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
value, cls_simple, cls_soutien, cls_invite = _selection_state(selected)
hint_cls, hint_txt = _change_hint(selected, sub_info)
return value, cls_simple, cls_soutien, cls_invite, hint_cls, hint_txt
```
(`State` est déjà importé depuis `dash` en tête de fichier.)
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
Expected: PASS (tous)
- [ ] **Step 5: Commit**
```bash
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
git commit -m "feat(abonnement): message 'changement à la prochaine échéance' sous les cards (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 6: Vérification finale
- [ ] **Step 1: Run the whole subscriptions test suite**
Run: `uv run pytest tests/subscriptions/ -v`
Expected: PASS (aucune régression)
- [ ] **Step 2: Run the full test suite**
Run: `uv run pytest`
Expected: PASS (les tests Selenium nécessitent Chrome/Chromium ; si l'environnement n'en a pas, noter les échecs Selenium comme non liés et confirmer que `tests/subscriptions/` est vert).
- [ ] **Step 3: Sanity-check manuel (optionnel mais recommandé)**
Lancer l'app (`uv run run.py`), se connecter avec un compte ayant un abonnement `active`, aller sur `/compte/abonnement` : vérifier le prix affiché et le bouton « Configurer mon abonnement ». Cliquer → `/compte/abonnement/mes-infos` : formule courante présélectionnée, pas de cases à cocher, bouton « Mettre à jour mon abonnement ». Cliquer sur l'autre formule → le message « … prochaine échéance : {date} » apparaît.
---
## Notes d'implémentation
- **Pourquoi `inf-change-hint` et `inf-sub-info` sont rendus dans les deux modes** : le callback `_select_plan` est enregistré une seule fois et référence ces `id` en sortie/état. Les rendre toujours (vides en mode `subscribe`) évite tout souci de composant manquant et garde le callback identique quel que soit le mode.
- **Pourquoi `_toggle_submit` n'est pas modifié** : ses `Input` (`inf-cb-retractation`, `inf-cb-cgu`) n'existent pas en mode `configure`, donc le callback ne se déclenche pas et le bouton conserve son `disabled=False` défini par `_submit_button("configure")`. En mode `subscribe`, comportement inchangé.
- **Timing facturation** : `update_customer` prend effet immédiatement (adresse/SIRET), tandis que le changement de formule est différé à l'échéance (`renewal`) — d'où le message de succès générique « Votre abonnement a été mis à jour. ».
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
# Migration Dash 3.4 → 4.4 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Faire passer l'application de `dash==3.4.0` à Dash 4.4.x sans changement fonctionnel, en acceptant le nouveau style des composants DCC.
**Architecture:** Bump groupé des dépendances de l'écosystème Dash dans un worktree isolé basé sur `dev`, puis on laisse la suite de tests et le smoke test manuel révéler les cassures, qu'on corrige au fil de l'eau. Une seule PR vers `dev`.
**Tech Stack:** Dash 4.4, `uv` (gestion des dépendances), pytest + Selenium (`dash[testing]`), `dash-leaflet` + `dash-extensions` (cartes), `dash-bootstrap-components`.
**Spec de référence:** `docs/superpowers/specs/2026-07-09-migration-dash-4-design.md`
## Global Constraints
- **Base worktree :** créer le worktree depuis `dev` (PAS `origin/main`, qui est en retard sur `dev`).
- **Version cible :** `dash` épinglé en **exact** sur la dernière 4.4.x (style d'épinglage existant `dash==3.4.0`).
- **DataTable reste :** ne PAS toucher aux `dash_table.DataTable` ni à `src/figures.py`/`src/utils/table_sql.py` (relève de #41).
- **Régressions visuelles :** accepter le look Dash 4 ; ne corriger que les cassures fonctionnelles ; **retirer** le CSS custom en conflit plutôt que le patcher.
- **Bump libs tierces :** seulement si la compat Dash 4 l'exige.
- **Tests :** toujours via `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici).
- **Avant chaque commit :** exécuter `pre-commit` (ruff + prettier) — cf. CLAUDE.md.
- **Imports :** modules de l'app toujours préfixés `src.` (ne pas régresser vers `utils.`/`pages.`).
---
### Task 1: Bump Dash et résoudre les dépendances (portail de compat)
C'est le portail de risque : la résolution `uv` et le démarrage de l'app révèlent immédiatement toute incompatibilité de `dash-leaflet` / `dash-extensions` / `dbc` avec Dash 4.
**Files:**
- Modify: `pyproject.toml` (bloc `dependencies` et groupe `dev`)
- Modify: `uv.lock` (régénéré par `uv`)
**Interfaces:**
- Produces: environnement résolu sur Dash 4.4.x avec l'app qui démarre. Les tâches suivantes s'appuient sur cet état.
- [ ] **Step 1: Déterminer la dernière version 4.4.x**
Run: `uv run python -c "import urllib.request, json; d=json.load(urllib.request.urlopen('https://pypi.org/pypi/dash/json')); print([v for v in d['releases'] if v.startswith('4.4.')][-1])"`
Expected: une version type `4.4.x`. Noter cette valeur (ci-après `<DASH_VERSION>`).
- [ ] **Step 2: Consolider la déclaration `dash` dans `pyproject.toml`**
Dans le bloc `dependencies`, remplacer les DEUX lignes actuelles :
```toml
"dash==3.4.0",
"dash[compress]",
```
par une seule ligne épinglée :
```toml
"dash[compress]==<DASH_VERSION>",
```
Dans le groupe `[dependency-groups].dev`, remplacer :
```toml
"dash[testing]",
```
par :
```toml
"dash[testing]==<DASH_VERSION>",
```
- [ ] **Step 3: Résoudre l'environnement**
Run: `uv sync`
Expected: résolution réussie. **Si** échec de résolution mentionnant `dash-leaflet`, `dash-extensions` ou `dash-bootstrap-components` (peer-deps React/Dash) : bumper la lib en cause vers sa dernière version (`uv add 'dash-leaflet@latest'` et/ou `uv add 'dash-extensions@latest'`) puis relancer `uv sync`.
- [ ] **Step 4: Vérifier l'import et la version**
Run: `uv run python -c "import dash, dash_leaflet, dash_extensions, dash_bootstrap_components as dbc; print(dash.__version__, dash_leaflet.__version__, dash_extensions.__version__, dbc.__version__)"`
Expected: `dash.__version__` commence par `4.4`, aucun ImportError.
- [ ] **Step 5: Vérifier le démarrage de l'app**
Run: `DEVELOPMENT=true uv run run.py` puis attendre le log de démarrage et arrêter (Ctrl-C).
Expected: l'app démarre sans traceback (le serveur écoute). Un warning de dépréciation sur `DataTable` est attendu et normal.
- [ ] **Step 6: Commit**
```bash
pre-commit run --files pyproject.toml || true
git add pyproject.toml uv.lock
git commit -m "build: bump dash 3.4 -> 4.4 et résolution des dépendances (#101)"
```
---
### Task 2: Rendre la suite de tests verte sur Dash 4
Le restyling DCC de Dash 4 peut casser des sélecteurs Selenium (classes CSS changées) ou des assertions. On fait passer toute la suite.
**Files:**
- Modify: fichiers de `tests/` dont les assertions/sélecteurs cassent (inconnus a priori)
**Interfaces:**
- Consumes: environnement Dash 4.4 de la Task 1.
- Produces: `uv run pytest` intégralement vert.
- [ ] **Step 1: Lancer la suite complète pour cartographier les cassures**
Run: `uv run pytest`
Expected: soit tout vert (idéal → aller directement au Step 4), soit des échecs. Noter chaque test en échec et sa cause (sélecteur introuvable, timeout, assertion).
- [ ] **Step 2: Corriger chaque test en échec**
Pour chaque échec, appliquer la correction minimale :
- **Sélecteur Selenium cassé** (une classe/structure DCC a changé en Dash 4) : mettre à jour le sélecteur pour cibler la nouvelle structure rendue. Inspecter le DOM réel via `DEVELOPMENT=true uv run run.py` si besoin.
- **Assertion sur du texte/markup restylé** : ajuster l'attendu à la sortie Dash 4.
- **Ne PAS** contourner un échec qui révèle une vraie régression fonctionnelle : le noter pour la Task 3/4.
- [ ] **Step 3: Relancer les tests corrigés**
Run: `uv run pytest <chemins des tests précédemment en échec>`
Expected: PASS pour les tests corrigés.
- [ ] **Step 4: Confirmer la suite complète**
Run: `uv run pytest`
Expected: intégralement vert.
- [ ] **Step 5: Commit**
```bash
pre-commit run --files <fichiers de test modifiés> || true
git add tests/
git commit -m "test: adapter la suite au rendu Dash 4 (#101)"
```
Si aucun test n'a nécessité de modification, ne pas créer de commit vide — passer directement à la Task 3.
---
### Task 3: Vérifier le comportement des Dropdown et nettoyer le CSS en conflit
Dash 4 change deux défauts de `dcc.Dropdown` (`optionHeight='auto'`, `closeOnSelect=False` en multi-select) et restyle les DCC. On vérifie les 11 Dropdown et on retire les overrides CSS devenus inutiles/cassants.
**Files:**
- Modify (si nécessaire) : `src/pages/recherche.py`, `src/pages/tableau.py`, et autres pages contenant des `dcc.Dropdown`
- Modify: `src/assets/css/style.css` (et autres fichiers de `src/assets/css/`) — retrait des overrides en conflit
**Interfaces:**
- Consumes: environnement Dash 4.4, suite verte (Tasks 1-2).
- Produces: Dropdown fonctionnels, CSS sans override cassant.
- [ ] **Step 1: Lister les Dropdown et repérer ceux en multi-select**
Run: `grep -rn "dcc.Dropdown" src/ | grep -v __pycache__`
Puis pour le contexte multi : `grep -rn "multi=True" src/ | grep -v __pycache__`
Expected: la liste des 11 emplacements ; identifier ceux en `multi=True` (impactés par le changement `closeOnSelect`).
- [ ] **Step 2: Vérifier visuellement les Dropdown impactés**
Run: `DEVELOPMENT=true uv run run.py` et ouvrir les pages concernées (notamment `/` recherche et `/tableau` filtres/sélecteur de colonnes).
Expected: les Dropdown s'ouvrent, filtrent, et se ferment correctement. Un multi-select qui reste ouvert après sélection est le nouveau défaut Dash 4 — **acceptable** (ne pas corriger sauf si réellement cassé). Ne corriger (ex. forcer `optionHeight` numérique) que si l'affichage est rompu.
- [ ] **Step 3: Repérer les overrides CSS en conflit avec le restyling DCC**
Run: `grep -rn "Select\|dropdown\|Checklist\|RadioItems\|dash-loading\|input" src/assets/css/`
Expected: liste des règles ciblant les DCC. Pour chacune, juger via le rendu (Step 2) si elle entre en conflit avec le nouveau style Dash 4.
- [ ] **Step 4: Retirer les overrides devenus inutiles ou cassants**
Supprimer (pas commenter) les règles CSS qui cassent ou déforment un composant DCC restylé. Conserver celles qui restent utiles et sans conflit. Ne PAS toucher au bloc CSS lié à `dash_table`/DataTable (hors périmètre, cf. `style.css:512`).
- [ ] **Step 5: Re-vérifier le rendu après nettoyage**
Run: `DEVELOPMENT=true uv run run.py` et re-parcourir les pages modifiées.
Expected: aucun composant cassé ; le rendu adopte le style Dash 4.
- [ ] **Step 6: Commit**
```bash
pre-commit run --files <fichiers modifiés> || true
git add src/
git commit -m "style: accepter le rendu DCC Dash 4 et retirer le CSS en conflit (#101)"
```
Si aucune modification n'a été nécessaire, ne pas créer de commit vide.
---
### Task 4: Smoke test complet et ouverture de la PR
Vérification finale (definition of done) : suite verte + parcours manuel des 6 pages + cartes Leaflet + exports.
**Files:** aucun a priori (corrections de dernière minute possibles selon findings).
**Interfaces:**
- Consumes: état des Tasks 1-3.
- Produces: PR vers `dev`.
- [ ] **Step 1: Suite de tests finale**
Run: `uv run pytest`
Expected: intégralement vert.
- [ ] **Step 2: Smoke test manuel des 6 pages**
Run: `DEVELOPMENT=true uv run run.py` et parcourir : `/` (recherche), `/acheteur`, `/titulaire`, `/tableau`, `/marche`, `/observatoire`.
Expected: chaque page se charge sans erreur console/serveur ; les tableaux DataTable s'affichent et filtrent ; les graphiques Plotly s'affichent.
- [ ] **Step 3: Vérifier spécifiquement les cartes Leaflet et le clustering**
Sur `/acheteur` et `/titulaire` (pages avec cartes), vérifier que la carte Leaflet s'affiche et que le clustering de marqueurs fonctionne (zoom/dézoom regroupe/éclate les marqueurs).
Expected: cartes rendues et interactives. **Si cassé** : bumper `dash-leaflet`/`dash-extensions` (cf. Task 1 Step 3), re-`uv sync`, re-tester, et amender le commit de la Task 1.
- [ ] **Step 4: Vérifier les exports**
Depuis `/tableau`, déclencher un export xlsx et un export csv.
Expected: fichiers téléchargés et ouvrables, contenu cohérent.
- [ ] **Step 5: Corriger les cassures résiduelles éventuelles**
Pour toute cassure trouvée aux Steps 2-4, appliquer la correction minimale et commiter séparément :
```bash
pre-commit run --files <fichiers> || true
git add <fichiers>
git commit -m "fix: <cassure corrigée> sur Dash 4 (#101)"
```
- [ ] **Step 6: Ouvrir la PR vers `dev`**
```bash
git push -u origin <branche-worktree>
gh pr create --base dev --title "Migration Dash 3.4 → 4.4 (#101)" --body "$(cat <<'EOF'
Migration de `dash==3.4.0` vers Dash 4.4.x (pure montée de version, cf. spec `docs/superpowers/specs/2026-07-09-migration-dash-4-design.md`).
## Périmètre
- Bump `dash` + libs écosystème si nécessaire
- Acceptation du look DCC Dash 4, retrait du CSS en conflit
- DataTable inchangées (→ #41), MCP hors périmètre (→ #111)
## Vérification
- `uv run pytest` vert
- Smoke manuel : 6 pages + cartes Leaflet + exports xlsx/csv
Closes #101
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
Expected: PR créée vers `dev`. Le merge déclenchera l'auto-deploy vers test.colibre.fr.
---
## Notes d'exécution
- La migration est **réactive** : les Tasks 2-4 corrigent ce que le bump casse. Si le bump ne casse rien (plausible vu que les ruptures Dash 3.0 sont déjà absorbées), plusieurs tasks se réduisent à leur étape de vérification, sans commit.
- Le point de bascule risqué est concentré dans la Task 1 (résolution/boot) et la Task 4 Step 3 (cartes). En cas de blocage dur d'une lib tierce sans release Dash 4 : arrêter et réévaluer avec le mainteneur du plan.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
# Colonnes configurables pour `rechercher_marches` (MCP) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rendre configurable la sélection des colonnes du tool MCP `rechercher_marches`, avec un champ `lien` vers chaque marché et un paramètre `colonnes` typé en `enum`.
**Architecture:** Le paramètre `colonnes` (défaut = jeu actuel, sinon « remplace ») est validé au runtime contre un ensemble sélectionnable dérivé du schéma de référence `DATA_SCHEMA ∩ duckdb_schema`, uni au défaut. Un champ virtuel `lien = APP_BASE_URL/marche/{uid}` est ajouté en Python après la requête. L'UX passe par une annotation `Literal` (enum) exposée dans le schéma du tool.
**Tech Stack:** Python, dash 4.4 (`mcp_enabled`), DuckDB, Polars, pytest, `typing.Literal`, pydantic `TypeAdapter`.
Design de référence : `docs/superpowers/specs/2026-07-14-mcp-rechercher-marches-colonnes-design.md`.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `from src.mcp.queries import ColonneMarche`).
- `pre-commit run --files <fichiers>` avant chaque `git add` (ruff formate).
- Lancer les tests avec `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici).
- Tests ciblés sur leur propre fichier ; ne pas lancer toute la suite (Selenium/Chrome) avant la fin.
- `lien` toujours présent, non désactivable ; `uid` toujours en sortie.
- Sémantique « remplace » : `colonnes=[...]` renvoie exactement ces colonnes (+ `uid` + `lien`).
- Erreur colonne invalide : `{"error": "colonne inconnue: <col>", "champ": <col>}` (même patron que les erreurs de filtre).
- `base = os.getenv("APP_BASE_URL", "").rstrip("/")` (cohérent avec `src/mcp/auth.py`, `oauth/routes.py`).
---
## File Structure
- Modify `src/mcp/queries.py` — ajoute `SELECTABLE_COLUMNS`, `ColonneMarche`, le paramètre `colonnes` + `lien` dans `search_marches`, et `colonnes_disponibles` dans `describe_schema`.
- Modify `src/mcp/tools.py` — ajoute le paramètre `colonnes: list[ColonneMarche] | None` (annotation enum) + docstring, et le transmet à `queries.search_marches`.
- Modify `tests/mcp/test_queries.py` — comportement `search_marches` / `describe_schema`.
- Modify `tests/mcp/test_tools.py` — enum du paramètre + passthrough bout-en-bout.
---
### Task 1: `queries.py` — colonnes configurables, `lien`, schéma
**Files:**
- Modify: `src/mcp/queries.py`
- Test: `tests/mcp/test_queries.py`
**Interfaces:**
- Consumes : `DATA_SCHEMA` (`src.utils.data`), `duckdb_schema` (= `from src.db import schema as duckdb_schema`, déjà importé), `MARCHES_COLUMNS`, `to_json_records`, `query_marches`, `count_marches`, `build_where`.
- Produces :
- `SELECTABLE_COLUMNS: tuple[str, ...]` — ensemble sélectionnable (défaut (DATA_SCHEMA ∩ duckdb_schema)).
- `ColonneMarche``typing.Literal[SELECTABLE_COLUMNS]`.
- `search_marches(..., colonnes: list[str] | None = None) -> dict` — inchangé si `colonnes is None` ; sinon exactement ces colonnes (+ `uid` + `lien`) ; chaque marché gagne `lien`.
- `describe_schema()` renvoie en plus `colonnes_disponibles: list[str]`, et `colonnes_retournees` inclut `"lien"`.
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter à la fin de `tests/mcp/test_queries.py` :
```python
def test_search_marches_default_columns_and_lien(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
from src.mcp.queries import MARCHES_COLUMNS
result = search_marches(acheteur_id="123")
m = result["marches"][0]
# Toutes les colonnes du défaut + le lien
assert set(MARCHES_COLUMNS).issubset(m.keys())
assert m["lien"] == f"https://colibre.fr/marche/{m['uid']}"
def test_search_marches_custom_columns_replace(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
result = search_marches(acheteur_id="123", colonnes=["objet", "montant"])
m = result["marches"][0]
# « remplace » : exactement les colonnes demandées + uid (clé) + lien
assert set(m.keys()) == {"uid", "objet", "montant", "lien"}
def test_search_marches_custom_columns_include_uid_only_once(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
result = search_marches(acheteur_id="123", colonnes=["uid", "objet"])
m = result["marches"][0]
assert set(m.keys()) == {"uid", "objet", "lien"}
def test_search_marches_invalid_column_rejected():
result = search_marches(acheteur_id="123", colonnes=["nexiste_pas"])
assert result["error"] == "colonne inconnue: nexiste_pas"
assert result["champ"] == "nexiste_pas"
assert "marches" not in result
def test_search_marches_lien_relative_when_base_unset(monkeypatch):
monkeypatch.delenv("APP_BASE_URL", raising=False)
result = search_marches(acheteur_id="123", colonnes=["objet"])
m = result["marches"][0]
assert m["lien"] == f"/marche/{m['uid']}"
def test_describe_schema_exposes_colonnes_disponibles():
from src.mcp.queries import describe_schema
schema = describe_schema()
dispo = schema["colonnes_disponibles"]
assert isinstance(dispo, list) and dispo
# surensemble des colonnes filtrables (inclut le défaut)
assert set(schema["colonnes_filtrables"]).issubset(set(dispo))
assert "lien" in schema["colonnes_retournees"]
```
- [ ] **Step 2: Lancer les tests pour vérifier l'échec**
Run: `uv run pytest tests/mcp/test_queries.py -k "colonnes or lien or disponibles or replace or invalid_column" -v`
Expected: FAIL (`TypeError: search_marches() got an unexpected keyword argument 'colonnes'` et `KeyError: 'colonnes_disponibles'`).
- [ ] **Step 3: Ajouter `import os` et `Literal`**
En tête de `src/mcp/queries.py`, remplacer :
```python
# src/mcp/queries.py
import re
```
par :
```python
# src/mcp/queries.py
import os
import re
from typing import Literal
```
- [ ] **Step 4: Définir `SELECTABLE_COLUMNS` et `ColonneMarche`**
Juste après la définition de `MARCHES_COLUMNS` (la liste des 10 colonnes) dans `src/mcp/queries.py`, ajouter :
```python
# Colonnes sélectionnables par le client : le schéma de référence (présent en
# base) uni aux colonnes du défaut, pour que tout le défaut reste re-sélectionnable
# même si une colonne enrichie (ex. acheteur_nom) est absente de DATA_SCHEMA.
_FILTRABLES = tuple(name for name in DATA_SCHEMA if name in duckdb_schema)
SELECTABLE_COLUMNS = tuple(dict.fromkeys((*MARCHES_COLUMNS, *_FILTRABLES)))
# Enum exposé dans le schéma du tool (UX : liste fermée pour l'agent/le client).
ColonneMarche = Literal[SELECTABLE_COLUMNS]
```
- [ ] **Step 5: Implémenter la résolution des colonnes + `lien` dans `search_marches`**
Dans `src/mcp/queries.py`, remplacer la signature de `search_marches` pour ajouter le paramètre `colonnes` (à la fin, après `filtres_avances`) :
```python
def search_marches(
*,
acheteur_id: str | None = None,
titulaire_id: str | None = None,
cpv: str | None = None,
objet_contient: str | None = None,
montant_min: float | None = None,
montant_max: float | None = None,
date_min: str | None = None,
date_max: str | None = None,
departement: str | None = None,
page: int = 1,
filtres_avances: dict | None = None,
colonnes: list[str] | None = None,
) -> dict:
```
Puis, dans le corps, remplacer le bloc allant de `args = build_where_args(...)` jusqu'au `return {...}` final par :
```python
args = build_where_args(named, filtres_avances)
try:
where_sql, params, order_sql = build_where(args, duckdb_schema)
except FilterError as e:
return {"error": str(e), "champ": e.field}
if colonnes is None:
out_columns = list(MARCHES_COLUMNS)
else:
invalid = [c for c in colonnes if c not in SELECTABLE_COLUMNS]
if invalid:
return {"error": f"colonne inconnue: {invalid[0]}", "champ": invalid[0]}
# uid toujours présent (clé primaire + nécessaire au lien), sans doublon.
out_columns = ["uid"] + [c for c in colonnes if c != "uid"]
page = max(1, int(page))
offset = (page - 1) * PAGE_SIZE
order_by = order_sql or '"dateNotification" DESC, "uid" DESC'
df = query_marches(
where_sql,
params,
columns=out_columns,
order_by=order_by,
limit=PAGE_SIZE,
offset=offset,
)
total = count_marches(where_sql, params)
base = os.getenv("APP_BASE_URL", "").rstrip("/")
marches = to_json_records(df)
for marche in marches:
marche["lien"] = f"{base}/marche/{marche['uid']}"
return {
"meta": {"page": page, "page_size": PAGE_SIZE, "total": total},
"marches": marches,
}
```
- [ ] **Step 6: Exposer `colonnes_disponibles` et `lien` dans `describe_schema`**
Dans `src/mcp/queries.py`, dans `describe_schema`, remplacer le `return {...}` final par :
```python
return {
"colonnes_filtrables": colonnes,
"colonnes_retournees": [*MARCHES_COLUMNS, "lien"],
"colonnes_disponibles": list(SELECTABLE_COLUMNS),
"operateurs": sorted(OPERATORS),
"filtres_nommes": {p: f"{c}__{o}" for p, c, o in _NAMED_FILTERS},
}
```
- [ ] **Step 7: Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/mcp/test_queries.py -v`
Expected: PASS (tous, y compris les tests existants inchangés).
- [ ] **Step 8: Commit**
```bash
pre-commit run --files src/mcp/queries.py tests/mcp/test_queries.py
git add src/mcp/queries.py tests/mcp/test_queries.py
git commit -m "feat(mcp): colonnes configurables + lien dans rechercher_marches (#114)"
```
---
### Task 2: `tools.py` — paramètre `colonnes` enum + docstring
**Files:**
- Modify: `src/mcp/tools.py`
- Test: `tests/mcp/test_tools.py`
**Interfaces:**
- Consumes : `ColonneMarche`, `search_marches` (Task 1).
- Produces : `rechercher_marches(..., colonnes: list[ColonneMarche] | None = None) -> dict` — le paramètre `colonnes` porte un `enum` dans le schéma du tool et est transmis à `queries.search_marches`.
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter à la fin de `tests/mcp/test_tools.py` :
```python
def test_rechercher_marches_colonnes_param_is_enum():
import typing
from pydantic import TypeAdapter
hints = typing.get_type_hints(tools.rechercher_marches)
schema = TypeAdapter(hints["colonnes"]).json_schema()
# list[ColonneMarche] | None -> anyOf[array(items.enum), null]
array_schema = next(s for s in schema["anyOf"] if s.get("type") == "array")
enum = array_schema["items"]["enum"]
assert "objet" in enum
assert "montant" in enum
assert "uid" in enum
def test_rechercher_marches_colonnes_passthrough(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
result = tools.rechercher_marches(acheteur_id="123", colonnes=["objet"])
m = result["marches"][0]
assert set(m.keys()) == {"uid", "objet", "lien"}
assert m["lien"] == f"https://colibre.fr/marche/{m['uid']}"
```
- [ ] **Step 2: Lancer les tests pour vérifier l'échec**
Run: `uv run pytest tests/mcp/test_tools.py -k "colonnes" -v`
Expected: FAIL (`KeyError: 'colonnes'` sur `get_type_hints`, et `TypeError` sur l'appel avec `colonnes=`).
- [ ] **Step 3: Importer `ColonneMarche`**
En tête de `src/mcp/tools.py`, remplacer :
```python
from src.mcp import queries
```
par :
```python
from src.mcp import queries
from src.mcp.queries import ColonneMarche
```
- [ ] **Step 4: Ajouter le paramètre `colonnes` (signature, docstring, appel)**
Dans `src/mcp/tools.py`, remplacer entièrement la fonction `rechercher_marches` par :
```python
@mcp_enabled(name="rechercher_marches", expose_docstring=True)
def rechercher_marches(
acheteur_id: str | None = None,
titulaire_id: str | None = None,
cpv: str | None = None,
objet_contient: str | None = None,
montant_min: float | None = None,
montant_max: float | None = None,
date_min: str | None = None,
date_max: str | None = None,
departement: str | None = None,
page: int = 1,
filtres_avances: dict | None = None,
colonnes: list[ColonneMarche] | None = None,
) -> dict:
"""Recherche paginée de marchés publics (DECP).
Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance
partielle), objet_contient (texte de l'objet), montant_min, montant_max,
date_min / date_max (format YYYY-MM-DD, sur dateNotification),
departement (code département de l'acheteur).
filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les
besoins pointus. Colonnes et opérateurs disponibles via l'outil
schema_donnees().
colonnes : liste optionnelle de colonnes à renvoyer. Par défaut, un jeu
standard (uid, objet, montant, dateNotification, codeCPV, acheteur_id,
acheteur_nom, acheteur_departement_code, titulaire_id, titulaire_nom). Si
fournie, REMPLACE le jeu par défaut (le champ uid reste toujours présent).
Colonnes disponibles via schema_donnees().colonnes_disponibles.
Chaque marché renvoyé contient en plus un champ `lien` (URL de la fiche
marché sur colibre).
page : numéro de page (50 résultats par page).
Retourne {meta: {page, page_size, total}, marches: [...]}.
"""
track_mcp_tool("rechercher_marches", query=objet_contient)
return queries.search_marches(
acheteur_id=acheteur_id,
titulaire_id=titulaire_id,
cpv=cpv,
objet_contient=objet_contient,
montant_min=montant_min,
montant_max=montant_max,
date_min=date_min,
date_max=date_max,
departement=departement,
page=page,
filtres_avances=filtres_avances,
colonnes=colonnes,
)
```
- [ ] **Step 5: Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/mcp/test_tools.py -v`
Expected: PASS (tous).
- [ ] **Step 6: Vérification finale des tests MCP**
Run: `uv run pytest tests/mcp/ -v`
Expected: PASS (aucune régression dans les autres modules MCP).
- [ ] **Step 7: Commit**
```bash
pre-commit run --files src/mcp/tools.py tests/mcp/test_tools.py
git add src/mcp/tools.py tests/mcp/test_tools.py
git commit -m "feat(mcp): parametre colonnes (enum) pour rechercher_marches (#114)"
```
---
## Notes d'implémentation
- **Sécurité SQL** : `query_marches` (`src/db.py`) interpole les colonnes sans quoting. La validation `c not in SELECTABLE_COLUMNS` (Step 5, Task 1) est la barrière — ne pas la retirer. `SELECTABLE_COLUMNS` ne contient que des noms issus du schéma / du défaut, jamais d'entrée libre.
- **`Literal[SELECTABLE_COLUMNS]`** : `Literal` accepte un tuple de littéraux (vérifié : produit bien `items.enum` via `TypeAdapter`). L'ordre suit `MARCHES_COLUMNS` puis les colonnes filtrables.
- **`get_type_hints`** dans le test enum : `tools.py` n'utilise pas `from __future__ import annotations`, donc l'annotation est un objet résoluble ; `ColonneMarche` doit être importable au niveau module (Step 3).
```
```
@@ -0,0 +1,341 @@
# Comptes utilisateurs — Design
**Date** : 2026-04-20
**Issue** : #73
**Branche** : `feature/73_compte_utilisateur`
**Spec initiale** : `comptes_utilisateurs.md`
## Objectif
Poser les fondations d'un système de comptes utilisateurs pour decp.info : inscription avec vérification d'email, connexion, réinitialisation de mot de passe, page compte permettant de changer son mot de passe. Ces fondations doivent permettre d'ajouter plus tard d'autres fonctionnalités (alertes email, préférences, etc.) sans avoir à retoucher l'authentification.
## Décisions de conception
| Sujet | Choix |
| ---------------------------------- | --------------------------------------------------------------- |
| Sessions | Flask-Login |
| Reset password | Token stocké en base (usage unique) |
| Vérification email à l'inscription | Obligatoire avant connexion |
| Règles mot de passe | Longueur ≥ 8 caractères, rien d'autre |
| Hashage mot de passe | `werkzeug.security` (scrypt par défaut) |
| Lien navbar | Un seul lien « Connexion » (dropdown avec email quand connecté) |
| Pages | 5 pages Dash séparées avec URLs explicites |
| Base de données utilisateurs | SQLite, chemin configurable via `USERS_DB_PATH` |
| Migrations | `CREATE TABLE IF NOT EXISTS` au démarrage |
| Accès SQLite | `sqlite3` stdlib, SQL brut avec requêtes paramétrées |
| Envoi emails | Flask-Mail |
| Rate limiting | Aucun pour l'instant |
| Tests | Unitaires + intégration Flask (pas de Selenium) |
| Format emails | HTML + texte brut (multipart) |
| CSRF | Flask-WTF (CSRFProtect global) |
## Architecture
### Nouvelles dépendances (`pyproject.toml`)
- `flask-login` — gestion des sessions utilisateur
- `flask-mail` — envoi SMTP
- `flask-wtf` — protection CSRF (uniquement CSRFProtect, pas d'utilisation des formulaires WTForms)
- `email-validator` — validation du format email à l'inscription
`itsdangerous` est déjà une dépendance transitive de Flask.
### Nouveau package `src/auth/`
| Fichier | Rôle |
| ---------------------- | ------------------------------------------------------------------------------------------------------ |
| `src/auth/__init__.py` | Exports publics (`current_user`, `login_required`, `init_auth`) |
| `src/auth/db.py` | Connexion SQLite, création schéma, requêtes CRUD |
| `src/auth/models.py` | Classe `User` compatible Flask-Login |
| `src/auth/mailer.py` | Init Flask-Mail, helpers `send_verification_email`, `send_reset_email` |
| `src/auth/tokens.py` | Génération/validation tokens (vérif email, reset password) |
| `src/auth/routes.py` | Routes Flask POST (login, signup, logout, request reset, perform reset, verify email, change password) |
| `src/auth/setup.py` | `init_auth(app)` appelé depuis `src/app.py` |
### Pourquoi des routes Flask natives plutôt que des callbacks Dash
Les actions d'authentification nécessitent de définir/effacer des cookies de session, rediriger entre pages, et manipuler `flask.session` — c'est le territoire de Flask, pas de Dash. Les callbacks Dash renvoient des composants React, pas des redirections HTTP avec cookies. Les pages Dash s'occupent du **rendu** (formulaires HTML), les routes Flask s'occupent des **actions** (POST).
### Nouvelles pages Dash (`src/pages/`)
| Fichier | URL | Description |
| ------------------------------- | ----------------------------- | --------------------------------------------------------------------------------- |
| `connexion.py` | `/connexion` | Formulaire email + mot de passe, lien vers inscription, lien mot de passe oublié |
| `inscription.py` | `/inscription` | Formulaire email + mot de passe + confirmation |
| `compte.py` | `/compte` | Protégée. Affiche l'email, formulaire changement mot de passe, bouton déconnexion |
| `mot_de_passe_oublie.py` | `/mot-de-passe-oublie` | Formulaire de demande (email seul) |
| `reinitialiser_mot_de_passe.py` | `/reinitialiser-mot-de-passe` | Formulaire nouveau mot de passe (token en query string) |
| `verification_email.py` | `/verification-email` | Page de statut après clic sur lien de vérification |
### Modifications de fichiers existants
- **`src/app.py`** : ajout de `init_auth(app)` après init du cache. Modification de la navbar pour afficher le lien « Connexion » (déconnecté) ou un `dbc.DropdownMenu` (connecté).
- **`pyproject.toml`** : ajout des dépendances listées ci-dessus et des variables d'env de tests dans `[tool.pytest.ini_options].env`.
- **`.template.env`** : ajout des nouvelles variables d'environnement.
### Nouvelles variables d'environnement
| Variable | Exemple | Description |
| --------------- | ------------------- | ----------------------------------------------------------------------- |
| `USERS_DB_PATH` | `users.sqlite` | Chemin du fichier SQLite utilisateurs |
| `SECRET_KEY` | (32 octets random) | Clé Flask pour sessions + signatures. Fail fast au démarrage si absente |
| `SMTP_HOST` | `smtp.example.com` | Serveur SMTP |
| `SMTP_PORT` | `587` | Port SMTP |
| `SMTP_USERNAME` | `…` | Identifiant SMTP |
| `SMTP_PASSWORD` | `…` | Mot de passe SMTP |
| `SMTP_USE_TLS` | `True` | STARTTLS |
| `MAIL_FROM` | `noreply@decp.info` | Expéditeur |
| `APP_BASE_URL` | `https://decp.info` | Base URL pour construire les liens absolus dans les emails |
Si `SECRET_KEY` est absente au démarrage → `raise RuntimeError` (fail fast). Si les variables SMTP sont absentes → warning au démarrage, l'app démarre mais toute route déclenchant un envoi d'email retourne une erreur lisible.
## Schéma SQLite
Trois tables créées au démarrage via `CREATE TABLE IF NOT EXISTS`. Connexion initialisée avec :
```python
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
```
### Table `users`
| Colonne | Type | Contraintes |
| ---------------- | ------- | ------------------------------------- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `email` | TEXT | NOT NULL UNIQUE (stocké en lowercase) |
| `password_hash` | TEXT | NOT NULL |
| `email_verified` | INTEGER | NOT NULL DEFAULT 0 (0 ou 1) |
| `created_at` | TEXT | NOT NULL (ISO 8601 UTC) |
| `updated_at` | TEXT | NOT NULL (ISO 8601 UTC) |
Index : `CREATE UNIQUE INDEX idx_users_email ON users(email)`.
### Table `email_verification_tokens`
| Colonne | Type | Contraintes |
| ------------ | ------- | --------------------------------------------------- |
| `token_hash` | TEXT | PRIMARY KEY (SHA-256 hex du token) |
| `user_id` | INTEGER | NOT NULL, FOREIGN KEY → users(id) ON DELETE CASCADE |
| `expires_at` | TEXT | NOT NULL (ISO 8601 UTC, typiquement +24h) |
| `created_at` | TEXT | NOT NULL |
### Table `password_reset_tokens`
| Colonne | Type | Contraintes |
| ------------ | ------- | --------------------------------------------------- |
| `token_hash` | TEXT | PRIMARY KEY (SHA-256 hex du token) |
| `user_id` | INTEGER | NOT NULL, FOREIGN KEY → users(id) ON DELETE CASCADE |
| `expires_at` | TEXT | NOT NULL (ISO 8601 UTC, typiquement +1h) |
| `created_at` | TEXT | NOT NULL |
### Principes sur les tokens
- **Stockage hashé** : on stocke `sha256(token)`, jamais le token en clair. Si la DB fuite, les tokens actifs restent inutilisables.
- **Génération** : `secrets.token_urlsafe(32)` → ~43 caractères URL-safe.
- **Usage unique** : à la validation réussie d'un token, on fait `DELETE FROM <table> WHERE user_id = ?` pour invalider tous les tokens actifs de l'utilisateur (pas uniquement celui utilisé).
- **Nouvelle demande** : avant de créer un nouveau token de reset, on supprime les anciens du même utilisateur.
- **Nettoyage périodique** : au démarrage de l'app, `DELETE FROM <table> WHERE expires_at < now()` sur les deux tables. Pas de cron nécessaire.
## Flux utilisateurs
### Flux A — Inscription
1. GET `/inscription` → formulaire Dash (email, mot de passe, confirmation).
2. Soumission POST `/auth/signup` avec token CSRF.
3. Serveur valide :
- Format email (`email-validator`).
- Longueur mot de passe ≥ 8.
- `password == password_confirm`.
- Email non déjà pris (lookup lowercase).
4. Création : `INSERT INTO users` avec `email_verified=0`, hash via `werkzeug.security.generate_password_hash`.
5. Génération token 32 octets, `INSERT INTO email_verification_tokens` (hash, expiration +24h).
6. Envoi email HTML+texte contenant `{APP_BASE_URL}/verification-email?token=…`.
7. **Si envoi KO** : rollback (`DELETE` user + token), erreur « Erreur technique, réessayez plus tard ».
8. **Si envoi OK** : redirection `/connexion?pending_verification=1` avec message « Compte créé, vérifie ton email ».
### Flux B — Vérification d'email
1. L'utilisateur clique sur le lien dans l'email → GET `/verification-email?token=…`.
2. La page Dash déclenche côté serveur la vérification via GET `/auth/verify-email?token=…`.
3. Serveur :
- Hash le token reçu, cherche dans `email_verification_tokens` non expiré.
- Si trouvé : `UPDATE users SET email_verified=1, updated_at=now()`, `DELETE FROM email_verification_tokens WHERE user_id = ?`, redirect `/connexion?verified=1`.
- Sinon : redirect vers page d'erreur avec bouton « Renvoyer l'email de vérification ».
### Flux C — Connexion
1. GET `/connexion` → formulaire Dash.
2. POST `/auth/login`.
3. Serveur :
- Lookup user par email lowercase.
- **Toujours** appeler `check_password_hash()` (même si user inexistant, avec un hash bidon pré-calculé) pour uniformiser le temps de réponse.
- Si user existe **et** password OK **et** `email_verified=1``login_user(user)` → redirect vers `next` validé ou `/compte`.
- Si user existe mais `email_verified=0` → message « Vérifie d'abord ton adresse email » + bouton « Renvoyer email ».
- Sinon → message générique « Identifiants invalides ».
### Flux D — Mot de passe oublié
1. GET `/mot-de-passe-oublie` → formulaire (email seul).
2. POST `/auth/request-password-reset`.
3. Serveur :
- Message de retour **toujours identique** : « Si un compte existe avec cet email, un lien de réinitialisation a été envoyé. »
- Si user trouvé : `DELETE` des anciens tokens reset, création d'un nouveau (expiration +1h), envoi email avec `{APP_BASE_URL}/reinitialiser-mot-de-passe?token=…`.
- Si envoi SMTP KO : log serveur + message d'erreur technique (dérogation à la règle de non-énumération, pour éviter de masquer une panne SMTP).
### Flux E — Réinitialisation du mot de passe
1. GET `/reinitialiser-mot-de-passe?token=…`.
2. Page Dash : vérifie le token (sans le consommer) via un callback au rendu. Si invalide → message « Lien invalide ou expiré ». Si valide → affiche formulaire (nouveau mot de passe + confirmation).
3. POST `/auth/reset-password` avec token en champ caché + CSRF.
4. Serveur :
- Revalide le token (hash, expiration).
- Valide le mot de passe (longueur, confirmation).
- `UPDATE users SET password_hash=…, updated_at=now()`.
- `DELETE FROM password_reset_tokens WHERE user_id = ?`.
- Redirect `/connexion?password_changed=1`.
### Flux F — Page compte
1. GET `/compte` protégée par `@login_required`. Déconnecté → redirect `/connexion?next=/compte`.
2. Page affiche l'email + formulaire « changer mot de passe » (mot de passe actuel + nouveau + confirmation) + bouton « Déconnexion ».
3. POST `/auth/change-password` : vérifie mot de passe actuel, valide le nouveau, update.
4. POST `/auth/logout` : `logout_user()`, redirect `/`.
### Navbar (`src/app.py`)
- **Déconnecté** : lien « Connexion » pointant vers `/connexion`, placé à droite (après « À propos »).
- **Connecté** : `dbc.DropdownMenu` affichant l'email tronqué (30 caractères max) avec :
- Item « Mon compte » → `/compte`
- Item « Déconnexion » → soumission POST vers `/auth/logout` (form avec CSRF)
## Gestion des erreurs et sécurité
### Messages d'erreur utilisateur
- Transit des messages via **query string** (ex : `?error=invalid_credentials`, `?verified=1`) avec mapping code → message côté page Dash. Plus simple à tester et stateless que `flash()`.
- Rendu via `dbc.Alert` en haut du formulaire (couleurs danger/success/info).
### Sécurité des cookies et sessions
- `SESSION_COOKIE_HTTPONLY = True`
- `SESSION_COOKIE_SAMESITE = 'Lax'`
- `SESSION_COOKIE_SECURE = True` en production (conditionnel sur `DEVELOPMENT=False`)
- Durée de session : 30 jours (« remember me » implicite, permanent session)
### Protection CSRF
- `CSRFProtect(app.server)` installé globalement depuis `init_auth`.
- Chaque formulaire Dash inclut un champ caché `csrf_token` rempli via un callback au rendu (`generate_csrf()` depuis `flask_wtf.csrf`).
- Toutes les routes POST sont protégées automatiquement.
### Protection contre l'énumération de comptes
- Login : même message pour email inexistant et mot de passe faux.
- Request password reset : même message pour email existant et inexistant (sauf en cas de panne SMTP, où l'erreur technique prime).
- Timing : toujours appeler `check_password_hash` même si user inexistant.
### Validation des redirections `next`
```python
def safe_next(url: str, fallback: str = "/") -> str:
if not url or not url.startswith("/") or url.startswith("//"):
return fallback
return url
```
### Tokens
- Entropie : 32 octets via `secrets.token_urlsafe(32)`.
- Stockage : SHA-256 hex.
- Usage unique (voir schéma SQLite).
- Expiration : 24h (vérif email), 1h (reset password).
### Mots de passe dans les logs
Audit rapide du code au moment de la revue : aucun `print`/`logger.debug` ne doit logger mot de passe ou token en clair. Formulaires d'inscription/login/reset loggent uniquement l'email (et encore, seulement en cas d'erreur).
### Pannes SMTP
- **Inscription** : envoi synchrone, échec → rollback de l'inscription, erreur technique affichée.
- **Reset password** : envoi synchrone, échec → erreur technique (dérogation au message générique).
- **Changement de mot de passe** : pas d'email → pas de dépendance SMTP pour ce flux.
- **Vérification email (renvoyer)** : échec → erreur technique.
### Configuration manquante au démarrage
- `SECRET_KEY` absente → `raise RuntimeError` (fail fast).
- Variables SMTP absentes → warning loggué, app démarre ; routes nécessitant SMTP retournent erreur lisible.
- `USERS_DB_PATH` absente → fallback sur `users.sqlite` à la racine.
- `APP_BASE_URL` absente → fallback sur `http://localhost:8050`.
## Emails (HTML + texte)
Templates simples dans `src/auth/templates/emails/` :
- `verify_email.html` et `verify_email.txt`
- `reset_password.html` et `reset_password.txt`
Chaque email contient :
- Salutation neutre (« Bonjour, »)
- Une phrase expliquant le contexte
- Le lien en clair (cliquable en HTML)
- Une phrase sur l'expiration (« Ce lien est valide 24h / 1h »)
- Un mot final (« Si vous n'êtes pas à l'origine de cette demande, ignorez cet email »)
- Pas de logo complexe ; un en-tête texte `decp.info` en gras suffit
## Tests
### Structure `tests/auth/`
- `conftest.py` — fixtures : base SQLite temporaire, capture emails (monkeypatch `mail.send`), `client` (Flask test client), `authed_client`, `SECRET_KEY` fixe.
- `test_db.py` — CRUD users, unicité email case-insensitive, cascade suppression tokens.
- `test_tokens.py` — génération, hash stable, validation, rejet expiré/invalide, invalidation à l'usage.
- `test_signup.py` — GET formulaire, POST validé, doublon rejeté, password trop court rejeté, email invalide rejeté, login avant vérif refusé.
- `test_verify_email.py` — token valide marque vérifié et supprime token, token expiré rejeté, token déjà utilisé rejeté.
- `test_login.py` — succès (cookie posé), mot de passe faux rejeté (message générique), email inexistant rejeté avec même message, user non vérifié rejeté, redirection `next` validée (rejet URLs absolues).
- `test_password_reset.py` — demande envoie email, email inexistant → même message sans envoi, token valide permet changement, token expiré rejeté, login avec nouveau mot de passe OK, ancien KO.
- `test_account.py``/compte` redirige si déconnecté, change password OK avec mot de passe actuel correct, KO sinon, logout efface session.
- `test_csrf.py` — POST sans token rejeté (403), POST avec token valide accepté.
### Fixture clé
```python
@pytest.fixture
def mail_outbox(monkeypatch):
outbox = []
monkeypatch.setattr(
"src.auth.mailer.mail.send",
lambda msg: outbox.append(msg),
)
return outbox
```
### Configuration pytest (`pyproject.toml`)
Ajout au bloc `[tool.pytest.ini_options].env` :
```
USERS_DB_PATH=tests/users.test.sqlite
SECRET_KEY=test-secret-do-not-use-in-prod
MAIL_FROM=test@decp.info
APP_BASE_URL=http://localhost:8050
SMTP_HOST=localhost
SMTP_PORT=25
```
### Non couvert (explicitement)
- Tests Selenium sur les formulaires (ajoutables plus tard si parcours critiques).
- Tests de charge SMTP.
- Tests cross-browser.
- Vérification que les tests existants (`tests/test_main.py`) ne cassent pas : l'auth est ajoutée sans modifier les comportements existants (navbar garde un lien « Connexion » par défaut, pas de redirection ajoutée sur les pages publiques).
## Risques et contraintes
- **SMTP indisponible en dev** : tout développeur doit pouvoir tester sans SMTP réel. Prévoir un mode dev où les emails sont loggés dans la console au lieu d'être envoyés (ex : `MAIL_SUPPRESS_SEND=True` de Flask-Mail quand `DEVELOPMENT=True`, avec impression du lien dans le log).
- **Clé de signature (SECRET_KEY)** : la rotation invalidera toutes les sessions actives. À documenter dans le README de deploy.
- **Base SQLite + WAL** : la base est accessible en lecture/écriture par le process de l'app. En cas de plusieurs workers gunicorn, WAL gère correctement la concurrence pour un nombre modéste d'écritures (inscriptions, resets, login updates) — acceptable pour le volume attendu.
- **Concurrence sur `users` depuis plusieurs workers gunicorn** : une seule connexion SQLite par process avec WAL est OK ; pas de connexion partagée entre workers.
@@ -0,0 +1,100 @@
# Design : amélioration du style des exports Excel (#83)
**Date :** 2026-06-23
**Issue :** #83
## Contexte
Les 6 fonctions d'export Excel du projet produisent des fichiers basiques : toutes les colonnes ont la même largeur par défaut et les en-têtes ne sont pas mis en valeur. L'objectif est d'améliorer la lisibilité en imitant les largeurs de colonnes du `DataTable` commun et en stylisant les en-têtes.
## Périmètre
Toutes les fonctions d'export Excel :
| Fichier | Fonction | Page |
| --------------------------- | ---------------------------------- | --------------- |
| `src/pages/tableau.py` | `download_data` | `/tableau` |
| `src/pages/acheteur.py` | `download_acheteur_data` | `/acheteur` |
| `src/pages/acheteur.py` | `download_filtered_acheteur_data` | `/acheteur` |
| `src/pages/titulaire.py` | `download_titulaire_data` | `/titulaire` |
| `src/pages/titulaire.py` | `download_filtered_titulaire_data` | `/titulaire` |
| `src/pages/observatoire.py` | `download_observatoire` | `/observatoire` |
## Solution retenue : wrapper `write_styled_excel` (approche C)
Le besoin de `text_wrap` impose de créer le workbook manuellement (`xlsxwriter.Workbook` avec `default_format_properties`). Répéter ce boilerplate 6 fois est peu maintenable, donc on centralise dans une fonction utilitaire dans `src/utils/table.py`.
## Détail du design
### Constantes et wrapper — `src/utils/table.py`
```python
import xlsxwriter
_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI
_EXCEL_HEADER_FORMAT = {
"bold": True,
"bg_color": "#b33821", # couleur primaire de l'app
"font_color": "white",
}
_EXCEL_COLUMN_WIDTHS = { # tirés des minWidth du DataTable commun (src/figures.py:269)
"objet": 350,
"acheteur_nom": 250,
"titulaire_nom": 250,
"acheteur_id": 160,
}
def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None:
col_widths = {
col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0))
for col in df.columns
}
wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}})
ws = wb.add_worksheet(worksheet)
df.write_excel(
workbook=wb,
worksheet=ws,
header_format=_EXCEL_HEADER_FORMAT,
column_widths=col_widths,
)
wb.close()
```
**Comportement :**
- `text_wrap=True` via `default_format_properties` s'applique à toutes les cellules de données.
- Chaque colonne reçoit au minimum 132 px (≈ 3.5 cm) ; les colonnes avec largeur explicite utilisent leur valeur si elle est supérieure.
- En-têtes : fond rouge (`#b33821`), texte blanc, gras.
- Le wrapper accepte un `pl.DataFrame` ; les callbacks qui travaillent avec une `LazyFrame` appellent `.collect()` (éventuellement `engine="streaming"`) avant d'appeler le wrapper.
### Mise à jour des 6 callbacks
Chaque bloc `def to_bytes(buffer):` est remplacé par un appel au wrapper.
**Exemples :**
```python
# tableau.py — download_data
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
# acheteur.py — download_acheteur_data (worksheet dynamique selon l'année)
def to_bytes(buffer):
write_styled_excel(
df_to_download, buffer,
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
)
# acheteur.py — download_filtered_acheteur_data
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
Titulaire et observatoire : même pattern.
## Décisions clés
- **`autofit=False`** (pas d'autofit Polars) : les largeurs sont entièrement contrôlées par `col_widths`.
- **`text_wrap` via workbook** : seule façon d'appliquer le wrapping à toutes les cellules via `write_excel` (les paramètres `column_formats`/`dtype_formats` de Polars n'exposent pas les propriétés xlsxwriter de format cellule).
- **Constantes privées** (`_EXCEL_*`) : non exportées, consommées uniquement par `write_styled_excel`.
- **Worksheet dynamique** : `download_acheteur_data` et `download_titulaire_data` utilisent l'année comme nom de feuille quand elle est définie — conservé via le paramètre `worksheet`.
@@ -0,0 +1,145 @@
# Défilement horizontal ergonomique des tableaux (#82)
## Problème
Les tableaux de données (`DataTable` Dash) sont souvent plus larges que l'écran et
**débordent vers la droite**. Aujourd'hui aucun `overflowX` n'est défini sur leur
conteneur : le tableau étire la page entière, et le seul moyen de faire défiler
horizontalement est la **barre de défilement de la fenêtre du navigateur**, tout en
bas du viewport. Cette barre :
- est discrète et fait défiler **toute la page** (pas seulement le tableau) ;
- n'est pas comprise comme « le moyen de voir le reste du tableau ».
De plus, les tableaux dépassent souvent du bas de l'écran : une barre placée en bas
du tableau serait invisible sans scroller.
## Objectif
Rendre le défilement horizontal **évident et toujours accessible**, et garder les
**en-têtes de colonnes visibles** pendant le défilement vertical, sans introduire de
zone scrollable imbriquée gênante.
## Approche retenue (option B — sticky au niveau page)
Le tableau **reste dans le flux de la page** (pas de conteneur à hauteur fixe, pas de
scroll imbriqué). On combine deux mécanismes :
1. **En-têtes collants**`position: sticky; top: 0` sur la ligne d'en-tête du
tableau. Quand l'utilisateur descend dans la page, les en-têtes se figent en haut
de la fenêtre au lieu d'être « avalés ».
2. **Barre de défilement horizontale miroir en haut** — un petit élément placé
juste au-dessus du tableau, lui aussi `sticky` en haut, dont le défilement
horizontal est **synchronisé** avec celui du tableau. Elle est donc toujours
visible dès qu'on voit le haut du tableau, et pilote le défilement horizontal sans
devoir descendre en bas du tableau.
La barre miroir et les en-têtes collants se figent ensemble en haut de la fenêtre :
l'utilisateur garde en permanence le repère des colonnes **et** le contrôle du
défilement horizontal.
### Pourquoi pas l'option A (tableau « fenêtré » à hauteur fixe)
Écartée volontairement : un conteneur à hauteur fixe avec scroll interne crée un
**scroll imbriqué** (la molette agit d'abord sur le tableau, pas sur la page), source
de confusion. L'option B garde un comportement de défilement vertical unique (celui
de la page) ; seul le défilement horizontal est « custom ».
## Contrainte technique CSS à gérer
Un conteneur en `overflow-x: auto` devient automatiquement un conteneur de
défilement **vertical** (règle CSS : `overflow-y: visible` recalculé en `auto` dès
que l'autre axe n'est pas `visible`), ce qui **casse** le `position: sticky; top: 0`
des en-têtes par rapport à la page.
Conséquences pour l'implémentation :
- Le **défilement horizontal réel** doit se faire dans un conteneur dédié en
`overflow-x: auto` ; mais ce conteneur ne peut pas, en même temps, héberger des
en-têtes sticky « page ». La barre miroir du haut résout ce conflit : c'est **elle**
qui porte le `overflow-x: auto`, séparée du tableau, et synchronisée par JS.
- Il faudra **vérifier et neutraliser au besoin l'`overflow` interne** que Dash
DataTable applique à ses propres conteneurs (`.dash-spreadsheet-container`,
`.dash-spreadsheet-inner`) pour que le sticky des en-têtes fonctionne.
- Le rendu réel de Dash DataTable doit être inspecté avant de figer le CSS : la
structure DOM exacte (où poser `sticky`, quel élément porte la largeur totale)
conditionne la solution. **À valider en testant dans le navigateur.**
## Composants
| Élément | Rôle | Emplacement probable |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| CSS en-têtes sticky | `position: sticky; top: 0` sur la ligne d'en-tête, z-index, fond opaque | `src/assets/css/style.css` (cible `.marches_table`) |
| Barre miroir (markup) | `<div>` scrollable au-dessus du tableau, avec un enfant à la largeur du tableau | composant partagé `DataTable` / wrapper dans `src/figures.py` |
| Synchronisation JS | lier scrollLeft barre ↔ tableau ; recopier la largeur du tableau dans la barre ; recalcul au resize / changement de données | nouveau fichier `src/assets/js/*.js` (assets Dash, chargé automatiquement) |
| CSS barre miroir | hauteur, sticky `top: 0`, masquage si pas de débordement | `src/assets/css/style.css` |
## Portée
Les **quatre pages** utilisant `className="marches_table"` :
`/tableau`, `/acheteur`, `/titulaire`, `/observatoire`. La solution passe par le
composant `DataTable` partagé et la classe CSS `marches_table`, donc l'effort est
quasi identique pour une ou quatre pages.
## Flux de données / interactions
1. Au rendu (et à chaque changement de données / largeur de fenêtre), le JS mesure la
largeur totale du tableau et la reporte dans l'élément interne de la barre miroir →
la barre miroir affiche une glissière proportionnelle.
2. Événement `scroll` sur la barre miroir → on applique `scrollLeft` au conteneur du
tableau ; et inversement (scroll du tableau → barre miroir), avec garde anti-boucle.
3. Si le tableau ne déborde pas, la barre miroir est masquée.
## Cas limites
- **Pas de débordement** : barre miroir masquée, en-têtes sticky inoffensifs.
- **Pagination / re-render** (pages en `page_action="custom"`) : la largeur peut
changer → la synchro doit se recalculer après mise à jour des données.
- **Resize de la fenêtre** : recalcul de la largeur miroir.
- **Plusieurs tableaux sur une page** (`/observatoire`, `/titulaire`, `/acheteur` ont
plusieurs `marches_table`) : le JS doit gérer chaque tableau indépendamment.
- **Persistance / tri / filtre** : ne doit pas casser la synchro (réattacher les
écouteurs si le DOM est recréé).
## Tests / validation
- Vérification **manuelle dans le navigateur** (point critique vu l'incertitude sur le
DOM de DataTable) : débordement horizontal sur `/tableau`, sticky des en-têtes en
scrollant, synchro des deux barres, comportement sur les pages à tableaux multiples.
- S'assurer que les tests Selenium existants ne régressent pas
(`pytest tests/test_main.py`).
## Hors périmètre (YAGNI)
- Colonnes figées (1re colonne sticky horizontalement).
- Réduction du nombre de colonnes par défaut / refonte du sélecteur de colonnes.
- `overscroll-behavior` et zones scrollables imbriquées (option A écartée).
## Verdict du spike
Le spike (Steps 13 exécutés par l'utilisateur en DevTools) doit valider les points suivants. En cas de déviation, le reste du plan reste applicable ; seule l'implémentation du sticky (Task 2) ajustera sa stratégie.
### Éléments attendus du DOM
- **Conteneur scrollable** : `.dash-spreadsheet-container` (enfant direct de `.marches_table`)
- **Conteneur interne** : `.dash-spreadsheet-inner` (enfant de `.dash-spreadsheet-container`) — peut aussi porter un `overflow` interne
- **En-têtes** : sélecteur exact `th.dash-header` (dans un `tr` au sein du tableau)
- **Table complète** : `.cell-table` avec ses dimensions (`scrollWidth` >> `clientWidth` du parent → débordement confirmé)
### Hypothèse sticky
L'astuce CSS consiste à :
1. Neutraliser l'`overflow` sur `.dash-spreadsheet-container` et `.dash-spreadsheet-inner` en les ramenant à `overflow: visible` (ou en supprimant le style si possible)
2. Appliquer `position: sticky; top: 0; z-index: 10; background: #fff` aux en-têtes `th.dash-header`
**Verdict attendu :** ✅ Oui — les en-têtes restent collés au haut de la fenêtre quand on scroll verticalement la page, sans recours à du JS supplémentaire (hormis la synchro scrollLeft pour le miroir).
**Si verdict = ❌ Non :** les en-têtes seront pilotés entièrement en JS (repositionnement au scroll), avec synchronisation du scroll vertical. Le reste du plan (barre miroir, synchro horizontale) reste valable.
### Références (à noter lors du spike)
- `scrollWidth` et `clientWidth` de la table vs. ses parents
- Styles `overflow` en _Computed_ sur `.dash-spreadsheet-container`, `.dash-spreadsheet-inner` et `.cell-table`
- Résultat du test d'hypothèse JS (sticky page fonctionne-t-il ?)
@@ -0,0 +1,168 @@
# Migration des emails transactionnels vers Brevo (API + templates hébergés)
**Issue :** #87
**Branche :** `87_brevo` (partie de `feature/73_compte_utilisateur`)
**Date :** 2026-06-24
## Contexte
Les emails transactionnels de decp.info (vérification d'adresse à l'inscription,
réinitialisation de mot de passe) sont aujourd'hui envoyés via **Flask-Mail + SMTP**
(boîte mail Infomaniak standard). Cette approche n'offre aucune visibilité sur la
délivrabilité (le mail de reset est-il arrivé ?), expose au risque de suspension de
la boîte mail en cas de pic, et délivre moins bien.
On migre vers **Brevo** (compte gratuit existant) en utilisant son **API
transactionnelle** via le SDK officiel `brevo-python` **v5** (`5.0.0rc1`, une
pré-release / release candidate, à installer avec `--pre` et épinglée strictement)
et ses **templates hébergés**. Brevo est une société française, données en UE
(bon pour le RGPD).
L'API v5 (vérifiée par introspection du paquet) :
```python
from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem
from brevo.core.api_error import ApiError
client = Brevo(api_key="...", headers={"X-Sib-Sandbox": "drop"}) # headers optionnels
client.transactional_emails.send_transac_email(
template_id=123,
params={"link": "https://..."},
sender=SendTransacEmailRequestSender(email="noreply@decp.info", name="decp.info"),
to=[SendTransacEmailRequestToItem(email="dest@example.com")],
)
```
## Objectif
Remplacer entièrement le transport SMTP/Flask-Mail par l'API Brevo, avec deux
templates hébergés côté Brevo (un par email), sans changer l'interface publique du
mailer ni le comportement des routes appelantes.
## Périmètre
### Dans le périmètre
- Réécriture de `src/auth/mailer.py` pour utiliser le SDK Brevo.
- Deux templates Brevo (déjà créés côté web), référencés par leur ID.
- Mise à jour des dépendances et des variables d'environnement.
- Suppression des templates Jinja d'email locaux.
- Réécriture des tests du mailer (hermétiques) + chemin sandbox optionnel.
### Hors périmètre
- Le contenu/design des templates Brevo (géré dans l'interface web Brevo).
- Les autres usages d'email du projet s'il en existe (variables legacy
`SENDER_SERVER_DOMAIN`, `LOGIN_EMAIL`, `FROM_EMAIL`, `TO_EMAIL` de `.template.env`) :
à **vérifier** avant suppression ; ne pas y toucher si utilisées ailleurs.
## Architecture
### Interface publique inchangée
Les fonctions appelées depuis `src/auth/routes.py` (lignes 49 et 112) gardent
exactement leur signature — **`routes.py` n'est pas modifié** :
```python
send_verification_email(email: str, token: str) -> None
send_reset_email(email: str, token: str) -> None
```
### Composants de `src/auth/mailer.py` (réécrit)
| Fonction | Rôle |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `init_mailer()` | Construit le client `Brevo(api_key=...)` depuis `BREVO_API_KEY` (+ header sandbox si `BREVO_SANDBOX=true`). Plus de paramètre `app`. Stocke le client au niveau module. |
| `_send_template(template_id, recipient, params)` | Appelle `client.transactional_emails.send_transac_email(template_id, params, sender=SendTransacEmailRequestSender(...), to=[SendTransacEmailRequestToItem(email=recipient)])`, log + remonte les `ApiError`. |
| `send_verification_email(email, token)` | Construit le lien `{base}/auth/verify-email?token=...` et appelle `_send_template(VERIFY_ID, email, {"link": link})`. |
| `send_reset_email(email, token)` | Construit le lien `{base}/reinitialiser-mot-de-passe?token=...` et appelle `_send_template(RESET_ID, email, {"link": link})`. |
Disparaît : toute la logique Jinja (`jinja_loader.searchpath`, `render_template`,
les templates `.txt`/`.html`), `MAIL_SUPPRESS_SEND`, l'objet Flask-Mail.
### Appel d'initialisation
`src/auth/setup.py:39` passe de `mailer.init_mailer(app)` à `mailer.init_mailer()`
(seule modification hors `mailer.py` et tests).
### Sender
Le sender (`MAIL_FROM` + `MAIL_FROM_NAME`) doit correspondre à un **expéditeur
vérifié dans Brevo** ; le domaine `decp.info` doit être authentifié (SPF/DKIM)
côté Brevo. Si le template Brevo définit déjà un sender, le passer explicitement
reste possible et prioritaire.
## Données / flux
1. Une route (`routes.py`) génère un token et appelle `send_*_email(email, token)`.
2. Le mailer construit le lien absolu (`APP_BASE_URL`) et l'envoie comme
`params = {"link": link}`.
3. Le template Brevo correspondant (`template_id`) injecte `{{ params.link }}` dans
son HTML et envoie.
4. En cas d'échec API : `ApiException` loggée puis remontée (comme aujourd'hui
`flask_mail.send` pouvait lever).
## Variables d'environnement (`.template.env`)
**Ajoutées :**
- `BREVO_API_KEY=` — clé API transactionnelle Brevo
- `BREVO_TEMPLATE_VERIFY_ID=` — ID numérique du template de vérification
- `BREVO_TEMPLATE_RESET_ID=` — ID numérique du template de reset
- `BREVO_SANDBOX=``true` pour valider sans délivrer (dev / intégration)
- `MAIL_FROM_NAME=decp.info` — nom d'expéditeur
**Conservées :** `MAIL_FROM`, `APP_BASE_URL`
**Supprimées :** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`,
`SMTP_USE_TLS`, `MAIL_SUPPRESS_SEND`
## Gestion d'erreur
- `_send_template` enveloppe l'appel dans un `try/except ApiError` : log
(niveau error, sans la clé API) puis `raise` pour que l'appelant gère.
- Si `init_mailer()` n'a pas été appelé ou `BREVO_API_KEY` absente : `assert`/erreur
explicite, comme l'actuel « Mailer non initialisé ».
## Stratégie de tests
Réconciliation validée :
- **Tests unitaires** (`tests/auth/test_mailer.py`, réécrit) : on **mocke** la méthode
`transactional_emails.send_transac_email` du client (monkeypatch) et on capture les
kwargs passés pour chaque fonction :
- `to[0].email == "a@b.c"`
- `template_id == BREVO_TEMPLATE_VERIFY_ID` / `..._RESET_ID`
- `params["link"]` contient le bon chemin et le token
- Aucun appel réseau, tourne en CI sans clé.
- **Mode sandbox Brevo** : pour la **vérification manuelle en dev**`BREVO_SANDBOX=true`
ajoute le header sandbox Brevo ; l'app accepte le déclenchement (signup/reset) et
Brevo valide sans délivrer.
- **Test d'intégration optionnel** : `@pytest.mark.integration`, skippé par défaut,
exécuté seulement si `BREVO_API_KEY` est présent, tape la vraie API en sandbox.
## Faits vérifiés / points résiduels
**Vérifié par introspection de `brevo-python==5.0.0rc1`** :
- Install : `pip install --pre "brevo-python==5.0.0rc1"` (package PyPI `brevo-python`,
import `brevo`).
- Imports : `from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem` ; `from brevo.core.api_error import ApiError`.
- Client : `Brevo(api_key=..., headers={...})` ; envoi via
`client.transactional_emails.send_transac_email(template_id=int, params=dict, sender=..., to=[...], headers=dict|None)`.
**À confirmer en implémentation (ne pas bloquer) :**
- Valeur exacte du header sandbox Brevo (`X-Sib-Sandbox: drop` est la valeur
documentée pour « accepter mais ne pas délivrer ») — à confirmer avec un envoi
réel en sandbox.
- Usage éventuel des variables legacy `SENDER_SERVER_DOMAIN`/`LOGIN_EMAIL`/
`FROM_EMAIL`/`TO_EMAIL` ailleurs dans le code avant toute suppression.
## Critères de succès
- L'inscription envoie un email de vérification via Brevo (template hébergé) avec
un lien fonctionnel.
- La demande de reset envoie l'email de reset via Brevo avec un lien fonctionnel.
- `routes.py` inchangé ; toute la suite de tests passe sans réseau.
- `flask-mail` et les configs/templates SMTP retirés du dépôt.
@@ -0,0 +1,157 @@
# Espace « Mon compte » — restructuration et section Compte
Date : 2026-06-24
Statut : design validé
Issue : #73 (comptes premium — abonnement mensuel à prix libre)
## Contexte
La page `/compte` actuelle (`src/pages/compte.py`) est une page unique étroite
qui affiche l'email, un formulaire de changement de mot de passe (POST vers
`/auth/change-password`) et un bouton de déconnexion. L'authentification passe
par un Blueprint Flask `/auth/*` (`src/auth/routes.py`) avec des formulaires
HTML côté serveur ; les pages sont des `register_page` Dash.
L'objectif est de transformer `/compte` en un **espace à plusieurs sections**
(jusqu'à 6 à terme) avec une navigation latérale, et d'implémenter
intégralement la première section, **Compte** (`/compte/admin`).
### Sections cibles
| Section | URL | Statut | Accès |
| ------------ | -------------------- | -------- | ---------- |
| Compte | `/compte/admin` | ce lot | compte |
| Abonnement | `/compte/abonnement` | coquille | compte |
| Mes archives | `/compte/archives` | futur | abonnement |
| Mes filtres | `/compte/filtres` | futur | abonnement |
| Mon SIRET | `/compte/siret` | futur | abonnement |
## Niveaux d'accès
Trois niveaux :
1. **Visiteur sans compte** → redirigé vers `/connexion?next=<path>`.
2. **Compte sans abonnement valide** → accès à _Compte_ et _Abonnement_
uniquement ; toute page gated redirige vers `/compte/abonnement`.
3. **Compte avec abonnement valide** → accès à toutes les sections.
L'abonnement n'est pas encore implémenté : on introduit une abstraction
`current_user_has_subscription() -> bool` qui renvoie `False` pour l'instant.
C'est le seul point à brancher lorsque la facturation arrivera.
## Architecture
### Routage (une page par section)
- Chaque section est un `register_page` distinct sous `/compte/*`, fidèle à la
convention « une page = un fichier » du projet.
- La page `/compte` actuelle devient une **redirection** vers `/compte/admin`
(renvoie `dcc.Location(href="/compte/admin")`).
- Ce lot crée `/compte/admin` (section Compte) et la coquille
`/compte/abonnement` (page enregistrée minimale, contenu de vente à venir).
Les sections gated (`archives`, `filtres`, `siret`) ne sont **pas** créées
dans ce lot.
### Coquille partagée
Nouveau module `src/pages/_compte_shell.py` exposant :
- `account_shell(active: str, contenu) -> Component`
Construit la mise en page commune :
- **Sidebar verticale** (desktop) listant les sections **accessibles** à
l'utilisateur courant ; l'entrée `active` est surlignée.
- **Bouton « ☰ Sections »** + `dbc.Offcanvas` (mobile) reprenant la même
liste.
- Insère `contenu` dans la zone principale.
- Sections gated **masquées** tant que `current_user_has_subscription()` est
faux (Compte + Abonnement seules visibles). La vente des fonctionnalités se
fera dans le contenu de la section Abonnement, pas via des entrées
verrouillées.
- Construit avec Dash Bootstrap Components (`dbc.Row`/`dbc.Col`,
`dbc.Nav`/`dbc.NavLink`, `dbc.Offcanvas`).
- `account_guard(require_subscription: bool) -> Component | None`
Appelé en tête de chaque `layout()` :
- non authentifié → `dcc.Location(href="/connexion?next=<path>")`
- authentifié, `require_subscription` et pas d'abonnement →
`dcc.Location(href="/compte/abonnement")`
- sinon → `None` (la page se rend normalement).
La définition des sections (libellé, URL, icône, `require_subscription`) est
centralisée dans une structure unique dans `_compte_shell.py`, pour que sidebar,
offcanvas et gardes restent cohérents et que l'ajout d'une section future tienne
en une ligne.
## Section Compte (`/compte/admin`)
Agencement : sections empilées avec séparateurs, dans `account_shell`.
1. **Adresse email** — affiche l'email actuel, champ « nouvelle adresse »,
bouton « Mettre à jour l'email » → `POST /auth/change-email`.
2. **Mot de passe** — formulaire existant (mot de passe actuel + nouveau +
confirmation), inchangé → `POST /auth/change-password`.
Sa cible de redirection passe de `/compte` à `/compte/admin`.
3. **Zone danger** — encadré rouge, bouton « Supprimer mon compte » qui ouvre
une **modale `dbc.Modal`** de confirmation demandant la re-saisie du mot de
passe → `POST /auth/delete-account`.
Le bouton **Déconnexion** est conservé (POST `/auth/logout`).
Les messages de succès/erreur sont passés en query string et rendus en
`dbc.Alert`, sur le modèle actuel ; `ERROR_MESSAGES` est étendu.
## Changement d'email avec re-vérification
Le changement d'email **ne prend pas effet immédiatement** : la nouvelle adresse
doit être confirmée par email, par cohérence avec l'inscription. L'ancienne
adresse reste active tant que la nouvelle n'est pas vérifiée (évite qu'une faute
de frappe verrouille le compte).
Flux :
1. `POST /auth/change-email` (`@login_required`) :
- valide la nouvelle adresse (`validate_email`, normalisée en minuscules) ;
- vérifie l'unicité (`get_user_by_email` → erreur `email_taken`) ;
- enregistre l'adresse en attente sur l'utilisateur et envoie un lien de
vérification à cette nouvelle adresse (réutilise le mécanisme de jetons de
vérification existant) ;
- redirige `/compte/admin?email_pending=1`.
2. Clic sur le lien → la nouvelle adresse devient l'email du compte et le
`pending_email` est effacé, puis redirection vers `/compte/admin?email_changed=1`.
Implications stockage : ajout d'un champ `pending_email` à la table `users`
(migration de schéma) et nouvelle fonction DB `update_email(user_id, email)`.
Le détail exact du jeton (réutilisation de `create_email_verification_token` vs
table dédiée) est laissé au plan d'implémentation.
## Suppression de compte
`POST /auth/delete-account` (`@login_required`) :
- vérifie le mot de passe courant (`check_password_hash`) → sinon
`?error=invalid_current_password` ;
- purge les jetons (`delete_email_verification_tokens_for_user`,
`delete_password_reset_tokens_for_user`) puis `delete_user(current_user.id)` ;
- `logout_user()` ;
- redirige vers `/?account_deleted=1`.
## Tests
- **Accès (Selenium)** : les trois niveaux, avec le stub d'abonnement forcé à
`True`/`False` ; redirection des non-authentifiés ; redirection des non-abonnés
sur une page gated ; présence/masquage des entrées de sidebar.
- **Navigation (Selenium)** : `/compte``/compte/admin` ; surlignage de la
section active ; ouverture de l'offcanvas mobile.
- **Routes** :
- `change-email` : succès (passage en attente), email déjà pris, email
invalide.
- `delete-account` : mauvais mot de passe (refus), succès (compte supprimé +
déconnexion).
## Hors périmètre
- Contenu réel de la section Abonnement (perks, tarifs, paiement).
- Sections Archives, Filtres, Mon SIRET.
- Implémentation réelle de la facturation derrière
`current_user_has_subscription()`.
@@ -0,0 +1,145 @@
# Connexion avec LinkedIn (OIDC) — Design
Date : 2026-06-24
Branche : `dev`
## Objectif
Permettre aux utilisateurs de créer un compte / se connecter à decp.info via
LinkedIn, comme les boutons « Se connecter avec Google/GitHub » d'autres sites.
Beaucoup d'utilisateurs viennent de LinkedIn ; réduire la friction d'inscription.
Portée : **LinkedIn uniquement** (pas d'abstraction multi-provider pour
l'instant). Code simple et direct.
## Mécanisme
LinkedIn fournit _« Sign In with LinkedIn using OpenID Connect »_ (OIDC).
On utilise la bibliothèque **Authlib** (intégration Flask) : elle gère la
découverte OIDC, la redirection, l'échange `code → token`, la validation du
token et le `state` anti-CSRF. Scopes demandés : `openid profile email`.
On ne stocke que l'**email** (comme l'auth existante), pas le nom ni la photo.
## Flux
1. L'utilisateur clique sur **« Connexion avec LinkedIn »** (présent sur
`/connexion` et `/inscription`).
2. `GET /auth/linkedin` → Authlib redirige vers LinkedIn.
3. L'utilisateur autorise → LinkedIn redirige vers
`GET /auth/linkedin/callback?code=...&state=...`.
4. Authlib échange le `code`, valide le token, expose l'identité OIDC :
`sub`, `email`, `email_verified`.
5. Résolution / liaison du compte (voir ci-dessous).
6. `login_user(user, remember=True)` → redirection vers `/compte/admin`
(ou le `next` validé via `safe_next`).
## Schéma (migrations additives, dans `db._migrate`)
- `users.password_hash` devient **nullable** : un compte créé via LinkedIn n'a
pas de mot de passe. Un tel utilisateur pourra s'en créer un plus tard via
le flux « mot de passe oublié ».
- SQLite ne permet pas de retirer un `NOT NULL` par `ALTER COLUMN`. La
migration recrée la table `users` sans la contrainte `NOT NULL` sur
`password_hash` si elle est encore présente (copie des données,
`PRAGMA table_info` pour détecter l'état). Les nouvelles installations
créent directement le schéma sans `NOT NULL` sur `password_hash`.
- Nouvelle table :
```sql
CREATE TABLE IF NOT EXISTS oauth_identities (
provider TEXT NOT NULL, -- 'linkedin'
subject TEXT NOT NULL, -- le 'sub' OIDC stable
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (provider, subject),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
```
## Résolution / liaison du compte (au callback)
Logique extraite dans une fonction testable `resolve_oauth_user(provider, subject, email, email_verified)` :
1. Si `(linkedin, sub)` existe dans `oauth_identities` → on connecte le
`user_id` lié.
2. Sinon, si un `user` existe déjà avec cet **email** → **liaison
automatique** : insert dans `oauth_identities`, et si `email_verified`
était `0`, on le passe à `1` (LinkedIn garantit un email vérifié).
3. Sinon → **création** d'un nouvel utilisateur (`email`,
`password_hash = NULL`, `email_verified = 1`) + insert `oauth_identities`.
Aucun email de vérification Brevo n'est envoyé.
Décision produit : la liaison par email est automatique (l'email LinkedIn est
vérifié, le risque est faible).
## Fichiers touchés
- **`src/auth/oauth.py`** (nouveau) : `init_oauth(app)` — initialise Authlib et
enregistre le provider LinkedIn (config OIDC, client id/secret depuis l'env).
- **`src/auth/routes.py`** : deux routes
- `GET /auth/linkedin` — démarre le flux (`authorize_redirect`).
- `GET /auth/linkedin/callback` — récupère l'identité, appelle
`resolve_oauth_user`, `login_user`, redirige.
- `resolve_oauth_user(...)` — logique de résolution (testable).
- **`src/auth/db.py`** : schéma `oauth_identities`, migration `password_hash`
nullable, helpers `get_oauth_identity(provider, subject)`,
`link_oauth_identity(provider, subject, user_id)`,
`create_oauth_user(email)`.
- **`src/auth/setup.py`** : appel à `init_oauth(app)`.
- **`src/pages/connexion.py`** et **`src/pages/inscription.py`** : bouton
« Connexion avec LinkedIn ».
- **`pyproject.toml`** : ajout de la dépendance `authlib`.
- **`.template.env`** : `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
## UI du bouton
- Libellé : **« Connexion avec LinkedIn »**.
- Style : texte blanc sur fond `rgb(10, 102, 194)` (bleu de marque LinkedIn,
`#0A66C2`).
- Un lien `<a href="/auth/linkedin">` (initiation par GET, pas de CSRF requis),
placé sous le formulaire email/mot de passe, séparé par un « ou ».
- Présent sur `/connexion` et `/inscription`.
## Gestion d'erreurs
- L'utilisateur annule / refuse sur LinkedIn → `/connexion?error=oauth_cancelled`.
- Échange de token échoué, ou pas d'email renvoyé par LinkedIn →
`/connexion?error=oauth_failed` (loggé via `logger.exception`).
- `state` invalide → détecté par Authlib → `oauth_failed`.
- Messages ajoutés dans `ERROR_MESSAGES` de `connexion.py`.
## Tests (dans `tests/auth/`)
Sans appel réseau réel à LinkedIn : on **mocke** le retour `userinfo` /
l'identité OIDC exposée par Authlib.
- `resolve_oauth_user` :
- nouvel utilisateur (création + `email_verified = 1`, pas de password).
- liaison par email à un compte existant (insert identity, promotion
`email_verified`).
- identité déjà liée (retour du même `user_id`, pas de doublon).
- Migration : `password_hash` accepte `NULL` ; table `oauth_identities` créée.
- Helpers db : `get_oauth_identity`, `link_oauth_identity`, `create_oauth_user`.
## Prérequis hors code (côté admin du projet)
1. Créer une application sur le **LinkedIn Developer Portal**.
2. Activer le produit _« Sign In with LinkedIn using OpenID Connect »_.
3. Déclarer les redirect URIs autorisées :
- `http://localhost:8050/auth/linkedin/callback` (dev)
- `https://test.decp.info/auth/linkedin/callback` (test.decp.info)
- `https://decp.info/auth/linkedin/callback` (prod)
4. Récupérer le **Client ID** et le **Client Secret**, les renseigner dans
`.env` (`LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
L'URL de callback est construite à partir de `APP_BASE_URL` (déjà présent dans
l'env).
## Hors périmètre (YAGNI)
- Google / GitHub / autres providers.
- Stockage du nom ou de la photo de profil LinkedIn.
- Page de gestion « délier mon compte LinkedIn » (pourra venir plus tard).
@@ -0,0 +1,142 @@
# Sauvegarde de la base utilisateurs sur S3 — conception
Date : 2026-06-24
Statut : conception validée, prête pour le plan d'implémentation
## Contexte et objectif
`users.sqlite` contient les comptes utilisateurs (`src/auth/db.py`) **et** les tokens API
(`src/api/tokens_db.py`) — un seul fichier SQLite, ~36 Ko aujourd'hui, attendu jusqu'à
~500 utilisateurs × quelques centaines de lignes. C'est la seule donnée non reproductible
du projet (la base DECP en Parquet/DuckDB est régénérable, donc hors périmètre).
L'app tourne sur un VPS unique (systemd + gunicorn, `/var/www/<APP_NAME>`). On veut prévenir
toute perte de données par des sauvegardes régulières vers un **stockage compatible S3
(non-Amazon)**, avec rotation multi-paliers et un mécanisme de restauration.
## Décisions validées
- **Déclencheur** : timer systemd sur le VPS (indépendant de gunicorn, robuste aux
redémarrages, pas de duplication entre workers).
- **Chiffrement** : les sauvegardes sont chiffrées côté application _avant_ l'envoi
(données personnelles : emails, hash de mots de passe, tokens).
- **Périmètre** : le seul fichier `users.sqlite` (chemin `USERS_DB_PATH`).
## Vue d'ensemble
Un nouveau module `src/backup/` expose une CLI à trois sous-commandes : `backup`, `list`,
`restore`. Le timer systemd lance `backup` toutes les heures. Chaque exécution :
1. produit un **snapshot cohérent** de `users.sqlite` (API de backup en ligne de SQLite,
sûre même en cas d'écriture concurrente) ;
2. le **compresse** (gzip) puis le **chiffre** ;
3. l'**envoie** sur le S3 sous une clé horodatée ;
4. applique la **rotation** : recalcule l'ensemble à conserver et supprime les obsolètes.
## Schéma de rétention
Union de quatre paliers — une sauvegarde est conservée si elle qualifie pour _au moins un_
palier :
| Palier | Granularité | Horizon | ~ Nb gardés |
| ------------ | --------------------- | ------------------- | ----------- |
| Horaire | 1 par heure | 12 dernières heures | ~12 |
| Bi-quotidien | 1 par tranche de 12 h | 72 dernières heures | ~6 |
| Quotidien | 1 par jour | 21 derniers jours | ~21 |
| Mensuel | 1 par mois calendaire | 12 derniers mois | ~12 |
Soit ~51 fichiers au régime permanent, chacun de quelques dizaines de Ko → stockage
négligeable.
### Algorithme — fonction pure
`select_retained(timestamps: list[datetime], now: datetime) -> set[datetime]` :
- Pour chaque palier à période fixe (horaire, bi-quotidien, quotidien) : regrouper les
horodatages par tranche (`floor((now - t) / période)`), et pour chaque tranche comprise
dans l'horizon, garder le **plus récent** de la tranche.
- Pour le palier mensuel : regrouper par **mois calendaire** (`(année, mois)`) car les mois
ont des durées variables ; garder le plus récent de chaque mois sur les 12 derniers mois.
- Le résultat est l'**union** des ensembles retenus de tous les paliers.
- L'ensemble à supprimer = tous les horodatages présents ensemble retenu.
Aucune I/O dans cette fonction : elle prend la liste des horodatages (extraits des clés S3)
et `now`, et renvoie quoi garder. Testée unitairement de façon exhaustive (chevauchements
de paliers, bascules d'horizon, mois variables, ensemble vide).
## Modules (`src/backup/`)
Chaque module a une responsabilité unique et une interface explicite, testable isolément.
- **`rotation.py`** — `select_retained` (fonction pure, pas d'I/O). Cœur logique, tests
unitaires nombreux.
- **`snapshot.py`** — produit un snapshot SQLite cohérent via l'API de backup en ligne
(`sqlite3.connect(src).backup(dest)`) vers un fichier temporaire, puis gzip. Renvoie le
chemin du fichier compressé.
- **`crypto.py`** — chiffrement/déchiffrement symétrique authentifié (Fernet, lib
`cryptography`). Clé lue depuis `BACKUP_ENCRYPTION_KEY`. Round-trip testé.
- **`storage.py`** — wrapper S3 (`upload`, `list`, `download`, `delete`) via `boto3` avec
`endpoint_url` (compatible tout fournisseur S3 non-Amazon). Configuration lue depuis l'env.
- **`cli.py`** — `argparse` : `backup`, `list`, `restore`. Point d'entrée appelé par le timer
systemd et par l'opérateur pour la restauration.
### Nommage des objets S3
Clé : `<S3_BACKUP_PREFIX>/users-<YYYYMMDDTHHMMSSZ>.sqlite.gz.enc`, horodatage UTC ISO 8601
compact, triable lexicographiquement. La rotation extrait l'horodatage depuis la clé.
## Procédure de sauvegarde (`backup`)
1. Snapshot cohérent de `users.sqlite` → fichier temporaire.
2. gzip.
3. Chiffrement Fernet.
4. Upload sur S3 sous la clé horodatée.
5. Rotation : lister les clés sous le préfixe, parser les horodatages, calculer l'ensemble
retenu via `select_retained`, supprimer le reste.
6. Nettoyage des fichiers temporaires (y compris en cas d'erreur).
Journalisation de chaque étape (succès/échec, clés uploadées/supprimées) pour suivi dans
les logs systemd.
## Procédure de restauration (`restore`, manuelle, avec garde-fous)
- `list` → affiche les sauvegardes disponibles (clé, date, taille), triées.
- `restore <clé>` :
1. télécharge l'objet ;
2. déchiffre puis décompresse vers un fichier temporaire ;
3. **vérifie l'intégrité** SQLite (`PRAGMA integrity_check`) — refuse de restaurer si KO ;
4. fait une **copie de secours** de la base courante (`users.sqlite.bak-<ts>`) ;
5. remplace de façon atomique (`os.replace`).
- Le script avertit d'**arrêter le service** (`systemctl stop <APP_NAME>`) avant la
restauration et de le redémarrer après.
La restauration est volontairement **manuelle** : un outil de reprise sur sinistre ne doit
jamais restaurer automatiquement.
## Déploiement et configuration
- Unités systemd dans `deploy/` :
- `decpinfo-backup.service` (type `oneshot`, exécute `python -m src.backup backup`) ;
- `decpinfo-backup.timer` (`OnCalendar=hourly`, `Persistent=true` pour rattraper un
créneau manqué après un redémarrage).
- Nouvelles variables dans `.template.env` :
- `S3_ENDPOINT_URL`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`,
`S3_BACKUP_PREFIX`, `BACKUP_ENCRYPTION_KEY`.
- Nouvelles dépendances : `boto3`, `cryptography`.
- **Gestion de la clé de chiffrement** : `BACKUP_ENCRYPTION_KEY` doit être sauvegardée
hors du serveur (gestionnaire de secrets / coffre). Si elle est perdue, les sauvegardes
sont irrécupérables.
## Tests
- **Unitaires** : `select_retained` (chevauchements, horizons, mois variables, vide) ;
round-trip `crypto` ; nommage/parsing des clés.
- **Intégration** : snapshot d'une base SQLite réelle → vérifie une base valide ;
cycle complet backup → list → restore sur un faux backend S3 (mock `boto3` ou `moto`),
vérifie l'égalité du contenu restauré et la copie de secours créée.
## Hors périmètre
- Sauvegarde de la base DECP (régénérable).
- Restauration automatique / orchestration multi-serveurs.
- Réplication temps réel.
@@ -0,0 +1,336 @@
# Abonnements payants via Frisbii — design
- **Issue** : [#90](https://github.com/ColinMaudry/decp.info/issues/90)
- **Branche** : `feature/90_subscriptions`
- **Date** : 2026-06-25
## Objectif
Permettre à un utilisateur connecté de **souscrire** un abonnement payant et de
**résilier** son abonnement, via [Frisbii](https://docs.frisbii.com/) (ex-Reepay),
une solution européenne de gestion d'abonnements. L'abonnement débloque les
sections premium de l'espace compte (Mes archives, Mes filtres, Mon SIRET), déjà
protégées par le mécanisme `require_subscription`.
Le câblage côté app est déjà prêt : `current_user_has_subscription()` dans
`src/pages/_compte_shell.py` est un stub qui renvoie `False`, et
`src/pages/compte_abonnement.py` est une page placeholder. Cette fonctionnalité
les branche sur Frisbii.
## Décisions clés
1. **Session de checkout hébergée + webhooks.** On crée une _subscription session_
Frisbii (`POST /v1/session/subscription`) qui renvoie une URL de page de
paiement hébergée. Frisbii collecte la carte ; on ne manipule jamais de données
de paiement. Les **webhooks** sont la **source de vérité** de l'état d'abonnement.
2. **Deux plans fixes, définis côté Frisbii.** decp.info ne référence que leurs
_handles_ (via `.env`) :
- `simple` — **20 € HT / mois**
- `soutien` — **50 € HT / mois**
Les deux plans donnent **le même accès premium** ; le soutien est une
contribution supérieure, pas un palier de fonctionnalités.
3. **Abonnement à durée indéterminée, mois glissants.** L'abonnement est renouvelé
chaque mois (période ancrée sur la date d'inscription, comportement par défaut
Frisbii — pas de prorata de première période). Il perdure jusqu'à résiliation.
4. **Essai gratuit configuré côté Frisbii (2 jours souhaités).** L'essai est un
`trial_interval` réglé sur **chaque plan dans le dashboard Frisbii** (aucun code
pour le définir, et **la durée n'est pas codée en dur** côté app : elle est lue
depuis le plan via l'API). La carte est **collectée à la souscription** (page hébergée)
mais débitée seulement à la fin de l'essai ; l'abonnement passe alors
automatiquement de `trial` à `active`. Si le paiement échoue → `expired`. Une
résiliation pendant l'essai expire en **fin d'essai** (pas de débit). Pendant
l'essai, l'utilisateur a **accès aux fonctions premium**.
**Anti-abus (un seul essai par compte).** Frisbii ne restreint pas l'essai par
client : sans garde-fou, un utilisateur pourrait s'abonner, résilier avant la fin
de l'essai (sans débit) et recommencer indéfiniment. On mémorise donc côté app un
indicateur `trial_used` (positionné dès que l'abonnement entre en `trial` ou
`active`). À toute souscription ultérieure d'un utilisateur dont `trial_used` est
vrai, la session est créée avec **`no_trial=true`** → passage direct en `active`,
débit immédiat, sans nouvel essai. _Limite connue, non traitée :_ un utilisateur
pourrait créer plusieurs comptes decp.info (emails différents) pour refarmer des
essais — acceptable vu l'essai de 2 jours et le contexte d'intérêt public.
5. **Résiliation en fin de période courante.** `POST` cancel Frisbii avec le
comportement **par défaut** (expiration en fin de période courante — ou fin
d'essai si en essai). L'accès est maintenu jusqu'à `current_period_end` renvoyé
par Frisbii ; aucun calcul de date côté app.
6. **Clé privée serveur uniquement.** HTTP Basic Auth (clé privée en username),
jamais exposée au frontend.
## Architecture
Nouveau module `src/subscriptions/`, calqué sur `src/auth/`, avec des frontières
nettes :
| Fichier | Rôle | Dépendances | Ne dépend PAS de |
| ----------- | -------------------------------------------------------------- | ----------------- | ---------------- |
| `client.py` | Client HTTP pur de l'API Frisbii | `requests`, env | DB, Flask |
| `db.py` | Table `subscriptions` (réutilise `auth.db.get_conn`) | sqlite | Flask, client |
| `plans.py` | Catalogue des plans (clé → handle, libellé, prix, description) | env | DB, Flask |
| `routes.py` | Blueprint Flask : subscribe, cancel, webhook | client, db, plans | — |
| `setup.py` | `init_subscriptions(app)` | routes | — |
Côté présentation :
- `src/pages/compte_abonnement.py` — UI de la page `/compte/abonnement`.
- `src/pages/_compte_shell.py``current_user_has_subscription()` branché sur
`subscriptions.db`.
### `client.py` — client Frisbii
Fonctions pures, sans état applicatif (toute config lue depuis l'env) :
- `_auth()` → tuple HTTP Basic `(FRISBII_API_KEY, "")`.
- `get_or_create_customer(handle: str, email: str) -> dict`
- Handle déterministe `decpinfo-{user_id}`. GET le customer ; s'il n'existe pas
(404), le crée (`POST /v1/customer`). Idempotent.
- `create_subscription_session(plan_handle, customer_handle, accept_url, cancel_url) -> str`
- `POST /v1/session/subscription` avec `prepare_subscription` (plan + customer) et
les URLs de retour. Renvoie l'`url` hébergée.
- `cancel_subscription(subscription_handle) -> dict`
- Cancel par défaut (fin de période courante). Renvoie l'objet subscription.
- `get_subscription(subscription_handle) -> dict` (utilitaire de réconciliation).
- `get_plan(plan_handle) -> dict`
- `GET /v1/plan/{handle}`. Sert à lire les caractéristiques du plan (dont la durée
d'essai `trial_interval`) sans la coder en dur côté app.
Base URL : `FRISBII_API_BASE_URL` (à confirmer au moment de l'implémentation depuis
la doc Frisbii ; valeur par défaut documentée dans `.template.env`). Timeouts
explicites sur tous les appels. Les erreurs HTTP lèvent une exception
`FrisbiiError` (sous-classe locale) loggée par l'appelant.
### `db.py` — état d'abonnement
Table `subscriptions` dans `users.sqlite` (un abonnement courant par utilisateur) :
```sql
CREATE TABLE IF NOT EXISTS subscriptions (
user_id INTEGER PRIMARY KEY,
frisbii_customer_handle TEXT,
frisbii_subscription_handle TEXT,
plan TEXT, -- 'simple' | 'soutien'
status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired'
current_period_end TEXT, -- ISO 8601, nullable
trial_used INTEGER NOT NULL DEFAULT 0, -- 1 dès qu'un essai a été consommé
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer
ON subscriptions(frisbii_customer_handle);
```
Le module possède sa propre `init_schema()` (appelée depuis `setup.py`) utilisant
`auth.db.get_conn()`, pour rester isolé du schéma auth.
Fonctions :
- `upsert_subscription(user_id, customer_handle, subscription_handle, plan, status, current_period_end)`
- `get_subscription_by_user(user_id) -> Row | None`
- `get_subscription_by_customer(customer_handle) -> Row | None` (résolution webhook)
- `set_status(user_id, status, current_period_end=None)`
- `has_active_subscription(user_id) -> bool`
`True` si une ligne existe avec `status` dans (`trial`, `active`) **ou**
(`status='cancelled'` **et** `current_period_end` dans le futur). Couvre l'essai en
cours et le cas « résilié mais encore valide jusqu'à la fin de la période ».
### Statuts et cycle de vie
| Statut | Sens | Accès premium |
| ----------- | --------------------------------------------- | --------------------- |
| `pending` | Session créée, paiement pas encore confirmé | non |
| `trial` | Essai gratuit en cours (2 j), carte collectée | oui |
| `active` | Abonnement en cours, renouvelé chaque mois | oui |
| `cancelled` | Résilié, valide jusqu'à `current_period_end` | oui (jusqu'à la date) |
| `expired` | Période échue (annulé ou échec de paiement) | non |
### `routes.py` — blueprint Flask
`subscriptions_bp` (préfixe explicite par route) :
- `POST /subscriptions/subscribe``@login_required`, **CSRF protégé**.
- Form `plan=simple|soutien`. Résout le handle via `plans.py` (400 si inconnu).
- `get_or_create_customer("decpinfo-{user_id}", email)`.
- `upsert_subscription(..., status='pending')`.
- **Anti-abus** : si `trial_used` est déjà vrai pour cet utilisateur, la session est
créée avec `no_trial=true` (pas de nouvel essai gratuit).
- `create_subscription_session(...)` avec
`accept_url={APP_BASE_URL}/compte/abonnement?paiement=succes` et
`cancel_url={APP_BASE_URL}/compte/abonnement?paiement=annule`.
- Redirection **303** vers l'URL hébergée.
- En cas d'erreur API : redirection `/compte/abonnement?error=frisbii` + log.
- `POST /subscriptions/cancel``@login_required`, **CSRF protégé**.
- Récupère l'abonnement de l'utilisateur ; 400 s'il n'y en a pas d'actif.
- `cancel_subscription(handle)` (défaut = fin de période).
- Met à jour le statut localement (`cancelled` + `current_period_end` si renvoyé) ;
le webhook confirmera.
- Redirection `/compte/abonnement?resiliation=ok`.
- `POST /frisbii/webhook`**CSRF-exempt**, pas d'auth de session.
- **Vérifie la signature** via `FRISBII_WEBHOOK_SECRET` selon le schéma documenté
par Frisbii (à confirmer depuis la doc webhooks à l'implémentation). Signature
invalide → **403**.
- Dispatch par type d'événement (mapping vers l'utilisateur via le customer
handle stocké) :
- `subscription_created``status='trial'` si l'abonnement démarre en essai
(champ d'essai du payload), sinon `status='active'` ; `current_period_end`
(= fin d'essai pendant l'essai) maj.
- fin d'essai / premier débit (`subscription_renewed` / `invoice_settled`) →
`status='active'`, `current_period_end` maj.
- `invoice_settled` / renouvellement → `current_period_end` maj.
- `subscription_cancelled``status='cancelled'`, `current_period_end` maj.
- `subscription_expired` / échec de paiement terminal → `status='expired'`.
- Dès que le statut calculé est `trial` ou `active`, positionne `trial_used=1`
(anti-abus, valeur collante).
- Événement inconnu → **200** (ignoré). Erreur de traitement → **5xx** pour que
Frisbii réessaie. Les noms exacts d'événements seront confirmés depuis la doc
Frisbii ; le dispatch est piloté par une table `EVENT_HANDLERS` facile à étendre.
### `setup.py` — initialisation
`init_subscriptions(app)` :
- `db.init_schema()`.
- Enregistre `subscriptions_bp`.
- Exempte la vue webhook de CSRF (même approche que `_auth_csrf.exempt` dans
`src/app.py`).
- Warnings au démarrage si `FRISBII_API_KEY`, `FRISBII_WEBHOOK_SECRET`,
`FRISBII_PLAN_SIMPLE` ou `FRISBII_PLAN_SOUTIEN` manquent (comme Brevo/LinkedIn).
Appelé depuis `src/app.py` après `init_auth(...)`.
### `plans.py` — catalogue
```python
PLANS = {
"simple": {"handle": env("FRISBII_PLAN_SIMPLE"), "label": "Abonnement simple",
"prix_ht": 20, "description": "..."},
"soutien": {"handle": env("FRISBII_PLAN_SOUTIEN"), "label": "Abonnement de soutien",
"prix_ht": 50, "description": "..."},
}
```
`resolve_handle(key) -> str | None` pour les routes ; le dict sert aussi à rendre
les cartes de la page.
`trial_days(key) -> int | None` : lit la durée d'essai **depuis Frisbii**
(`client.get_plan(handle)``trial_interval`, parsé en jours), avec un **cache**
(TTL ~1 h via `src/utils/cache.py`, les plans changeant rarement). Échec API ou plan
sans essai → `None` (la mention d'essai est alors masquée, pas de valeur en dur).
## Page `/compte/abonnement`
`account_guard("/compte/abonnement", require_subscription=False)` reste (la page est
accessible sans abonnement). Le contenu dépend de l'état :
**Sans abonnement actif** :
- Deux cartes de plan (Simple 20 € HT/mois, Soutien 50 € HT/mois), chacune avec un
formulaire `POST /subscriptions/subscribe` (input caché `plan` + CSRF) et un bouton
« S'abonner ». Mention d'essai sur les cartes, selon l'éligibilité de l'utilisateur
(`db.has_used_trial`) :
- essai non encore utilisé et plan avec essai → « {n} jours d'essai gratuit »
(`{n}` lu depuis le plan via `plans.trial_days(key)`) ;
- essai déjà utilisé (souscription créée avec `no_trial=true`) → mention explicite
**« Sans période d'essai (déjà utilisée) — débit immédiat »** ;
- plan sans essai configuré → aucune mention.
- Contenu pédagogique (issue #90) :
- **À quoi servent les abonnements** : abonnement Frisbii 50 €, serveur Scaleway
40 €, espace de coworking 250 €, salaire médian 3 840 €.
- **Ce que le soutien permettrait** : rédaction d'études à partir des données (ex.
acheteurs aux données introuvables et raisons de la non-publication) ;
coordination des bonnes volontés militant pour une législation plus exigeante sur
la transparence de la commande publique.
**Avec abonnement actif** :
- Plan courant, statut, date de prochain renouvellement / fin de validité
(`current_period_end`).
- Si `trial` : bandeau « Essai gratuit jusqu'au {date}, puis débit automatique ».
- Si `cancelled` : bandeau « Abonnement résilié, actif jusqu'au {date} ».
- Si `trial` ou `active` : formulaire `POST /subscriptions/cancel` (CSRF) + bouton
« Résilier » (en essai, la résiliation évite tout débit).
**Messages de retour** (query params lus dans le `layout`) : `paiement=succes`
(« Merci, votre abonnement est en cours d'activation »), `paiement=annule`,
`resiliation=ok`, `error=frisbii`.
## `current_user_has_subscription()`
Dans `_compte_shell.py`, remplacer le stub par :
```python
def current_user_has_subscription() -> bool:
if not current_user.is_authenticated:
return False
return subscriptions.db.has_active_subscription(current_user.id)
```
C'est le seul point de branchement avec le reste de l'espace compte ; le mécanisme
`visible_sections` / `guard_redirect` existant fonctionne tel quel.
## Configuration (`.template.env`)
```bash
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
FRISBII_API_BASE_URL= # base de l'API Frisbii (cf. doc)
FRISBII_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois)
FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois)
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
```
**Prérequis de configuration côté dashboard Frisbii** (hors code, à documenter) :
- Créer les deux plans mensuels (mois glissants, ancrés sur la date d'inscription)
avec un **essai de 2 jours** (`trial_interval`) et collecte de la carte à la
souscription.
- Configurer un webhook vers `{APP_BASE_URL}/frisbii/webhook` avec les événements
d'abonnement et de facturation, et récupérer le secret de signature.
## Gestion des erreurs
| Situation | Comportement |
| ---------------------------- | ------------------------------------------------------------------------- |
| Échec API à la souscription | Redirect `/compte/abonnement?error=frisbii` + log |
| Échec API à la résiliation | Redirect `/compte/abonnement?error=frisbii` + log ; statut local inchangé |
| Webhook signature invalide | 403, pas de traitement |
| Webhook événement inconnu | 200, ignoré |
| Webhook erreur de traitement | 5xx → Frisbii réessaie |
| Config Frisbii absente | Warnings au démarrage ; souscription échoue proprement |
## Tests
Unitaires (mocks, pas d'appel réseau réel) :
- `client.py` : auth Basic, get-or-create customer (200 vs 404→create), création de
session (URL renvoyée), cancel ; gestion d'erreur HTTP → `FrisbiiError`. HTTP mocké.
- `db.py` : upsert / get / set_status ; `has_active_subscription` pour chaque statut
(`trial` et `active` → vrai ; `cancelled` futur → vrai, passé → faux ; `pending`
et `expired` → faux).
- `plans.py` : `resolve_handle` (connu / inconnu) ; `trial_days` (parsing du
`trial_interval` renvoyé par un `get_plan` mocké, mise en cache, `None` si échec API
ou plan sans essai).
- `routes.py` : webhook — signature valide/invalide, dispatch de chaque événement
vers le bon changement de statut (payloads factices), résolution par customer
handle ; subscribe (redirect 303 vers l'URL de session, statut `pending` créé) ;
cancel (appel client + statut `cancelled`).
Intégration légère (rendu) :
- Page `/compte/abonnement` : affiche les deux cartes + boutons « S'abonner » sans
abonnement ; affiche le bouton « Résilier » et la date avec abonnement actif (DB
de test préremplie).
## Hors périmètre (YAGNI)
- Changement de plan / upgrade-downgrade en self-service (le client peut résilier et
re-souscrire).
- Montant de soutien libre (décidé : plans fixes).
- Réconciliation périodique automatique (un utilitaire `get_subscription` existe pour
un script manuel si besoin, mais pas de cron).
- Facturation / historique des factures dans l'UI (Frisbii fournit son propre portail
et envoie les factures par email).
@@ -0,0 +1,113 @@
# Accès gratuit pour tous via `TOUS_ABONNES`
**Date :** 2026-06-26
**Statut :** Design validé
## Contexte
La plateforme de paiement Frisbii doit effectuer un _background check_ avant
d'autoriser la réception de paiements (plusieurs semaines). En attendant, on
veut ouvrir gratuitement à tout utilisateur connecté les fonctionnalités
normalement réservées aux abonnés, sans casser le code d'abonnement existant
(qui sera réactivé tel quel une fois Frisbii validé).
## Objectif
Un drapeau d'environnement `TOUS_ABONNES` qui, lorsqu'il vaut `true` :
1. donne à tout utilisateur **connecté** l'accès aux fonctionnalités réservées
aux abonnés ;
2. affiche un bandeau d'information en haut de la page `/compte/abonnement` ;
3. désactive (et grise) les boutons « S'abonner » sur `/compte/abonnement`.
Quand le drapeau est absent ou `false`, le comportement actuel est strictement
inchangé.
## Architecture existante (rappel)
- `src/pages/_compte_shell.py` centralise l'accès :
- `current_user_has_subscription()` → utilisé par `_nav` (sections visibles
du menu) **et** `account_guard` (protection des pages réservées) ;
- `SECTIONS` marque `archives`, `filtres`, `siret` avec
`require_subscription: True`.
- `db.has_active_subscription(user_id)` est le contrôle bas-niveau « vrai
abonnement payant » ; appelé directement par :
- `compte_abonnement.py` (`has_access` → vue « abonnement actif » vs cartes de
plans) ;
- `auth/routes.py::_post_login_url` (redirection post-login) ;
- `subscriptions/routes.py::subscribe` (anti double-abonnement).
- `compte_abonnement.py::_plan_card` rend le bouton « S'abonner ».
## Conception
### 1. Variable d'environnement
Dans `src/utils/__init__.py`, suivant la convention de `DEVELOPMENT` :
```python
TOUS_ABONNES = os.getenv("TOUS_ABONNES", "False").lower() == "true"
```
Documentée dans `.template.env`.
### 2. Déblocage de l'accès (point unique)
`src/pages/_compte_shell.py::current_user_has_subscription()` :
```python
def current_user_has_subscription() -> bool:
from src.subscriptions import db
if not current_user.is_authenticated:
return False
if TOUS_ABONNES:
return True
return db.has_active_subscription(current_user.id)
```
Ce seul changement débloque les sections `archives`/`filtres`/`siret` dans le
menu (`_nav`) **et** lève leur `account_guard`.
**On ne touche pas** à `db.has_active_subscription()` : il doit continuer à
refléter un vrai abonnement payant. Conséquence voulue : sur
`/compte/abonnement`, `has_access` reste `False` pour un utilisateur sans
abonnement réel → il voit les cartes de plans (désactivées) + le bandeau, et
non une fausse vue « abonnement actif ».
### 3. Bandeau d'information (page `/compte/abonnement` uniquement)
Dans `compte_abonnement.py::layout`, quand `TOUS_ABONNES`, insérer en haut du
`body` (avant les cartes) un `dbc.Alert` (`color="info"`) :
> Les fonctionnalités normalement accessibles contre un abonnement de 20 € HT
> par mois sont accessibles à tous et toutes en attendant la validation de mon
> dossier pour recevoir des paiements.
### 4. Boutons « S'abonner » désactivés et gris
Dans `compte_abonnement.py::_plan_card`, quand `TOUS_ABONNES`, le bouton est
rendu désactivé et gris (`className="btn btn-secondary disabled"`,
`disabled=True`). Les cartes restent visibles à titre informatif.
### 5. Redirection post-login
**Inchangée.** `_post_login_url` s'appuie sur `db.has_active_subscription`, qui
reste `False` pour les non-abonnés réels → redirection vers
`/compte/abonnement`, ce qui est le comportement souhaité avec `TOUS_ABONNES`.
## Hors périmètre
- Aucune autre fonctionnalité « abonné » n'est gatée ailleurs que via
`account_guard` (vérifié : pas de contrôle d'abonnement dans `tableau.py`,
exports, sauvegarde de filtres).
- Pas de modification du flux de paiement Frisbii ni du webhook.
## Tests
- `current_user_has_subscription()` : `True` si connecté + `TOUS_ABONNES`,
`False` si non connecté même avec le drapeau, comportement DB normal si
drapeau absent.
- `visible_sections` : sections réservées visibles via le drapeau (en
s'appuyant sur le helper).
- `compte_abonnement` : bandeau présent et bouton désactivé quand `TOUS_ABONNES`
est actif ; absents sinon.
@@ -0,0 +1,274 @@
# Sauvegarde des vues du Tableau
**Date :** 2026-06-29
**Statut :** Design validé
**Issue :** [#95](https://github.com/ColinMaudry/decp.info/issues/95)
## Contexte
La page `/tableau` permet déjà de filtrer, trier et choisir les colonnes des
marchés. Ces réglages sont **matérialisés dans l'URL** via trois paramètres
(`filtres`, `tris`, `colonnes`) : `sync_url_and_reset_button` les produit (bouton
« Partager la vue ») et `restore_view_from_url` les restaure à l'ouverture d'une
URL ainsi formée.
L'issue #95 demande d'aller plus loin : permettre aux utilisateur·ices de
**sauvegarder des vues nommées** et de les ré-appliquer en un clic, sans avoir à
manipuler ou conserver des URL.
L'issue mentionnait les trois tableaux (`/tableau`, `/titulaire`, `/acheteur`).
**Le périmètre a été resserré à `/tableau` uniquement.** C'est le seul des trois
qui gère aujourd'hui les paramètres d'URL et le partage ; `/titulaire` et
`/acheteur` ne les supportent pas encore et sont hors périmètre.
## Objectif
Pour un·e **abonné·e** sur `/tableau` :
1. **Sauvegarder** la vue courante (filtres + tris + colonnes) sous un nom
personnalisé, saisi dans une modale.
2. **Appliquer** une vue sauvegardée en la choisissant dans un menu déroulant.
Pour un·e **abonné·e** dans l'espace compte :
3. **Gérer** ses vues sur une nouvelle page `/compte/vues` : lister, renommer,
supprimer.
Les non-abonné·es ne voient aucun de ces contrôles, et toute opération
d'écriture est refusée côté serveur.
## Principe
Une **vue** = un nom + la query string que `/tableau` sait déjà produire et
restaurer (`filtres` + `tris` + `colonnes`). On ne réinvente rien :
- **Sauvegarder** = construire la query string comme le fait déjà
`sync_url_and_reset_button`, puis la stocker avec un nom.
- **Appliquer** = naviguer vers `/tableau?<query>` ; `restore_view_from_url`
existant fait le reste.
## Architecture existante (rappel)
- `src/pages/tableau.py` :
- `sync_url_and_reset_button` — construit la query string à partir de
`filter_query`, `sort_by`, `hidden_columns` (via `invert_columns`).
- `restore_view_from_url` — réagit à `tableau_url.search`, applique
`filtres`/`tris`/`colonnes` au DataTable.
- `dcc.Location(id="tableau_url", refresh=False)` — la navigation interne ne
recharge pas la page.
- `src/pages/_compte_shell.py` :
- `current_user_has_subscription()`**point unique** de contrôle d'accès,
respecte le drapeau `TOUS_ABONNES`.
- `SECTIONS` — liste centralisée des sections de l'espace compte (chaque entrée
peut exiger `require_subscription: True`).
- `account_guard(path, require_subscription)` — protège une page compte
(redirige vers `/connexion` ou `/compte/abonnement`).
- `account_shell(active, contenu)` — gabarit (barre latérale + contenu).
- `src/subscriptions/db.py` — modèle de référence pour un module DB sur
`users.sqlite` : constante `SCHEMA`, `init_schema()`, fonctions CRUD via
`src.auth.db.get_conn()`.
- `src/subscriptions/setup.py::init_subscriptions` appelle `db.init_schema()` au
démarrage ; câblé dans `src/app.py` (`init_subscriptions(app.server)`).
- L'identité de l'utilisateur·ice connecté·e est disponible dans les callbacks
via `flask_login.current_user` (les callbacks Dash s'exécutent dans le
contexte de requête Flask).
## Conception
### 1. Stockage — table `saved_views` dans `users.sqlite`
Nouveau module `src/saved_views/db.py`, calqué sur `src/subscriptions/db.py`.
```sql
CREATE TABLE IF NOT EXISTS saved_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
table_name TEXT NOT NULL DEFAULT 'tableau',
name TEXT NOT NULL,
query TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (user_id, table_name, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_views_user
ON saved_views(user_id, table_name);
```
- `table_name` vaut toujours `'tableau'` pour l'instant. La colonne réserve la
place pour `/titulaire` et `/acheteur` plus tard, sans surcoût ni UI
aujourd'hui.
- `query` est la query string telle qu'elle apparaît dans l'URL (par ex.
`filtres=...&tris=...&colonnes=...`), produite et consommée exactement comme le
fait le partage existant. Appliquer = naviguer vers `/tableau?<query>`.
- `UNIQUE (user_id, table_name, name)` empêche les doublons de nom pour un·e même
utilisateur·ice.
Fonctions du module (toutes via `src.auth.db.get_conn()`) :
| Fonction | Rôle |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `init_schema()` | `executescript(SCHEMA)` — idempotent (`IF NOT EXISTS`). |
| `list_views(user_id, table_name)` | Vues de l'utilisateur·ice pour ce tableau, triées par `name`. |
| `upsert(user_id, table_name, name, query)` | Insert, ou `ON CONFLICT(user_id, table_name, name) DO UPDATE`**écrase** `query` et `updated_at`. Enregistrer sous un nom existant **met donc la vue à jour** (pas d'erreur). |
| `rename(view_id, user_id, new_name)` | Renomme ; `user_id` dans le `WHERE` garantit la propriété. |
| `delete(view_id, user_id)` | Supprime ; `user_id` dans le `WHERE` garantit la propriété. |
| `get(view_id, user_id)` | Une vue, contrôle de propriété. |
`init_schema()` est appelé au démarrage dans `src/app.py`, à côté de
`init_subscriptions(app.server)` :
```python
from src.saved_views import db as saved_views_db
saved_views_db.init_schema()
```
### 2. UI sur `/tableau` (abonné·es uniquement)
Ajout d'un conteneur `saved-views-bar` dans la `table-menu` existante de
`tableau.py`, **masqué par défaut** (`style={"display": "none"}`). Il contient
trois éléments :
1. **Bouton « Sauvegarder la vue »** — ouvre la modale de nommage.
2. **Modale de sauvegarde** — un champ texte (nom) + bouton « Enregistrer ».
3. **Menu déroulant « Mes vues »** (`dbc.DropdownMenu`) — la liste des vues.
#### Affichage conditionnel (gating)
Un callback rend la barre visible **uniquement pour les abonné·es** :
- Déclencheur : chargement de la page (Input sur `tableau_url.pathname` ou
`href`).
- Logique : `style = {}` si `current_user_has_subscription()`, sinon
`{"display": "none"}`.
Les composants restent présents dans le DOM (cachés), donc leurs callbacks sont
toujours valides — pas besoin de `suppress_callback_exceptions`. Le masquage
côté client ne suffit pas à lui seul : **toute écriture est re-contrôlée côté
serveur** (voir ci-dessous).
#### Sauvegarder
Callback de la modale (clic sur « Enregistrer ») :
- States : `filter_query`, `sort_by`, `hidden_columns` du DataTable + valeur du
champ nom.
- **Re-vérifie `current_user_has_subscription()`** ; si faux, ne fait rien
(no-update).
- Construit la query string de la même manière que
`sync_url_and_reset_button` (réutiliser/extraire la logique commune dans une
petite fonction utilitaire pour éviter la duplication).
- Appelle `saved_views.db.upsert(current_user.id, "tableau", name, query)`.
- Ferme la modale et rafraîchit le menu déroulant ; affiche une confirmation
(« Vue « <nom> » enregistrée. »).
- Nom vide → message d'erreur inline dans la modale, pas d'enregistrement.
#### Appliquer
Callback qui remplit le menu déroulant :
- Déclencheurs : chargement de la page **et** signal de rafraîchissement émis
après une sauvegarde.
- Récupère `list_views(current_user.id, "tableau")`.
- Rend un `dbc.DropdownMenuItem` par vue, **sous forme de lien** :
`href=f"/tableau?{view['query']}"`.
- Si la liste est vide, le menu n'est pas affiché (ou est désactivé avec un
libellé « Aucune vue enregistrée »).
Cliquer sur un item navigue vers `/tableau?<query>` (sans rechargement, grâce à
`dcc.Location(refresh=False)`), ce qui déclenche `restore_view_from_url`
existant. **Aucune nouvelle logique d'application n'est nécessaire.**
### 3. Page de gestion `/compte/vues`
#### Section dans `_compte_shell.py`
Ajouter une entrée à `SECTIONS` :
```python
{
"key": "vues",
"label": "Mes vues",
"href": "/compte/vues",
"require_subscription": True,
},
```
Cela rend automatiquement le lien visible dans la navigation de l'espace compte
pour les abonné·es (via `visible_sections`) et active la protection d'accès.
#### Page `src/pages/compte_vues.py`
Même structure que `src/pages/compte_admin.py` :
```python
def layout(**_):
guard = account_guard("/compte/vues", require_subscription=True)
if guard is not None:
return guard
contenu = _vues_section()
return account_shell("vues", contenu)
```
Contenu (`_vues_section`) :
- Titre « Mes vues » + courte explication.
- **Liste** des vues (`list_views(current_user.id, "tableau")`) : pour chaque
vue, son nom, sa date de création, un lien **« Ouvrir »** vers
`/tableau?<query>`, un bouton **« Renommer »** et un bouton **« Supprimer »**.
- **État vide** : message invitant à créer une vue depuis `/tableau`.
Actions, via callbacks pattern-matching (ids du type
`{"type": "vue-delete", "index": view_id}`), **contrôle de propriété par
`user_id`** dans chaque appel DB :
- **Supprimer**`delete(view_id, current_user.id)`, puis rafraîchit la liste.
- **Renommer** → champ de saisie (inline ou petite modale) →
`rename(view_id, current_user.id, new_name)`, puis rafraîchit la liste.
### 4. Sécurité
- Le masquage des contrôles sur `/tableau` est **cosmétique** ; la garantie
réelle est le contrôle serveur dans chaque callback d'écriture
(`current_user_has_subscription()`) et la présence de `user_id` dans tous les
`WHERE` des opérations DB (lecture comme écriture).
- `/compte/vues` est protégée par `account_guard(..., require_subscription=True)`
comme les autres sections réservées.
## Hors périmètre
- `/titulaire` et `/acheteur` : ces pages ne gèrent pas encore les paramètres
d'URL ni le partage. La colonne `table_name` réserve la place pour les y
étendre plus tard, sans UI ni callback dédiés aujourd'hui.
- Aucune modification du partage d'URL existant (« Partager la vue ») ni de la
persistance localStorage de la DataTable.
- Pas de partage d'une vue sauvegardée entre comptes, ni de vues publiques.
- La taille de page et la page courante ne font pas partie d'une vue (cohérent
avec le partage existant).
## Tests
`uv run pytest`
### Tests unitaires DB (`src/saved_views/db.py`)
- `upsert` crée une vue ; `list_views` la retourne.
- `upsert` avec un `(user_id, table_name, name)` existant **écrase** `query` et
met à jour `updated_at` (pas de doublon, pas d'erreur).
- `rename` / `delete` n'affectent que les vues du bon `user_id` (isolation entre
comptes).
- La suppression d'un·e utilisateur·ice supprime ses vues en cascade
(`ON DELETE CASCADE`).
### Tests de gating
- Le callback d'affichage de `saved-views-bar` renvoie un style masqué pour un·e
non-abonné·e et visible pour un·e abonné·e (en s'appuyant sur
`current_user_has_subscription()`).
- Le callback de sauvegarde refuse l'écriture (no-update) sans abonnement.
- `/compte/vues` redirige un·e non-abonné·e (comportement `account_guard`, déjà
couvert par le motif existant).
```
```
@@ -0,0 +1,124 @@
# Charte graphique des boutons — design
Date : 2026-06-30
Statut : validé (charte), à implémenter
## Problème
Le thème Bootstrap **Simplex** dérive ses couleurs contextuelles à partir de la
couleur primaire. La primaire de decp.info étant un terracotta chaud
(`rgb(179, 56, 33)` = `#b33821`), Simplex calcule un **`danger` mauve** (`#9b479f`)
et un **`secondary` gris très clair**. Résultat constaté sur `/compte/vues` :
- bouton **Renommer** (`color="secondary", outline=True`) : gris clair sur fond
blanc, quasi illisible ;
- bouton **Supprimer** (`color="danger", outline=True`) : mauve, sans rapport
sémantique avec une action destructive.
Les couleurs Simplex ne servent donc pas la lisibilité ni la sémantique. Il faut
**redéfinir les styles `primary`, `secondary` et `danger`** des boutons et établir
une charte claire où **chaque style a une fonction**.
## Principe directeur
**La couleur encode la fonction (sémantique) ; le remplissage encode l'emphase.**
Trois rôles seulement. Le code applicatif continue d'écrire
`color="primary|secondary|danger"` + `outline=True|False` exactement comme avant —
le CSS ne fait que restyler ces classes. Aucun changement d'API, aucun helper.
## La charte
| Style (dbc) | Fonction | Apparence |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `primary` plein | **L'**action principale validante d'un contexte — idéalement une par écran (Rechercher, Enregistrer, S'abonner, Envoyer) | terracotta plein `#b33821`, texte blanc (dégradé existant conservé) |
| `primary` + `outline` | action affirmative de moindre emphase (quand le plein serait trop lourd) | bord + texte terracotta, fond transparent ; survol = plein terracotta texte blanc |
| `secondary` + `outline` | action **neutre / alternative** (Renommer, Annuler, navigation) | gris ardoise — bord `#5a6570`, texte `#344054`, fond transparent ; survol = fond gris très clair |
| `danger` + `outline` | action **destructive** dans une liste ou le flux courant (Supprimer, Se désabonner) | rouge `#c0392b` — bord + texte, fond transparent ; survol = plein rouge texte blanc |
| `danger` plein | **confirmation finale** destructive (dans une modale) | rouge plein `#c0392b`, texte blanc |
### Règles d'usage
1. **Une seule action `primary` pleine par contexte.** Tout le reste est `outline`
ou `secondary`.
2. **Le destructif est `outline` par défaut** ; seul le bouton de confirmation
finale (modale) est plein rouge.
3. **`secondary` est le défaut neutre** — remplace les boutons gris clair
illisibles sur fond blanc.
## Palette
Quatre valeurs ajoutées/confirmées ; le reste hérité de Simplex.
```
terracotta #b33821 primary (chaud, marque) — existant
ardoise texte #344054 / bord #5a6570 secondary (froid, neutre)
rouge #c0392b danger (chaud saturé, distinct du terracotta)
disabled bord #ccc / texte #666 état désactivé — existant, conservé
```
Le rouge `#c0392b` est volontairement plus saturé et légèrement plus froid que le
terracotta `#b33821`, pour rester distinguable à côté d'un bouton primaire.
## Périmètre
### Dans le périmètre
- Override CSS des classes **boutons uniquement** dans `src/assets/css/style.css` :
- `.btn-primary` (harmonisation du dégradé déjà présent)
- `.btn-outline-primary`
- `.btn-secondary` et `.btn-outline-secondary`
- `.btn-danger` et `.btn-outline-danger`
- états `:hover`, `:focus-visible`, `:disabled` correspondants
- Audit des boutons existants (~46 occurrences `color=…`, dont 8 `outline=True`)
pour corriger les cas qui ne respectent pas la charte, en particulier :
- `src/saved_views/ui.py` : Renommer (`secondary`+outline), Supprimer
(`danger`+outline) — déjà conformes en intention, à revalider visuellement ;
- vérifier qu'aucun écran n'a deux `primary` pleins concurrents ;
- vérifier que les confirmations destructives en modale sont en `danger` plein
et les déclencheurs en liste en `danger` outline.
### Hors périmètre (inchangé)
- Les variables racine `--bs-primary`, `--bs-danger`, `--bs-secondary`, etc. **ne
sont pas modifiées.** L'override cible exclusivement les sélecteurs `.btn-*`.
Conséquence : `dbc.Alert`, badges, `text-danger`, etc. conservent la sémantique
Simplex.
- `info` / `success` / `warning` : utilisés quasi exclusivement par des
`dbc.Alert` (bleu / vert / ambre). On garde la sémantique Simplex pour les
alertes. Aucun bouton `info`/`success`/`warning` n'est restylé.
- `light` (1 bouton, navbar `src/app.py`) et `link` (1 bouton,
`src/saved_views/ui.py`) : laissés tels quels, hors charte des trois rôles.
## Approche d'implémentation
**Retenue : CSS-only + audit.**
Override des classes `.btn-*` dans `style.css`, puis passe de revue des boutons
existants. Minimal, sans churn d'API, respecte le fonctionnement de
`dash-bootstrap-components`.
**Écartée : helper Python encapsulant `dbc.Button`.** Plus invasif (toucher tous
les appels), aucun bénéfice ici puisque la charte se mappe exactement sur les
`color`/`outline` natifs.
## Détails CSS à respecter
- Scoper aux sélecteurs `.btn-*` pour ne pas toucher alertes/badges.
- Gérer explicitement `:hover`, `:focus-visible` (anneau de focus visible —
accessibilité clavier) et `[disabled]`.
- Conserver le `border-radius: 3px` et la typographie (`Inter`, poids 400) déjà en
place pour `.btn-primary`.
- Attention à la spécificité : le `.btn-primary` actuel est ciblé via
`button.btn.btn-primary`. Aligner la spécificité des nouvelles règles pour
éviter qu'elles s'annulent avec celles de `bootstrap.simplex.css`.
- Respecter `prefers-reduced-motion` si des transitions de survol sont ajoutées.
## Critères de réussite
- Sur `/compte/vues` : « Renommer » lisible (gris ardoise) et « Supprimer »
clairement rouge, plus aucun mauve ni gris clair illisible.
- Les trois rôles sont visuellement distincts (terracotta / ardoise / rouge) et
cohérents sur toutes les pages.
- Les `dbc.Alert` (info/success/warning) sont inchangées.
- Focus clavier visible sur tous les boutons.
@@ -0,0 +1,144 @@
# Vote pour les prochaines fonctionnalités (Roadmap) — Design
Issue : [#94](https://github.com/ColinMaudry/decp.info/issues/94)
Date : 2026-06-30
## Objectif
Permettre aux abonnés de voter pour les prochaines fonctionnalités depuis une
section Roadmap réservée aux abonnés (`/compte/roadmap`). La même roadmap est
exposée en lecture seule au public (`/a-propos/roadmap`). Les fonctionnalités
sont gérées dans GitHub via des labels ; le changelog du dépôt est affiché en
bas des deux pages.
## Modèle d'attribution des votes
- Un nouvel abonné reçoit **2 votes** au moment où sa **période d'essai se
termine** (passage du statut `trial`/pending à `active`).
- Il gagne ensuite **+1 vote par semaine** tant que son abonnement est **actif**.
- Les votes ne s'accumulent **pas** pendant une période sans abonnement (gel).
- Au **réabonnement**, l'accumulation reprend mais **les 2 votes initiaux ne
sont pas re-crédités**.
- Un abonné peut voter **plusieurs fois** pour la même fonctionnalité.
- Un vote dépensé est **définitif** : pas de retrait possible.
### Accumulation paresseuse (pas de cronjob)
État stocké sur la table `subscriptions` (2 colonnes ajoutées par migration) :
| Colonne | Type | Rôle |
| ---------------------- | ---------------------------- | ---------------------------------------------------- |
| `votes_balance` | `INTEGER NOT NULL DEFAULT 0` | Solde de votes dépensable |
| `votes_credited_until` | `TEXT` (NULL par défaut) | Curseur d'accumulation ; NULL tant que jamais activé |
Fonction `credit_pending(user_id)`, appelée **au chargement de
`/compte/roadmap` et avant chaque vote** :
1. Charger la ligne d'abonnement de l'utilisateur. Si absente → ne rien faire.
2. Si `votes_credited_until` est NULL **et** statut `active` (= fin d'essai
atteinte) → créditer les **+2 initiaux**, `votes_credited_until = maintenant`.
3. Si `votes_credited_until` posé **et** statut `active`
`semaines = floor((maintenant votes_credited_until) / 7 jours)` ;
si `semaines > 0` : `votes_balance += semaines` et avancer
`votes_credited_until` de `semaines × 7 jours`.
4. Statut non-`active` → aucun crédit (gel).
Cette fonction est **idempotente** : recharger la page le même jour ne crédite
rien de plus, car le curseur n'avance que par semaines pleines.
### Gel au désabonnement / réabonnement
Dans `update_from_webhook` (`src/subscriptions/db.py`) :
- **Résiliation** (`active``cancelled`) : appeler `credit_pending` pour
banquer les semaines acquises ; le statut `cancelled` bloque ensuite tout
crédit (le curseur reste figé).
- **Réabonnement** (`cancelled``active`) : remettre
`votes_credited_until = maintenant` pour ne pas créditer la période sans
abonnement, **sans re-créditer les +2** (curseur non-NULL).
## Registre des votes émis
Nouvelle table dans `users.sqlite` :
```sql
CREATE TABLE feature_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL
);
```
- Une ligne par vote émis (vote multiple = plusieurs lignes).
- Décompte d'une fonctionnalité = `COUNT(*)` groupé par `issue_number`.
- Décomptes **publics** (visibles sur `/a-propos/roadmap`).
Migrations ajoutées dans `src/migrations.py` (`_MIGRATIONS`) :
- ajout de `votes_balance` et `votes_credited_until` sur `subscriptions`
- création de la table `feature_votes`
## Intégration GitHub & cache
Nouveau module `src/utils/roadmap.py` :
- `fetch_roadmap_issues()`, décoré `@cache.memoize(timeout=3600)` (cache 1 h) :
`httpx.get` sur `GET /repos/ColinMaudry/decp.info/issues?state=open`, puis
filtrage local par label. Retourne deux listes de dicts
`{number, title, html_url}` : les `en cours` et les `mis au vote`.
- **Appel anonyme** (pas de `GITHUB_TOKEN`) : suffisant pour un dépôt public
avec un cache d'1 heure.
- On reste sur `httpx`, déjà dépendance du projet (Dash, `src/utils/data.py`,
`src/utils/tracking.py`) — pas d'ajout de `requests`.
`src/utils/roadmap.py` centralise aussi :
- la récupération des décomptes (`COUNT` groupé depuis `feature_votes`) ;
- un constructeur de composants `render_roadmap(editable: bool)` partagé par les
deux pages.
## Pages & navigation
### Page abonné — `src/pages/compte_roadmap.py`
- Route `/compte/roadmap`, `require_subscription=True`, via `account_shell`.
- À l'entrée : `credit_pending(user_id)`.
- Contenu :
1. Bandeau « Il te reste **N** votes » (solde courant).
2. Section **« En cours »** (label `en cours`) — titres liés vers GitHub, pas
de vote.
3. Section **« Au vote »** (label `mis au vote`) — triée par votes
décroissants ; chaque fonctionnalité affiche son décompte + un bouton
« Voter » (désactivé si solde = 0).
4. **Changelog** (`CHANGELOG.md` rendu via `dcc.Markdown`).
Action de vote : vérifier `votes_balance > 0`, décrémenter `votes_balance`,
insérer une ligne `feature_votes`, rafraîchir l'affichage.
### Page publique — `src/pages/a_propos/roadmap.py`
- Route `/a-propos/roadmap`, via `apropos_shell`.
- Identique mais `render_roadmap(editable=False)` : décomptes publics visibles,
**aucun bouton**, pas de bandeau de solde.
### Navigation
- Ajout d'une entrée `roadmap` dans `SECTIONS` de `_compte_shell.py`
(`require_subscription=True`).
- Ajout d'une entrée `roadmap` dans `SECTIONS` de `_apropos_shell.py`.
## Lien version & changelog
- Dans `src/app.py` (~ligne 194), le lien du numéro de version pointe vers
`/a-propos/roadmap` au lieu de l'URL GitHub du `CHANGELOG.md`.
- Lecture de `CHANGELOG.md` (racine du dépôt) rendue via `dcc.Markdown`,
partagée par les deux pages.
## Hors périmètre (YAGNI)
- Pas de cronjob / timer pour l'accumulation.
- Pas de retrait de vote.
- Pas de `GITHUB_TOKEN`.
- Pas de gestion d'écriture vers GitHub (les fonctionnalités restent gérées
manuellement via les labels GitHub).
@@ -0,0 +1,210 @@
# Handle d'abonnement personnalisé + historique des abonnements — design
- **Contexte** : suite de [2026-06-25-frisbii-abonnements-design.md](2026-06-25-frisbii-abonnements-design.md)
- **Date** : 2026-07-01
## Objectif
Remplacer le paramètre `generate_handle: true` (Frisbii génère le handle de
l'abonnement) par un handle choisi par colibre, préfixé `abo`, unique. Générer
ce handle nécessite de pouvoir consulter l'historique des abonnements d'un
utilisateur — ce qui n'est pas possible avec le schéma actuel de
`subscriptions`, qui n'a qu'une ligne par utilisateur (`user_id PRIMARY KEY`),
écrasée à chaque nouvelle souscription. Ce design scinde donc `subscriptions`
en un historique multi-lignes et introduit une table séparée pour l'état
cumulatif de l'utilisateur (votes, essai), en plus du changement de handle.
## Décisions clés
1. **Format du handle : `abo-{user_id}-{N}`**, `N` incrémental par
utilisateur (ex. `abo-42-1`, puis `abo-42-2` en cas de résiliation puis
réabonnement). Calculé en scannant les handles déjà utilisés par cet
utilisateur dans `subscriptions` (`LIKE 'abo-{user_id}-%'`) et en prenant
le suffixe max + 1.
2. **`subscriptions` devient un historique multi-lignes** (une ligne par
tentative d'abonnement Frisbii), `id` auto-incrémenté comme clé primaire,
`user_id` non-unique.
3. **Nouvelle table `subscriber_state`** (1 ligne par utilisateur) pour l'état
cumulatif indépendant du cycle de vie d'un abonnement particulier
(`trial_used`, `votes_balance`, `votes_last_credited_at`) — ces colonnes
n'ont pas de sens sur une ligne d'historique précise. Alternative écartée :
les ajouter à `users` (mélangerait identité et logique d'abonnement/billing
dans `auth/db.py`, qui doit rester focalisé sur l'identité).
4. **Le handle est écrit par colibre à la création, pas par le webhook.**
Contrairement au comportement actuel (Frisbii génère le handle, on
l'apprend via le webhook), colibre choisit `abo-{user_id}-{N}` et
l'enregistre dans `subscriptions.frisbii_subscription_handle` **avant**
d'appeler l'API Frisbii. Le webhook ne fait plus que lire/matcher sur ce
handle (`get_by_handle`), jamais l'écrire.
- Ordre retenu : écrire chez nous d'abord, appeler Frisbii ensuite. En cas
d'échec de l'appel Frisbii, on se retrouve avec une ligne locale
`pending``failed` dont le handle n'a jamais existé côté Frisbii — c'est
inoffensif (voir décision 5). L'ordre inverse (Frisbii d'abord) est plus
risqué : un succès Frisbii suivi d'un échec d'écriture locale laisserait
un abonnement réel et potentiellement payant chez Frisbii sans aucune
trace côté colibre, avec un risque de collision de handle au prochain
essai.
5. **Statut `failed`** pour distinguer un échec propre côté Frisbii (juste
après `create_pending`) d'un abonnement réellement `pending` chez Frisbii
(session créée, paiement pas encore confirmé). Sans ce statut, l'écran
`/compte/abonnement` affiche `_active_view` dès qu'une ligne existe pour
l'utilisateur (peu importe le statut), ce qui bloquerait indéfiniment le
bouton « S'abonner » derrière un écran « Ajouter une méthode de paiement »
pour un abonnement fantôme, et « Me désabonner » échouerait aussi (handle
inconnu de Frisbii). La ligne `failed` reste en base (pas de suppression)
pour que `abo-{user_id}-{N}` ne réutilise jamais ce suffixe — protection
utile en cas d'échec réseau ambigu (timeout ne garantit pas que la requête
n'a pas été traitée côté Frisbii).
6. **Chaque tentative de souscription crée une nouvelle ligne** (pas de
réutilisation d'une ligne `pending` existante). Chaque clic sur
« S'abonner » qui atteint `create_subscription_session` crée un véritable
nouvel objet abonnement chez Frisbii ; réutiliser une ligne locale
reviendrait à réutiliser un handle déjà proposé à Frisbii pour un objet
différent.
## Schéma
```sql
CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
frisbii_customer_handle TEXT,
frisbii_subscription_handle TEXT,
plan TEXT,
prix_ht REAL,
status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired' | 'failed'
current_period_end TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE UNIQUE INDEX idx_subscriptions_handle ON subscriptions(frisbii_subscription_handle);
CREATE TABLE subscriber_state (
user_id INTEGER PRIMARY KEY,
trial_used INTEGER NOT NULL DEFAULT 0,
votes_balance INTEGER NOT NULL DEFAULT 0,
votes_last_credited_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
```
### Migration
`ALTER TABLE` ne permet ni de retirer `user_id` comme clé primaire ni de
répartir des colonnes vers une nouvelle table. Migration par reconstruction de
table, sur le modèle de `_rebuild_users_password_nullable` dans
`src/auth/db.py:86` : fonction Python dédiée (pas une entrée de la liste
générique `_MIGRATIONS`, qui n'exécute qu'une instruction SQL simple), guard
d'idempotence sur `PRAGMA table_info(subscriptions)` (absence de la colonne
`id`), transaction unique :
1. `CREATE TABLE subscriptions_new` (nouveau schéma).
2. `INSERT INTO subscriptions_new (...) SELECT user_id, frisbii_customer_handle, ... FROM subscriptions` (les anciennes lignes deviennent l'historique, un `id` frais leur est attribué).
3. `INSERT INTO subscriber_state (user_id, trial_used, votes_balance, votes_last_credited_at, updated_at) SELECT ... FROM subscriptions` (depuis l'ancienne table, avant le `DROP`).
4. `DROP TABLE subscriptions`, `ALTER TABLE subscriptions_new RENAME TO subscriptions`, recréation des index.
Appelée depuis `subscriptions/db.py:init_schema()`, `PRAGMA foreign_keys = OFF`
pendant le `DROP`/`RENAME` (cascade), comme dans le précédent auth.
## API `subscriptions/db.py`
- `get_by_user`**`get_current(user_id) -> Row | None`** : dernière ligne
(`ORDER BY id DESC LIMIT 1`). Remplace tous les usages actuels de
« l'abonnement de l'utilisateur ».
- `get_by_customer`**`customer_known(customer_handle) -> bool`** : test
d'existence seul (un `frisbii_customer_handle` peut apparaître sur
plusieurs lignes d'historique).
- **`get_by_handle(subscription_handle) -> Row | None`** (nouveau) : résout la
ligne exacte à mettre à jour depuis un webhook.
- **`_next_handle(user_id) -> str`** (nouveau, privé) :
```python
def _next_handle(user_id: int) -> str:
prefix = f"abo-{user_id}-"
rows = get_conn().execute(
"SELECT frisbii_subscription_handle FROM subscriptions "
"WHERE user_id = ? AND frisbii_subscription_handle LIKE ?",
(user_id, f"{prefix}%"),
).fetchall()
n = max(
(int(r[0][len(prefix):]) for r in rows if r[0][len(prefix):].isdigit()),
default=0,
)
return f"{prefix}{n + 1}"
```
- **`create_pending(user_id, customer_handle, plan, prix_ht=None) -> tuple[str, int]`** :
génère le handle via `_next_handle`, **INSERT** une nouvelle ligne
(`status='pending'`, `frisbii_subscription_handle` déjà renseigné), renvoie
`(handle, subscription_id)`.
- **`mark_failed(subscription_id) -> None`** (nouveau) :
`UPDATE subscriptions SET status='failed', updated_at=? WHERE id=?`. Pas de
logique de crédit de votes (rien n'a jamais été actif), contrairement à
`set_cancelled`.
- **`update_from_webhook(subscription_handle, status, current_period_end)`** :
signature simplifiée (retrait de `customer_handle`), cible la ligne via
`get_by_handle`. `trial_used` déplacé vers `subscriber_state`.
- **`set_cancelled(subscription_id, current_period_end)`** : opère par `id`
de ligne (plus par `user_id`) — `routes.cancel()` récupère déjà la ligne via
`get_current`, lui passe `row["id"]`.
- `has_active_subscription`, `has_used_trial`, `credit_pending`, `spend_vote`,
`next_recharge_at`, `freeze_votes_cursor` : signatures inchangées, mais
relus/écrits sur `subscriber_state` pour le solde/l'essai. `credit_pending`
reste la seule fonction à toucher aux deux tables (lit le statut courant sur
`subscriptions` via `get_current`, écrit le solde sur `subscriber_state`).
`has_used_trial`/les fonctions de vote tolèrent l'absence de ligne
`subscriber_state` (première interaction de l'utilisateur : valeurs par
défaut, pas d'erreur).
## `client.py`
`create_subscription_session` reçoit un paramètre `handle: str` obligatoire ;
remplace `"generate_handle": True` par `"handle": handle` dans le corps de la
requête `POST /v1/subscription`.
## `routes.py`
- `subscribe()` : appelle `db.create_pending(...)``(handle, subscription_id)`,
passe `handle` à `client.create_subscription_session(..., handle=handle)`.
Dans le `except client.FrisbiiError`, appelle `db.mark_failed(subscription_id)`
avant de rediriger vers `?error=frisbii`.
- `cancel()` : récupère la ligne via `get_current`, passe `row["id"]` à
`set_cancelled`.
- `webhook()` : garde existant basé sur `customer_known(customer)` ; appelle
`update_from_webhook(sub_handle, status, current_period_end)` (sans
`customer_handle`).
## `compte_abonnement.py`
`layout()` : `if row is not None:``if row is not None and row["status"] != "failed":`
pour qu'une ligne `failed` ne bloque plus l'affichage de `_plan_cards` (retour
au formulaire de souscription).
## Sites d'appel à migrer
`get_by_user`/`get_by_customer` sont utilisés dans :
`src/pages/compte_roadmap.py`, `src/pages/_compte_shell.py`,
`src/pages/compte_abonnement.py`, `src/auth/routes.py` — renommage mécanique
vers `get_current`. `tests/subscriptions/test_db.py` et `test_routes.py`
nécessitent une réécriture significative (nouvelles signatures, statut
`failed`, table `subscriber_state`).
## Hors périmètre (YAGNI)
- **Nettoyage automatique des lignes `pending`/`failed` orphelines** (essais
de souscription abandonnés ou échoués). Inoffensif : ne bloque pas la
réinscription, ne crée pas de collision de handle. Pas de cron de
réconciliation pour l'instant — cohérent avec la décision « pas de
réconciliation périodique automatique » du design initial.
- **Scoping des colonnes des `SELECT *`** dans `subscriptions/db.py` /
`auth/db.py` (évoqué en discussion, explicitement écarté de cette tâche).
- **Vérifier une vraie méthode de paiement (Frisbii) avant d'afficher le
bandeau « Ajouter une méthode de paiement »** dans `_active_view` pour
`status == "pending"`. Aujourd'hui le bandeau s'affiche pour tout `pending`,
y compris juste après un ajout de méthode de paiement via
`/subscriptions/add-payment` si le webhook n'a pas encore fait passer le
statut à `trial`/`active`. `client.get_customer_payment_methods` existe déjà
pour ça. Orthogonal à ce design (ne dépend d'aucune décision ci-dessus) —
traité comme tâche de suivi séparée, avec son propre petit design (gestion
d'erreur API, message si paiement déjà présent mais webhook en retard).
@@ -0,0 +1,155 @@
# Panneau admin — éditeur générique de tables (`/admin`)
Date : 2026-07-03
Statut : design validé
Remplace : [2026-07-03-admin-ui-design.md](2026-07-03-admin-ui-design.md) (pages dédiées liste/détail/journal + formulaire de
changement de statut) — abandonné avant merge sur `main` au profit de ce design.
## Contexte
Le design précédent (pages `/admin`, `/admin/user/<id>`, `/admin/journal` + un
formulaire dédié pour changer un statut d'abonnement) a été entièrement
implémenté et revu (9 tâches, revue finale "ready to merge"), mais **jamais
mergé sur `main`**. Avant la fusion, il est apparu qu'ajouter une page dédiée
à chaque nouveau besoin de support serait trop lent à faire évoluer. `dash_table.DataTable`
supporte l'édition de cellule (`editable=True`), ce qui permet une approche
plus générique : une seule page `/admin` avec un menu déroulant pour choisir
la table SQLite à afficher, filtrer nativement, et éditer directement les
cellules.
## Portée
Trois tables du schéma `users.sqlite` sont éditables : `users` (hors
`password_hash`, totalement exclue), `subscriptions`, `subscriber_state`.
Une quatrième table, `admin_actions` (journal d'audit), est consultable dans
le même sélecteur mais **en lecture seule**. Toute autre table du schéma
(`email_verification_tokens`, `password_reset_tokens`, `oauth_identities`,
`saved_views`, `feature_votes`) est hors périmètre — pas dans le sélecteur.
## Architecture
### Fichiers
- **`src/pages/admin/liste.py`** (réécrit) : page unique `/admin` — menu
déroulant de sélection de table + `dash_table.DataTable` unique, editable,
`filter_action="native"`, `sort_action="native"`, `page_action="native"`,
`page_size=20`.
- **Supprimés** : `src/pages/admin/detail.py`, `src/pages/admin/journal.py`,
`src/admin/routes.py` (le blueprint Flask du formulaire de changement de
statut — plus de formulaire, l'édition passe par un callback Dash).
- **`src/pages/admin/_shell.py`** simplifié : `admin_nav()` supprimé (une
seule page, plus de navigation entre sous-pages) ; `not_admin()` conservé
à l'identique.
- **`src/admin/guard.py`** (`is_admin()`) : inchangé, réutilisé tel quel.
- **`src/admin/db.py`** (`log_action`, `list_actions`) : inchangé, réutilisé
pour l'audit des éditions de cellule.
- **Nouveau `src/admin/tables.py`** : registre des tables autorisées et
fonction générique d'écriture.
### Registre des tables (`src/admin/tables.py`)
```python
@dataclass
class TableConfig:
label: str
columns: list[str] # colonnes affichées, dans l'ordre
editable_columns: set[str] # sous-ensemble de columns
pk: str
column_types: dict[str, type] # int | float | str, pour les colonnes éditables
dropdowns: dict[str, list[str]] # colonne -> valeurs autorisées (optionnel)
target_user_id: Callable[[dict], int | None] # dérive le user_id à loguer depuis une ligne
```
| Table | Colonnes affichées | Éditables | Contraintes |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------- |
| `users` | id, email, email_verified, siret, pending_email, created_at, updated_at (`password_hash` exclue) | email, email_verified, siret, pending_email | `email_verified` : dropdown 0/1 |
| `subscriptions` | id, user_id, frisbii_customer_handle, frisbii_subscription_handle, plan, prix_ht, status, current_period_end, created_at, updated_at | plan, prix_ht, status, current_period_end | `status` : dropdown `SUBSCRIPTION_STATUSES` ; `plan` : dropdown clés de `PLANS` |
| `subscriber_state` | user_id, trial_used, votes_balance, votes_last_credited_at, updated_at | trial_used, votes_balance, votes_last_credited_at | `trial_used` : dropdown 0/1 |
| `admin_actions` | id, admin_email, action, target_user_id, details, created_at | _(aucune)_ | — |
Règles fixes, non contournables par la config : la clé primaire et les
colonnes `created_at`/`updated_at` ne sont jamais dans `editable_columns`.
Les handles Frisbii (`frisbii_customer_handle`, `frisbii_subscription_handle`)
et `user_id` (FK) sont explicitement exclus de l'édition — modifier un
handle désynchroniserait silencieusement l'état réel côté Frisbii sans rien
signaler ; modifier `user_id` casserait le rattachement à l'utilisateur.
`target_user_id` par table : `users``row["id"]`, `subscriptions`
`row["user_id"]`, `subscriber_state``row["user_id"]` (sa propre PK),
`admin_actions` → non applicable (table en lecture seule, jamais loguée).
## Flux d'édition
1. **Callback de sélection de table**`Input("admin-table-select", "value")``Output("admin-table", "data"/"columns"/"dropdown_conditional")`.
Charge `SELECT * FROM <table>``<table>` est validé contre les clés de
`TABLES` avant toute requête — jamais interpolé tel quel depuis l'UI, donc
aucune table hors liste blanche n'est accessible même via un payload de
callback forgé.
2. **Callback d'édition**`Input("admin-table", "data")`,
`State("admin-table", "data_previous")`,
`State("admin-table-select", "value")`. Diff ligne par ligne pour
localiser la cellule modifiée.
- **Revalidation côté serveur** : le callback ignore tout changement sur
une colonne absente de `editable_columns`, même si elle est présente
dans le payload — le flag `editable` de la DataTable est une aide
visuelle côté client, pas une garantie de sécurité. Seul un admin
authentifié (`is_admin()` déjà en tête de page) peut atteindre ce code.
- **Coercition de type avant écriture** : SQLite étant faiblement typé,
écrire sans validation permettrait à du texte non convertible de finir
silencieusement dans une colonne `REAL`/`INTEGER`. Chaque colonne
éditable a un type attendu (`column_types`) ; une valeur qui ne
convertit pas proprement est rejetée : la cellule revient à son
ancienne valeur, une alerte s'affiche, rien n'est écrit.
- Pour les colonnes à `dropdowns`, la valeur est aussi vérifiée contre la
liste autorisée avant écriture (défense en profondeur, en plus du
rendu en dropdown côté UI).
- Écriture via `set_cell(table: str, pk_value, column: str, value) -> None`
dans `src/admin/tables.py``UPDATE <table> SET <column> = ? WHERE <pk> = ?`.
- Audit : `log_action(current_user.email, f"edit_{table}", target_user_id, f"{column}: {old} → {new}")`.
## Gestion des erreurs
- Table hors liste blanche (requête forgée) : aucune donnée renvoyée, pas de
crash.
- Coercition de type échouée : cellule restaurée à l'ancienne valeur +
`dbc.Alert` d'erreur, rien n'est écrit en base.
- `UPDATE` touchant 0 ligne (ligne supprimée entre-temps) : alerte d'erreur,
pas d'exception non gérée.
## Tests
- **`tests/admin/test_tables.py`** (nouveau, remplace `tests/admin/test_routes.py`) :
table hors liste blanche rejetée, colonne non éditable rejetée même
présente dans le payload, coercition de type (succès et échec) par type de
colonne, écriture réussie relue en base, `admin_actions` loguée avec le
bon `target_user_id` par table.
- Les callbacks Dash (sélection de table, édition de cellule) sont de
simples fonctions Python décorées — testables en les import-appelant
directement avec des données factices, sans dispatch Dash ni serveur
Flask.
- **`tests/admin/test_guard.py`** : inchangé (guard non affecté par ce
pivot).
- **`tests/admin/test_pages.py`** : les tests anonyme/non-admin → 404 sont
conservés à l'identique (page toujours gardée par `is_admin()`). Le test
de flux complet est réécrit : login admin réel → `/admin` → sélection de
"subscriptions" dans le menu déroulant → édition de la cellule `status`
d'une ligne → vérification de la nouvelle valeur affichée et d'une ligne
dans `admin_actions` (consultable via le même sélecteur). Même contrainte
de nettoyage explicite de `tests/users.test.sqlite` (fichier committé,
partagé pour toute la session de tests) qu'auparavant.
- Supprimés : `tests/admin/test_routes.py` et toute couverture spécifique à
`detail.py`/`journal.py`.
## Hors périmètre
- Toute table hors de la liste blanche (`email_verification_tokens`,
`password_reset_tokens`, `oauth_identities`, `saved_views`,
`feature_votes`) — pas dans le sélecteur, pas éditable.
- `password_hash` : totalement exclue de l'affichage de `users` (ni lecture
ni édition).
- Ajout/suppression de lignes depuis l'éditeur — édition de cellule
seulement, pas de création/suppression.
- Plusieurs administrateurs (`ADMIN_EMAIL` unique, inchangé du design
précédent).
- Pagination de `admin_actions` au-delà de `list_actions(limit=200)`
(inchangé du design précédent).
@@ -0,0 +1,240 @@
# Panneau admin interne (`/admin`)
Date : 2026-07-03
Statut : **remplacé** — voir [2026-07-03-admin-table-editor-design.md](2026-07-03-admin-table-editor-design.md).
Implémenté (9 tâches, revue finale approuvée) mais jamais mergé sur `main` ;
abandonné avant fusion au profit d'un éditeur de tables générique.
## Contexte
Pour le débuggage et le support utilisateur, il n'existe aujourd'hui aucun
moyen de consulter ou corriger l'état d'un compte (`users.sqlite`) sans passer
par SQL en direct sur le serveur. On ajoute un panneau admin interne,
accessible à une seule adresse email (variable d'env `ADMIN_EMAIL`), pour
consulter la liste des comptes, l'historique complet des abonnements Frisbii
d'un compte, et corriger manuellement un statut d'abonnement en cas de
désynchronisation avec Frisbii — avec une trace de chaque action.
Flask-Admin a été écarté : ses `ModelView` supposent un ORM (SQLAlchemy,
Peewee, MongoEngine) alors que `src/auth/db.py` et `src/subscriptions/db.py`
utilisent `sqlite3` brut. Le gain de Flask-Admin (génération auto des
formulaires/listes depuis des modèles ORM) ne s'applique donc pas ici.
## Architecture générale
Le panneau reprend le pattern déjà utilisé pour `/compte/*` (voir
[2026-06-24-espace-compte-design.md](2026-06-24-espace-compte-design.md)) :
des pages Dash (`register_page`) pour la lecture, un Blueprint Flask pour les
mutations (POST + redirect + query params pour les messages), le tout sur le
même serveur Flask (`use_pages=True`, `src/app.py:87`) où les blueprints
enregistrés (`src/auth/routes.py:auth_bp`) coexistent avec le routage de
pages Dash sans collision, du moment que les chemins ne se recouvrent pas.
### Pages Dash (lecture)
Nouveau package `src/pages/admin/` (miroir de `src/pages/compte/`) :
- `src/pages/admin/liste.py``/admin`
- `src/pages/admin/detail.py``path_template="/admin/user/<user_id>"`,
avec `def layout(user_id=None, **_):`. Dash injecte le segment dynamique
comme argument nommé de `layout()`, exactement comme `compte/admin.py`
reçoit déjà ses paramètres de query string
(`layout(error=None, password_changed=None, ...)`,
`src/pages/compte/admin.py:176`). C'est délibérément différent du pattern
utilisé par `acheteur.py`/`marche.py` (`path_template` + layout statique +
parsing de `pathname` côté client dans un callback,
ex. `src/pages/acheteur.py:52`) : ces pages n'ont pas de contrôle d'accès
serveur, alors qu'ici `admin_guard`/`get_user_by_id` doivent s'exécuter
côté serveur avant le rendu, donc `user_id` doit être disponible
synchrone dans `layout()`.
- `src/pages/admin/journal.py``/admin/journal`
### Blueprint Flask (mutations)
Nouveau module `src/admin/routes.py`, `Blueprint("admin", __name__, url_prefix="/admin/actions")`, enregistré dans `src/auth/setup.py`
(`init_auth`) juste après `app.register_blueprint(auth_bp)`. Une seule route :
- `POST /admin/actions/subscription-status`
### Garde d'accès
Nouveau module `src/admin/guard.py` :
```python
def is_admin() -> bool:
admin_email = os.getenv("ADMIN_EMAIL")
return bool(
admin_email
and current_user.is_authenticated
and current_user.email.lower() == admin_email.lower()
)
```
Utilisée aux deux points d'entrée :
- Dans chaque `layout()` Dash (`src/pages/admin/*.py`) : si `not is_admin()`,
retourne un composant "404" simple (`html.H1("404")` — pas de redirect, pour
ne pas laisser deviner l'existence de la route à un compte non-admin) au
lieu du contenu de la page.
- Dans le blueprint (`before_request` du blueprint `admin`) : si
`not is_admin()`, `abort(404)`. Défense en profondeur — la route de mutation
ne doit pas dépendre uniquement du fait que l'UI qui y pointe soit cachée.
Nouvelle variable d'env `ADMIN_EMAIL` ajoutée à `.template.env`, dans une
nouvelle section `# Panneau admin (accès à /admin)`.
## Couche données
Pas de nouvelle table de schéma pour users/subscriptions ; une seule nouvelle
table pour le journal d'audit (voir plus bas), ajoutée via une migration
`src/migrations.py` comme documenté dans `CLAUDE.md`.
### `src/auth/db.py`
```python
def list_users(limit: int = 1000) -> list[sqlite3.Row]:
"""Tous les users, plus récents en premier, plafonné à `limit`."""
```
### `src/subscriptions/db.py`
```python
def list_by_user(user_id: int) -> list[sqlite3.Row]:
"""Historique complet des abonnements d'un user, plus récent en premier."""
# SELECT * FROM subscriptions WHERE user_id = ? ORDER BY id DESC
def set_status(subscription_id: int, status: str) -> None:
"""Force le statut d'un abonnement (correction manuelle)."""
# UPDATE subscriptions SET status = ?, updated_at = ? WHERE id = ?
```
`get_current(user_id)` (déjà existant, `src/subscriptions/db.py:115`) reste
utilisé pour identifier l'abonnement "courant" à afficher en tête de liste et
cibler par défaut dans le formulaire de changement de statut.
Statuts valides (déduits de `src/subscriptions/webhooks.py:map_subscription`) :
`active`, `trial`, `cancelled`, `expired`, `pending`. Cette liste est
centralisée dans une constante `SUBSCRIPTION_STATUSES` (nouveau, dans
`src/subscriptions/db.py` ou `plans.py`) réutilisée à la fois pour peupler le
dropdown du formulaire et pour valider côté route.
### Table d'audit `admin_actions`
Migration `src/migrations.py` (id `0006_create_admin_actions`) :
```sql
CREATE TABLE IF NOT EXISTS admin_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_email TEXT NOT NULL,
action TEXT NOT NULL,
target_user_id INTEGER,
details TEXT,
created_at TEXT NOT NULL
)
```
Nouveau module `src/admin/db.py` (réutilise `get_conn()` de
`src/auth/db.py`, même fichier `users.sqlite`) :
```python
def log_action(admin_email: str, action: str, target_user_id: int | None,
details: str | None) -> None: ...
def list_actions(limit: int = 200) -> list[sqlite3.Row]: ...
```
## Pages
### `/admin` (liste)
- Garde d'accès en tête de `layout()`.
- `dash_table.DataTable(filter_action="native", sort_action="native", page_action="native", page_size=20, ...)` alimenté par `list_users()`
filtrage/tri/pagination entièrement côté navigateur (pas de callback
serveur), à la différence de `/tableau` dont le `filter_action="custom"`
(`src/pages/tableau.py:74`) n'existe que pour déléguer le filtrage à DuckDB
sur ~1,5M lignes ; ici quelques centaines de lignes au plus, le mode natif
suffit pour les trois (20 lignes par page).
- Colonnes : email, email vérifié, plan courant, statut courant, créé le.
- Lien "Voir" par ligne vers `/admin/user/<id>`.
- Lien vers `/admin/journal` dans l'en-tête de page.
### `/admin/user/<user_id>` (détail)
- Garde d'accès, puis `get_user_by_id(user_id)` ; si absent, composant "user
introuvable" (pas une 500).
- **Compte** : email, email vérifié, siret, créé le.
- **État abonné** (`subscriber_state`) : votes_balance, trial_used —
affichage seul, pas d'édition dans ce lot.
- **Historique des abonnements** (`list_by_user`) : table triée (plus récent
en haut), badge "actuel" sur la ligne correspondant à `get_current`.
- **Changer le statut** : formulaire ciblant l'abonnement courant — dropdown
des statuts valides + bouton, POST vers `/admin/actions/subscription-status`
avec champs cachés `user_id`, `subscription_id`, et le `csrf_token` (même
pattern que `src/pages/compte/admin.py:_csrf`).
- Bannière succès/erreur lue depuis les query params (`status_changed=1`,
`error=invalid_status`), même pattern que `compte/admin.py`
(`ERROR_MESSAGES`/`SUCCESS_MESSAGES`).
### `/admin/journal`
- Garde d'accès, puis `DataTable` en lecture seule de `list_actions(limit=200)` :
date, email admin, action, user ciblé (lien vers `/admin/user/<id>` si
`target_user_id` non nul), détails.
## Route de mutation
`POST /admin/actions/subscription-status`, protégée par le
`before_request` du blueprint (`is_admin()` → sinon `abort(404)`) :
1. Lit `user_id`, `subscription_id`, `status` du formulaire.
2. Valide `status` contre `SUBSCRIPTION_STATUSES` → sinon redirect
`/admin/user/<user_id>?error=invalid_status`.
3. Vérifie que la subscription `subscription_id` appartient bien à `user_id`
(relit la ligne via `get_current`/une lecture directe) avant d'écrire —
empêche de modifier l'abonnement d'un autre user par ID forgé dans le
formulaire.
4. Capture l'ancien statut, appelle `set_status(subscription_id, status)`.
5. `log_action(current_user.email, "subscription_status_change", user_id, f"{old_status} → {status}")`.
6. Redirect `/admin/user/<user_id>?status_changed=1`.
CSRF : protection déjà globale via `CSRFProtect(app)` (`src/auth/setup.py:53`),
aucun code supplémentaire nécessaire au-delà du champ caché habituel.
## Tests
Nouveau dossier `tests/admin/`, calqué sur les conventions existantes :
- **Unitaires** (`tests/admin/test_guard.py`), sur le modèle de
`tests/test_compte_shell.py` : `is_admin()` avec `current_user` mocké
(`patch("src.admin.guard.current_user", ...)`) — anonyme → `False`, connecté
avec un email différent de `ADMIN_EMAIL``False`, email correspondant
(insensible à la casse) → `True`, `ADMIN_EMAIL` non défini → `False`.
- **Route de mutation** (`tests/admin/test_routes.py`), sur le modèle de
`tests/auth/conftest.py` (`app`/`client`/`users_db_path` fixtures). Session
admin simulée par injection directe des clés Flask-Login, comme
`tests/subscriptions/conftest.py:95` (`logged_in_client`) :
`sess["_user_id"] = str(uid); sess["_fresh"] = True`, avec
`monkeypatch.setenv("ADMIN_EMAIL", ...)` et un user créé avec cet email.
Cas couverts : statut invalide → 302 vers `?error=invalid_status` sans
écriture ; subscription n'appartenant pas au user → refusée ; succès →
statut mis à jour + une ligne dans `admin_actions` ; accès sans être admin
→ 404.
- **Pages** (`tests/admin/test_pages.py`, Selenium/`DashComposite`, sur le
modèle de `tests/test_compte_pages.py`) : anonyme sur `/admin` → 404 (pas de
redirection vers `/connexion`, cohérent avec la garde d'accès qui ne
distingue pas anonyme / authentifié non-admin) ; compte non-admin
authentifié → 404 ; admin → liste visible, navigation vers un détail,
changement de statut reflété à l'écran et dans `/admin/journal`.
## Hors périmètre
- Reset de mot de passe et suppression de compte depuis l'admin (l'utilisateur
garde ces actions en self-service via `/compte/admin`).
- Édition de `subscriber_state` (votes_balance, trial_used).
- Plusieurs administrateurs (`ADMIN_EMAIL` unique pour l'instant — passer à
une liste serait un changement d'une ligne dans `is_admin()` si besoin
futur).
- Pagination de `/admin/journal` (reste une liste plate limitée à 200 lignes) ;
et pagination de `/admin` au-delà de la limite fixe de `list_users()`
(1000 users) — la pagination native ne pagine que les lignes déjà chargées,
pas la requête SQL ; à revoir si le volume le justifie.
@@ -0,0 +1,170 @@
# Cartes /acheteur et /titulaire : afficher la contrepartie (clustering)
## Objectif
Sur les fiches `/acheteurs/<id>` et `/titulaires/<id>`, la carte affiche
aujourd'hui uniquement la position de l'organisme consulté (un point unique,
issu de l'API annuaire, via `point_on_map`). On veut y ajouter la position de
la **contrepartie** :
- sur `/acheteurs/<id>` : les titulaires avec qui cet acheteur a contracté ;
- sur `/titulaires/<id>` : les acheteurs qui ont contracté avec ce titulaire.
Design et code inspirés de `/observatoire` (`get_geographic_maps` /
`make_clusters_map` dans `src/figures.py`), notamment le clustering
dash-leaflet. Couleurs identiques : acheteur en orange (`#E69F00`), titulaire
en bleu ciel (`#56B4E9`).
## Comportement attendu
- La carte suit le filtre **Année** de la page (comme le tableau Top
titulaires/acheteurs et l'histogramme de distance) — elle n'est donc plus
calculée à partir de l'API annuaire mais des données DECP (marchés).
- Le marqueur de l'organisme consulté et ceux de la contrepartie utilisent
**la même source de données** (`acheteur_longitude`/`latitude` et
`titulaire_longitude`/`latitude` des marchés), avec clustering identique à
`/observatoire`.
- Le cadrage de la carte s'ajuste automatiquement (fitBounds) pour englober
l'organisme et toutes ses contreparties, avec un léger padding.
- Si aucune coordonnée exploitable n'est disponible (aucun marché
géolocalisé, ou dataset sans colonnes longitude/latitude), repli sur une
vue France fixe (centre `[46.6, 2.2]`, zoom 5) plutôt qu'une zone vide.
- Contrairement à `/observatoire`, **pas** de découpage par région/DOM-TOM ni
de bascule chloroplèthe : une seule carte cluster, le volume de points
d'un organisme unique restant faible.
- La carte dash-leaflet conserve les contrôles de zoom +/- natifs de Leaflet
(contrairement à l'actuelle carte Plotly qui n'en a pas) — comportement par
défaut de `dl.Map`, aucune configuration spécifique nécessaire.
## Architecture
### Nouvelle fonction partagée `build_org_markers` (`src/figures.py`)
Extraite du corps de `get_geographic_maps` (branche `"clusters"`), pour être
réutilisée par `/observatoire` et par la nouvelle carte org :
```python
ORG_COLORS = {
"acheteur": "#E69F00", # orange
"titulaire": "#56B4E9", # bleu ciel
}
def build_org_markers(lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]) -> list[dict]:
"""Regroupe les marchés par point géographique pour un type d'organisme.
Retourne [] si les colonnes longitude/latitude de ce type sont absentes
du LazyFrame (ex: tests/test.parquet), sans lever d'exception.
"""
```
Le groupement (par `{org_type}_longitude`, `{org_type}_latitude`,
`{org_type}_nom`, comptage `nb_marches`) et le format des marqueurs
(`{"lat", "lon", "tooltip", "marker_color"}`) restent identiques à
l'implémentation actuelle. Nouveauté : garde d'absence de colonnes via
`lff.collect_schema().names()` (cf. `get_considerations_card_content` pour le
même pattern) — corrige au passage un crash latent non testé de
`get_geographic_maps` sur des jeux de données sans colonnes géo.
`get_geographic_maps` est mis à jour pour appeler `build_org_markers(lff, org_type)` au lieu de la boucle inline, et pour utiliser `ORG_COLORS` au lieu
de son dict `colors` local.
### Nouvelle fonction `get_org_location_map` (`src/figures.py`)
```python
def get_org_location_map(
dff: pl.DataFrame,
home_type: Literal["acheteur", "titulaire"],
map_id: str,
) -> dl.Map:
"""Carte cluster (dash-leaflet) de l'organisme et de sa contrepartie."""
```
- Calcule les marqueurs pour `home_type` et son type complémentaire via
`build_org_markers`.
- Construit jusqu'à deux couches `dl.GeoJSON` clusterisées (une par type
présent), réutilisant le JS clientside existant
(`dash_clientside.leaflet.pointToLayer` / `clusterToLayer`, inchangé) et le
même mécanisme `options={"fillColor": ORG_COLORS[org_type]}` que
`make_clusters_map`.
- Calcule les bounds `[[lat_min, lon_min], [lat_max, lon_max]]` sur l'ensemble
des points (les deux types confondus) et les passe via la prop `bounds` de
`dl.Map`, avec `boundsOptions={"padding": [30, 30], "maxZoom": 12}`.
- Si aucun point n'est disponible : `center=[46.6, 2.2]`, `zoom=5` (pas de
prop `bounds`).
- `style={"width": "100%", "height": "300px"}` (cohérent avec la hauteur
actuelle de la colonne carte).
- Les ids des couches GeoJSON sont dérivés de `map_id`
(`f"{map_id}-acheteur"` / `f"{map_id}-titulaire"`).
## Flux de données (pages)
Sur `src/pages/acheteur.py` et `src/pages/titulaire.py`, le rendu de la carte
est aujourd'hui mélangé dans le callback qui interroge l'API annuaire
(`update_acheteur_infos` / `update_titulaire_infos`, déclenché uniquement par
l'URL). On sépare :
- Ce callback existant perd l'`Output` `*_map` et l'appel à `point_on_map` ;
il continue de fournir nom/commune/département/région/lien annuaire,
inchangés.
- **Nouveau callback dédié** par page, avec `Input` sur l'URL **et** le
dropdown Année (comme `get_top_titulaires`/`get_top_acheteurs`) :
```python
@callback(
Output("acheteur_map", "children"),
Input("acheteur_url", "pathname"),
Input("acheteur_year", "value"),
)
def update_acheteur_map(pathname, ach_year):
where_sql, params = _acheteur_scope(pathname, ach_year)
geo_columns = [
c
for c in [
"uid",
"acheteur_longitude",
"acheteur_latitude",
"acheteur_nom",
"titulaire_longitude",
"titulaire_latitude",
"titulaire_nom",
]
if c in schema.names()
]
dff = query_marches(where_sql, params, columns=geo_columns)
return get_org_location_map(dff, "acheteur", "acheteur_map_leaflet")
```
Le filtre `geo_columns` sur `schema.names()` évite une erreur SQL DuckDB
(colonne inexistante) quand le dataset ne contient pas encore les colonnes
longitude/latitude — c'est le cas de `tests/test.parquet` aujourd'hui. Dans
ce cas, `build_org_markers` renvoie `[]` pour les deux types et
`get_org_location_map` bascule sur la vue France par défaut.
Symétrique sur `src/pages/titulaire.py` (`update_titulaire_map`,
`_titulaire_scope`, `"titulaire"` comme `home_type`).
## Nettoyage
`point_on_map` (carte Plotly à point unique basée sur l'annuaire) devient
inutilisée une fois les deux pages migrées : suppression de la fonction dans
`src/figures.py` et de son import dans `acheteur.py`/`titulaire.py`.
## Tests
- Test unitaire de `build_org_markers` : cas nominal (plusieurs points,
comptage), cas colonnes absentes (`[]` sans exception), cas coordonnées
nulles filtrées.
- Test unitaire de `get_org_location_map` : bounds calculés sur des points
connus ; repli sur la vue France par défaut quand aucun marqueur.
- Pas de nouveau test Selenium dédié (aucun test existant ne navigue
actuellement vers `/acheteurs/<id>` ou `/titulaires/<id>` dans le
navigateur) ; vérification manuelle via le serveur de dev recommandée
après implémentation.
## Hors périmètre (YAGNI)
- Pas de découpage par région/DOM-TOM ni de bascule chloroplèthe sur ces
pages (réservé à `/observatoire`).
- Pas de changement du texte département/région/lien annuaire (reste basé
sur l'annuaire des entreprises).
- Pas de nouveau filtre autre que celui déjà présent (Année).
@@ -0,0 +1,222 @@
# Refonte du tunnel d'abonnement — abonnement public
## Contexte et objectif
Aujourd'hui, pour s'abonner, un visiteur doit : (1) créer un compte, (2) valider
son email, (3) se connecter, (4) seulement là découvrir et entamer le tunnel
d'abonnement. Les cards d'abonnement vivent derrière l'authentification, dans
`/compte/abonnement`. Ce parcours est trop long et masque l'offre aux visiteurs.
Objectif : **rendre l'offre d'abonnement visible aux visiteurs non connectés** et
raccourcir le tunnel, tout en préservant l'accès des anciens abonnés à leur compte
(factures) même s'ils ne sont plus abonnés.
## Principes
- **Se connecter ≠ être abonné.** La connexion ne requiert pas d'abonnement (déjà le
cas). Un ancien abonné garde l'accès à `/compte/admin` et `/compte/abonnement`
(guards en `require_subscription=False`). Il perd seulement l'accès aux sections
réservées (`/compte/vues`, `/compte/roadmap`).
- **L'offre est publique.** La page `/a-propos/abonnement` (URL inchangée, au
singulier) présente les cards et un unique bouton d'entrée dans le tunnel.
- **Le choix du plan (simple/soutien) se fait dans `mes-infos`**, pas sur la card.
Cela évite de transporter le plan à travers inscription → email → mes-infos.
## Flow cible
```
Visiteur
└─ navbar "Connexion" ─────────────► /connexion (formulaire + lien vers offre)
└─ /a-propos/abonnement (public) ──► cards + explainer + bouton "Je m'abonne"
├─ non connecté ────────────► /inscription
│ ├─ email : signup → email de validation
│ │ └─ clic lien ► /auth/verify-email : auto-login
│ │ └─► /compte/abonnement/mes-infos
│ └─ LinkedIn (next=mes-infos) ─► /compte/abonnement/mes-infos
└─ déjà connecté, non abonné ► /compte/abonnement/mes-infos
/compte/abonnement/mes-infos
└─ radios plan (simple/soutien, défaut simple) + rappel tarifs
+ infos facturation + cases CGU
└─ POST /subscriptions/subscribe ► checkout Frisbii
/compte/abonnement (connecté)
├─ abonné actif ──────► vue de gestion (inchangée)
└─ non abonné ────────► texte + bouton :
├─ has_used_trial → "Me réabonner" ─► /a-propos/abonnement
└─ sinon → "M'abonner" ─► /a-propos/abonnement
```
## Décisions actées
1. **Bouton unique « Je m'abonne »** centré sous les 2 cards de la page publique.
Les cards deviennent purement informatives (plus de bouton « S'abonner » par card).
2. **Choix du plan par boutons radio dans `mes-infos`** (défaut « simple »), avec
rappel des tarifs. Aucun plan transporté depuis la card.
3. **URL de la page publique inchangée** : `/a-propos/abonnement` (singulier). Pas de
renommage ni de redirect.
4. **Cible du bouton conditionnelle à l'état de connexion** (voir détail §2).
5. **`/compte/abonnement` non-abonné** : plus de cards, un bouton « M'abonner » /
« Me réabonner » selon `has_used_trial(user_id)`.
6. **Signal « déjà abonné » = `has_used_trial`** (True quand un abonnement a atteint
`trial`/`active`). Un checkout Frisbii abandonné (`pending`/`failed`, aucun accès)
→ considéré « jamais abonné » → « M'abonner ».
7. **Validation d'email = auto-login** puis redirection vers `mes-infos`.
8. **`linkedin_button` paramétrable par `next`** : la connexion garde son
comportement, l'inscription route vers `mes-infos`.
## Changements par fichier
### 1. `src/pages/a_propos/abonnement.py` — page publique
Devient le foyer unique des composants cards (refactor depuis `compte/abonnement.py`).
- **Remonter** ici (auth-agnostiques) : `_plan_card`, `_plan_cards`, `_explainer`.
- `_plan_card` : **retirer le bouton « S'abonner » par card**. La carte n'affiche
plus que label, tarif, description et le badge d'essai générique
(`trial_days(key)` jours, sans personnalisation `trial_used`).
- `layout()` devient dynamique (appelé par requête, peut lire `current_user`) et
assemble de haut en bas :
1. `_plan_cards()` + `_explainer()`
2. le bouton **« Je m'abonne »** centré (voir §2)
3. `subscription_terms` (CGU existantes)
- **Contenu** : la sous-section « Fonctionnalités incluses » + `abonnement_features`
de `subscription_terms` fait désormais doublon avec `_explainer` juste au-dessus.
**la retirer de `subscription_terms`** (garder le reste des CGU tel quel).
### 2. Bouton « Je m'abonne » (dans `a_propos/abonnement.py`)
Une fonction dédiée qui décide libellé + cible selon l'état :
| État | Libellé | Cible |
| ---------------------------------- | --------------------------- | -------------------------------------- |
| `TOUS_ABONNES` | « Je m'abonne » (désactivé) | `#` + bannière (comme cards actuelles) |
| non authentifié | « Je m'abonne » | `/inscription` |
| authentifié, sans abonnement actif | « Je m'abonne » | `/compte/abonnement/mes-infos` |
| authentifié, abonnement actif | « Gérer mon abonnement » | `/compte/abonnement` |
Bouton centré, `btn btn-primary`, largeur ajustée.
### 3. `src/pages/compte/abonnement.py`
- **Supprimer** `_plan_card`, `_plan_cards`, `_explainer` (déplacés en §1) et l'import
`from src.pages.a_propos.abonnement import abonnement_features`.
- La branche « non-abonné » (`else` de `layout`, aujourd'hui cards + explainer)
devient un court texte d'invitation + un bouton :
- `db.has_used_trial(current_user.id)` → « Me réabonner »
- sinon → « M'abonner »
- href → `/a-propos/abonnement` dans les deux cas.
- Le message « Votre abonnement a expiré » (cas `status == "expired"`) est conservé.
- La vue « abonné actif » (`_active_view`, résiliation, feedback paiement,
`_salaire_modal`, `_tous_abonnes_banner`) est **inchangée**.
### 4. `src/pages/compte/abonnement_mes_infos.py`
- **Retirer** la redirection « pas de `?plan=` » (lignes 53-55) : la page est
accessible directement.
- **Ajouter en tête de formulaire** une sélection de formule sous forme de **cartes
cliquables** (réutilisation de `_plan_card` de la page publique), avec rappel des
tarifs (20 € HT / 50 € HT). UX voulue : aucune formule sélectionnée par défaut, un
texte « Choisissez votre formule » invite l'utilisateur ; la carte sélectionnée
prend un fond légèrement teinté. Mécanisme (le POST du `html.Form` ne soumet
nativement qu'un champ portant `name`) :
- deux cartes, chacune enveloppée dans un `html.Div` cliquable
(`id="plan-card-simple"|"plan-card-soutien"`, `n_clicks`, classe `plan-selectable`) ;
- un `dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value="")` — vide
au départ ; c'est CE champ, natif, qui est soumis (même mécanisme que le champ
caché `plan` actuel, déjà lu par `subscribe()`) ;
- un callback sur le `n_clicks` des deux cartes qui écrit la formule dans le champ
caché, applique la classe `selected` à la carte choisie (retirée de l'autre) et
masque l'invite ;
- un texte d'invite `id="inf-plan-invite"` visible tant qu'aucune formule n'est choisie.
- **Style** : ajouter dans `src/assets/css/style.css`
`.plan-selectable { cursor: pointer }` et
`.plan-selectable.selected .card { background-color: var(--bs-primary-bg-subtle); border-color: var(--bs-primary) }`.
- **Gating du bouton d'envoi** : `_toggle_submit` exige désormais aussi qu'une formule
soit sélectionnée (en plus des deux cases rétractation/CGU). Sans plan → désactivé.
- **Remplacer** le `dcc.Input(type="hidden", name="plan", value=plan)` fixe (ligne 232) par le champ caché vide synchronisé ci-dessus.
- Le reste (prefill Frisbii, SIRET, cases rétractation/CGU) est inchangé.
`subscribe()` lit toujours `request.form.get("plan")` → compatible.
### 5. `src/auth/routes.py``verify_email()`
Après `db.set_email_verified(user_id)` :
```python
user = User(db.get_user_by_id(user_id))
login_user(user, remember=True)
return redirect("/compte/abonnement/mes-infos")
```
(`login_user` et `User` sont déjà importés.) La page `/verification-email` reste
utilisée pour le seul cas `error=invalid_token`.
### 6. `src/pages/connexion.py`
- `linkedin_button` accepte un paramètre `next_url` optionnel :
```python
def linkedin_button(next_url: str | None = None):
href = "/auth/linkedin"
if next_url:
href += f"?next={next_url}"
# ... inchangé
```
- Le CTA du bas de `/connexion` (« Créer un compte avec mon adresse email » →
`/inscription`) devient un lien vers `/a-propos/abonnement` (« Pas encore de
compte ? Voir les abonnements »).
### 7. `src/pages/inscription.py`
- Appelle `linkedin_button("/compte/abonnement/mes-infos")` pour que l'inscription
via LinkedIn finisse dans le tunnel (au lieu de `/compte/admin`).
- Le reste (formulaire email, lien « Déjà un compte ? ») inchangé.
## Sécurité
- **Redirection de retour LinkedIn** : `safe_next` (`auth/setup.py:16-19`) n'autorise
qu'un chemin interne commençant par un seul `/` (rejette `//` et les URLs
absolues). Le `next=/compte/abonnement/mes-infos` passé par l'inscription est un
chemin interne valide, filtré à l'entrée (`linkedin_login`) et à la sortie (callback).
Pas d'open redirect introduit.
- **Auto-login via lien email** : le token de vérification est à usage unique et
consommé (`consume_verification_token`). L'auto-login qui en découle est un
magic-link classique, acceptable.
## Cas limites
- **Abandon sur mes-infos** : l'utilisateur a un compte fonctionnel sans abonnement.
`get_current` renvoie `None`, `has_used_trial` False → `/compte/abonnement` affiche
« M'abonner ». Cohérent.
- **Ancien abonné (expiré/résilié)** : `has_used_trial` True → « Me réabonner ».
- **Abonné actif visitant `/a-propos/abonnement`** : bouton « Gérer mon abonnement »
`/compte/abonnement` (pas de « Je m'abonne » trompeur).
- **`TOUS_ABONNES`** : bouton public désactivé + bannière, comme les cards
actuelles ; `mes-infos`/`subscribe` déjà gérés en amont.
- **Email déjà pris à l'inscription** : comportement existant (`email_taken`),
l'utilisateur est invité à se connecter.
## Tests
Étendre `tests/` (Selenium `DashComposite`) :
- Visiteur non connecté : `/a-propos/abonnement` affiche les cards + « Je m'abonne »
pointant vers `/inscription`.
- Utilisateur connecté sans abonnement : « Je m'abonne » pointe vers `mes-infos` ;
`/compte/abonnement` affiche « M'abonner ».
- Utilisateur ayant déjà été abonné (`trial_used=1`) : `/compte/abonnement` affiche
« Me réabonner ».
- `mes-infos` accessible sans `?plan=` ; radios présents, « simple » par défaut ;
soumission POST envoie bien `plan`.
- `verify_email` : après consommation du token, session authentifiée et redirection
vers `mes-infos`.
- `safe_next` : `next` externe (`//evil.com`, `https://…`) ignoré au profit du fallback.
## Hors périmètre
- Refonte visuelle des cards / de la page publique (on réutilise l'existant).
- Modification du parcours de paiement Frisbii lui-même.
- Gestion des factures (déjà côté Frisbii, inchangée).
@@ -0,0 +1,124 @@
# Changer de méthode de paiement (issue #108) — Design
Date : 2026-07-05
Branche : `dev`
## Objectif
Permettre à un·e abonné·e actif·ve ou en essai de changer sa carte bancaire
depuis `/compte/abonnement`, sans repasser par tout le flux d'inscription.
## Mécanisme Frisbii
`GET /v1/subscription/{handle}` (déjà implémenté : `client.get_subscription`)
renvoie un champ `hosted_page_links.payment_info` : une page hébergée par
Frisbii, dédiée au changement de carte sur un abonnement existant
(doc : https://docs.frisbii.com/docs/change-payment-method-on-existing-subscription).
Elle accepte `accept_url` et `cancel_url` en query params pour rediriger après
succès/annulation.
Ce mécanisme est plus simple que le flux `add-payment` existant (Checkout API
`/v1/session/recurring` + callback qui associe la nouvelle méthode via
`set_subscription_payment_method`) : pas de session à créer, pas de callback à
gérer côté colibre, Frisbii associe directement la nouvelle carte à
l'abonnement.
## Portée
Bouton **« Changer de méthode de paiement »** affiché uniquement pour
`row["status"] in ("trial", "active")` :
- `pending` garde son bouton actuel « Ajouter une méthode de paiement »
(aucune carte n'existe encore, flux différent).
- `cancelled` n'a pas ce bouton : l'abonnement s'arrête à la fin de la
période en cours, il n'y a plus rien à facturer dessus. Le chemin logique
est de reprendre un abonnement (`_reabo_button`, déjà géré ailleurs).
## Fichiers touchés
### `src/subscriptions/client.py`
Nouvelle fonction :
```python
def get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str:
sub = get_subscription(sub_handle)
url = sub["hosted_page_links"]["payment_info"]
parts = urlsplit(url)
query = dict(parse_qsl(parts.query))
query["accept_url"] = accept_url
query["cancel_url"] = cancel_url
return urlunsplit(parts._replace(query=urlencode(query)))
```
Utilise `urllib.parse` pour fusionner proprement avec une éventuelle query
string déjà présente sur `payment_info` plutôt que de la concaténer
naïvement.
### `src/subscriptions/routes.py`
Nouvelle route, symétrique à `add_payment()` / `cancel()` :
```python
@subscriptions_bp.route("/subscriptions/change-payment-method", methods=["POST"])
@login_required
def change_payment_method():
base = os.getenv("APP_BASE_URL", "")
row = db.get_current(current_user.id)
if row is None or not row["frisbii_subscription_handle"]:
return "Aucun abonnement actif", 400
try:
url = client.get_payment_info_url(
row["frisbii_subscription_handle"],
f"{base}/compte/abonnement?carte=succes",
f"{base}/compte/abonnement?carte=annule",
)
except client.FrisbiiError:
logger.exception("Échec de récupération du lien de paiement Frisbii")
return redirect("/compte/abonnement?error=frisbii")
return redirect(url, code=303)
```
Pas de webhook/callback à gérer : Frisbii associe la nouvelle méthode de
paiement à l'abonnement de son côté, et le webhook existant
(`/frisbii/webhook`) continuera de refléter l'état de l'abonnement comme
aujourd'hui.
### `src/pages/compte/abonnement.py`
- `_active_view(row)` : pour `row["status"] in ("trial", "active")`, ajouter
un `html.Form` POST vers `/subscriptions/change-payment-method` (CSRF token
via `_csrf_input()`), bouton `btn btn-outline-secondary`
« Changer de méthode de paiement », affiché à côté du bouton
« Me désabonner » existant.
- `_feedback(query)` : ajouter la gestion de `query.get("carte")` :
- `"succes"` → alerte success « Méthode de paiement mise à jour. »
- `"annule"` → alerte secondary « Modification annulée. »
## Gestion d'erreurs
- Pas d'abonnement / pas de handle → 400 (cas normalement inatteignable
depuis l'UI, le bouton n'étant rendu que si `row` existe et a un statut
trial/active, donc un handle).
- Échec API Frisbii (`FrisbiiError`) → `logger.exception` +
`redirect("/compte/abonnement?error=frisbii")`, réutilise l'alerte
« Une erreur est survenue avec le service de paiement. » déjà gérée par
`_feedback`.
## Tests
- `tests/subscriptions/` : test de `client.get_payment_info_url` — mock de
`client.get_subscription` (ou de `_call`), vérifie que `accept_url` et
`cancel_url` sont bien ajoutés à l'URL, y compris si `payment_info` a déjà
une query string.
- Test de la route `change_payment_method` : redirect 303 vers l'URL Frisbii
quand un abonnement actif existe ; 400 si pas d'abonnement.
- Test de `_feedback()` pour les nouvelles clés `carte=succes` / `carte=annule`.
## Hors périmètre (YAGNI)
- Affichage de la carte actuellement enregistrée (marque, 4 derniers
chiffres) — pourra venir plus tard via `client.get_customer_payment_methods`.
- Bouton pour `cancelled` (cf. Portée ci-dessus).
- Gestion multi-méthodes de paiement (le champ `active_payment_methods` de
Frisbii ne contient au plus qu'un élément dans notre usage actuel).
@@ -0,0 +1,177 @@
# Configurer son abonnement (#109)
## Contexte
Sur `/compte/abonnement`, un·e abonné·e `active`, `trial` ou `pending` ne
peut aujourd'hui ni changer de formule (simple ↔ soutien) ni mettre à jour
ses informations de facturation. Cette fonctionnalité l'ajoute, en réutilisant
la page `/compte/abonnement/mes-infos` déjà utilisée pour l'abonnement initial.
On en profite pour afficher le prix (HT + TTC) à côté de la formule courante
sur `/compte/abonnement`.
## Décisions produit
- **Effet du changement de formule** : à la prochaine échéance
(`timing="renewal"`), sans proratisation ni remboursement. Simple à
expliquer, aucun mouvement d'argent immédiat.
- **Cas `pending`** (abonnement créé mais sans méthode de paiement, billing
non démarré) : mise à jour API directe. La formule change avec
`timing="immediate"` (aucune échéance à laquelle rattacher un `renewal`) et
les infos de facturation sont mises à jour. L'ajout de carte reste un bouton
séparé sur `/compte/abonnement`.
- **Hint « prochaine échéance »** sous les cards : affiché uniquement pour
`active`/`trial`. Rien pour `pending`.
## Architecture : flexibiliser `mes-infos` (pas de nouvelle page)
`/compte/abonnement/mes-infos` sert déjà ~80 % de ce dont on a besoin : le
formulaire de facturation à deux colonnes (10 champs), la recherche SIRET et
son callback, les cards de formule sélectionnables et `_select_plan`, le
préremplissage Frisbii, la modale CGU.
Les deux scénarios ne diffèrent que par : présence des cases à cocher, libellé
du bouton, cible/logique du formulaire, formule présélectionnée.
**Décision : flexibiliser `mes-infos` avec un `mode`** dérivé de l'état de
l'abonnement, plutôt qu'une nouvelle page. En Dash, deux pages ne peuvent pas
partager les mêmes `id` de composants : une page dupliquée forcerait à
renommer chaque `id` **et** à dupliquer les quatre callbacks (`_select_plan`,
`_lookup_siret`, `_toggle_submit`, `_toggle_cgu`) — soit l'essentiel du
fichier dupliqué pour 20 % de différence. `suppress_callback_exceptions=True`
(déjà activé, `src/app.py:88`) rend sûrs les composants rendus
conditionnellement.
**Le `mode` est dérivé de l'état, pas d'un paramètre d'URL.** Le bouton
« Configurer mon abonnement » et le bouton « M'abonner » pointent tous deux
vers `/compte/abonnement/mes-infos` ; la page s'adapte. Cela reprend la logique
du garde déjà présent dans `subscribe()` (refus d'un second abonnement quand
un est actif).
```python
row = db.get_current(current_user.id)
mode = "configure" if row and row["status"] in ("active", "trial", "pending") else "subscribe"
```
## Section 1 — page `/compte/abonnement`
Fichier : `src/pages/compte/abonnement.py`.
1. **Prix à côté de la formule** dans `_active_view`. Réutiliser le motif de
`src/pages/a_propos/abonnement.py:34-35` :
`{prix_ht} € HT / mois ({prix_ht * 1.2:g} € TTC)`. `plans.plan_meta()`
retourne déjà `prix_ht`. Ajouter sous le `html.H3(meta["label"])` un
paragraphe discret (`<small>`/muted) avec le prix.
2. **Bouton « Configurer mon abonnement »** : un lien
(`href="/compte/abonnement/mes-infos"`, classe `btn`) ajouté dans les
branches `active`/`trial`/`pending`, aux côtés de « Changer de méthode de
paiement » et « Me désabonner ».
3. **Message de succès** : ajouter dans `_feedback()` une entrée
`maj=succes`_« Votre abonnement a été mis à jour. »_ (couleur `success`).
## Section 2 — page `/compte/abonnement/mes-infos`
Fichier : `src/pages/compte/abonnement_mes_infos.py`.
`layout()` calcule `row = db.get_current(current_user.id)` et en dérive `mode`.
Rendu conditionnel piloté par `mode` :
| Élément | `subscribe` (actuel) | `configure` (nouveau) |
| ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------- |
| Cards de formule | aucune présélectionnée | formule courante (`row["plan"]`) présélectionnée via `_selection_state` |
| Cases à cocher (rétractation + CGU) | affichées | **omises** |
| Modale CGU | affichée | omise |
| Libellé du bouton | « Ajouter une carte de paiement » | **« Mettre à jour mon abonnement »** |
| `disabled` initial du bouton | `True` | `False` (une formule est déjà sélectionnée) |
| `action` du formulaire | `/subscriptions/subscribe` | `/subscriptions/update` (nouvelle route) |
| Texte d'intro | « Choisissez votre formule : » | « Votre formule : » |
Les colonnes de facturation (`col1`/`col2`), la recherche SIRET et le
préremplissage Frisbii sont identiques dans les deux modes.
### Hint « prochaine échéance »
- Élément message masqué sous les cards : `id="inf-change-hint"`,
`className="d-none"` au départ (texte discret / info).
- La formule courante et la date d'échéance sont transmises au callback via un
`dcc.Store` caché rempli dans le layout :
`{"current_plan": row["plan"], "echeance": format_date_french(row["current_period_end"])}`,
lu en `State`. En mode `subscribe`, le Store est absent → hint jamais affiché.
- Étendre le callback `_select_plan` (déjà déclenché au clic sur une card) pour
produire aussi `className` + `children` du hint :
- formule sélectionnée **==** formule courante → masqué (`d-none`)
- formule sélectionnée **!=** formule courante **et** statut ∈ {active, trial}
→ affiché : _« Le changement d'abonnement sera appliqué à la prochaine
échéance : {echeance}. »_
- statut `pending` → rien (masqué) quelle que soit la sélection
### Callbacks
Les callbacks partagés (`_select_plan`, `_lookup_siret`, `_toggle_cgu`)
référencent des `id` qui existent toujours en mode `configure` (sauf
`_toggle_cgu`, dont les composants sont absents — toléré par
`suppress_callback_exceptions`).
`_toggle_submit` dépend aujourd'hui des deux cases à cocher, absentes en mode
`configure`. En mode `configure` le bouton démarre activé et n'est gardé par
aucune case ; le callback dégrade gracieusement (ses `Input` de cases ne sont
pas rendus). Forme exacte à confirmer à l'écriture du plan (p. ex. retour
`False`/`no_update` quand une formule est présente).
## Section 3 — route `/subscriptions/update` + client
Fichier : `src/subscriptions/routes.py`.
Nouvelle route `update()` (`@login_required`), de forme proche de
`subscribe()` mais **sans redirection vers un checkout** :
1. Charger `row = db.get_current(current_user.id)` ; si absent ou sans
`frisbii_subscription_handle``400`.
2. **Mettre à jour les infos de facturation** : construire le dict `billing`
depuis `request.form` (mêmes champs que `subscribe`), persister le SIRET via
`auth_db.set_siret`, appeler `client.update_customer(cust, billing)`.
(Pas de branche 404 : le customer existe déjà pour tout abo
active/trial/pending.)
3. **Changer la formule si différente** : lire `plan` dans le formulaire ; si
`plans.resolve_handle(plan)` diffère de la formule courante
(`row["plan"]`), appeler `client.change_subscription(...)` avec
`timing="immediate"` si `row["status"] == "pending"` sinon `"renewal"`.
4. Sur `client.FrisbiiError``redirect("/compte/abonnement?error=frisbii")`.
5. Sur succès → `redirect("/compte/abonnement?maj=succes", code=303)`.
Nouvelle fonction client dans `src/subscriptions/client.py` :
```python
def change_subscription(sub_handle: str, plan_handle: str, timing: str = "renewal") -> dict:
return _call(
"PUT",
f"/v1/subscription/{sub_handle}",
json={"timing": timing, "plan": plan_handle},
)
```
Corps validé contre le schéma OpenAPI `ChangeSubscription` (endpoint
`PUT /v1/subscription/{handle}`, « Change subscription ») : `timing` est le
seul champ requis ; `plan` déclenche le changement de formule. Aucun paramètre
de proratisation puisque le changement est à la prochaine échéance.
**Note sur le timing des infos de facturation** : `update_customer` s'applique
immédiatement au customer, donc un changement d'adresse/SIRET prend effet tout
de suite même si le changement de _formule_ est différé à l'échéance. C'est
normal (adresse/SIRET ne sont pas liés à une période) ; le message de succès
reste générique (« mis à jour ») plutôt que d'impliquer que tout attend
l'échéance.
## Tests
- Fichier de test dédié à cette fonctionnalité (les champs d'abonnement de
`tests/test.parquet` ne sont pas concernés ; l'état d'abonnement vit dans
SQLite).
- Couvrir : dérivation du `mode` selon le statut ; rendu conditionnel
(cases à cocher présentes/absentes, libellé du bouton, formule
présélectionnée) ; route `update()` (mise à jour customer + appel
`change_subscription` avec le bon `timing`, court-circuit si formule
inchangée, garde 400 sans abonnement) ; affichage du hint selon
statut/sélection.
- `client.change_subscription` : vérifier le corps JSON envoyé (mock httpx).
@@ -0,0 +1,228 @@
# Design — Serveur MCP colibre, lot 1 : les tools (scope A de l'issue #111)
**Date** : 2026-07-09
**Issue** : #111 — Serveur MCP des données colibre, conditionné à l'abonnement
**Périmètre de CE design** : **A seulement** — exposer des fonctions métier comme
_tools_ MCP via le décorateur `@mcp_enabled` de Dash. **Sans authentification.**
La couche d'autorisation OAuth 2.0 + gate abonnement fera l'objet d'un design
séparé (scope B).
## Contexte
- La migration Dash 4.x (#101) est terminée (Dash 4.4). Le serveur MCP de Dash est
disponible à partir de Dash 4.3.0 → prérequis satisfait.
- Dash fournit la couche protocole MCP (`enable_mcp=True`, décorateur
`@mcp_enabled`, `configure_mcp_server(...)`). Dash **n'implémente pas**
l'authentification (cf. `/dash-mcp/auth`) → c'est le scope B, hors de ce design.
- Les **DECP sont des données publiques ouvertes**. Exposer recherche/stats en MCP
n'est donc pas une fuite de confidentialité ; le gate abonnement (scope B) relève
du contrôle d'accès / monétisation, pas du secret. Conséquence : le scope A peut
tourner en dev/local sans risque de données.
## Ce qui existe déjà et qu'on réutilise
- `src/db.py` : `query_marches(where_sql, params, columns, order_by, limit, offset)`,
`count_marches(where_sql, params)`, `aggregate_marches(select_sql, where_sql, params, group_by, order_by, limit, offset)` — accès DuckDB paramétré renvoyant du
Polars.
- `src/api/filters.py` : `build_where(args, schema) -> (where_sql, params, order_by)`
et `parse_aggregators(...)` — moteur de filtres `col__op=valeur` déjà utilisé par
l'API REST (`src/api/routes.py`). **On le réutilise tel quel** pour que MCP et REST
partagent la même sémantique de filtrage.
- `src/utils/search.py` : `search_org(dff, query, org_type)` — recherche floue par
nom sur les acheteurs/titulaires (déjà utilisée par la page `/`).
- `src/utils/tracking.py` : `track_search(query, category)` — envoi direct à l'API
HTTP de tracking Matomo (`matomo.php`).
## Approches considérées
- **A — Module MCP fin réutilisant la couche données existante (RETENU).**
Nouveau package `src/mcp/`, 4 fonctions `@mcp_enabled` appelant directement
`db.*` / `filters.build_where` / `search_org`. Le « service layer » partagé
qu'on voudrait existe déjà (`src/db.py` + `src/api/filters.py`) → peu de code neuf.
- **B — Extraire un service commun** partagé entre `api/routes.py` et MCP. Meilleure
déduplication à terme mais gros refactor de l'API REST pour un gain marginal (la
logique est déjà factorisée). Rejeté (YAGNI).
- **C — MCP appelle l'API REST en HTTP.** Ajoute un saut HTTP interne en process,
perd le typage. Rejeté.
## Architecture
### Arborescence
```
src/mcp/
__init__.py
tools.py # les 4 fonctions @mcp_enabled (surface MCP)
serialization.py # Polars → JSON propre (dates ISO, montants, None-safe)
stats.py # helpers d'agrégation acheteur/titulaire, partagés par les 2 tools stats_*
```
### Activation (dans `src/app.py`, là où `Dash(...)` est construit)
```python
from dash.mcp import configure_mcp_server
app = Dash(__name__, ..., enable_mcp=os.getenv("DASH_MCP_ENABLED") == "true")
configure_mcp_server(
include_layout=False,
include_callbacks=False,
include_pages=False,
include_clientside_callbacks=False,
) # n'expose QUE les fonctions @mcp_enabled — aucun callback/layout/page d'UI
import src.mcp.tools # noqa: E402,F401 — l'import enregistre les @mcp_enabled
```
**Sécurité (point de vigilance #111)** : en coupant `include_callbacks/layout/pages/ clientside`, aucun callback d'UI ni nom interne (type `get_data_from_s3`) n'est
exposé. La surface se limite aux 4 tools nommés proprement, avec docstrings
maîtrisées.
### Isolation des unités
- `tools.py` : **uniquement** la surface MCP (signatures, docstrings destinées à
l'agent, validation des arguments, appels aux helpers). Ne contient pas de SQL.
- `stats.py` : logique d'agrégation acheteur/titulaire, testable sans MCP.
- `serialization.py` : conversion Polars → structures JSON-sérialisables, testable
isolément.
## Les 4 tools
Tous renvoient des structures JSON-sérialisables (dict / list). Montants en euros
(float ou int), dates en ISO 8601 (`YYYY-MM-DD`), valeurs manquantes en `null`.
Les docstrings sont exposées à l'agent (`expose_docstring=True`) : ce sont elles qui
documentent l'outil côté client.
### 1. `rechercher_organisations(query: str, type: str = "acheteur", limite: int = 20)`
- `type``{"acheteur", "titulaire"}`.
- Réutilise `search_org` sur la frame correspondante (mêmes données que la page `/`).
- Sortie : `[{ "id", "nom", "departement", "commune" }]`, triée par pertinence,
tronquée à `limite`.
- Rôle : **résoudre un nom → id** pour alimenter `stats_acheteur` / `stats_titulaire`.
### 2. `stats_acheteur(acheteur_id: str)`
Réutilise `aggregate_marches` avec un `where` filtrant sur `acheteur_id`.
Sortie :
```json
{
"identite": { "id", "nom", "departement", "commune" },
"nb_marches": 0,
"montant_total": 0,
"repartition_annuelle": [ { "annee", "nb_marches", "montant_total" } ],
"top_titulaires": [ { "id", "nom", "nb_marches", "montant_total" } ],
"top_cpv": [ { "cpv", "libelle", "nb_marches" } ]
}
```
- `top_*` limités (ex. 10). Si `acheteur_id` inconnu → `nb_marches: 0` et listes vides
(pas d'erreur).
### 3. `stats_titulaire(titulaire_id: str)`
Symétrique de `stats_acheteur` : `top_acheteurs` au lieu de `top_titulaires`,
montants remportés.
### 4. `rechercher_marches(...)` — signature **hybride**
```python
rechercher_marches(
acheteur_id: str | None = None,
titulaire_id: str | None = None,
cpv: str | None = None,
objet_contient: str | None = None,
montant_min: float | None = None,
montant_max: float | None = None,
date_min: str | None = None, # ISO YYYY-MM-DD (dateNotification)
date_max: str | None = None,
departement: str | None = None,
page: int = 1,
filtres_avances: dict | None = None, # échappatoire moteur générique
)
```
- Les paramètres nommés sont traduits en tuples `col__op` (ex.
`montant_min``("montant__greater", ...)`, `objet_contient`
`("objet__contains", ...)`, `date_min``("dateNotification__greater", ...)`).
- `departement` mappe sur **`acheteur_departement_code`** (intention de requête la
plus courante). Pour filtrer sur le département du titulaire ou du lieu
d'exécution, l'agent passe par `filtres_avances`.
- `filtres_avances` : dict `{"col__op": valeur}` passant au moteur générique complet,
**fusionné** avec les paramètres nommés. Couvre toute colonne/opérateur supportés
par l'API REST.
- L'ensemble passe à `filters.build_where(args, duckdb_schema)` puis
`db.query_marches` / `db.count_marches`**même sémantique que l'API REST**.
- Pagination : `page_size` **fixe** (ex. 50), pagination par `page` (offset calculé).
- Sortie :
```json
{
"meta": { "page": 1, "page_size": 50, "total": 0 },
"marches": [
{
/* colonnes principales du marché */
}
]
}
```
- Erreurs de filtre (`FilterError`) → message d'erreur clair renvoyé à l'agent (pas
d'exception brute).
## Tracking Matomo des appels MCP
Nouveau helper dédié dans `src/utils/tracking.py` (on **ne** surcharge **pas**
`track_search`, qui gate sur `len(query) >= 4` et attend une requête texte) :
```python
def track_mcp_tool(tool_name: str, query: str | None = None) -> None:
...
```
- Même pattern que `track_search` : n'émet que si `not DEVELOPMENT` **et**
`MATOMO_DOMAIN` défini. Best-effort (ne doit jamais faire échouer l'appel du tool).
- Paramètres envoyés à `matomo.php` :
- `action_name = "MCP"` (hiérarchie `f"MCP / {tool_name}"` acceptable pour un arbre
lisible dans le rapport Actions),
- `dimension1 = tool_name`,
- `search` / `search_cat` en plus quand l'outil a une requête texte
(`rechercher_organisations`, `rechercher_marches`).
- Chaque tool appelle `track_mcp_tool(...)` en début d'exécution.
- **Prérequis de déploiement Matomo** : créer un _Custom Dimension_ slot 1, scope
**Action**, côté admin Matomo. Sinon `dimension1` est ignoré silencieusement.
## Déploiement / gating
- Activation via variable d'environnement `DASH_MCP_ENABLED` (Dash lit nativement
cette variable ; on la reflète dans le constructeur).
- **Off par défaut.** Activé en dev/local uniquement.
- **Pas activé en prod tant que le scope B (OAuth + gate abonnement) n'est pas
livré** — sinon le serveur MCP serait ouvert sans contrôle d'accès (feature
payante + coût compute).
- Documenter la variable dans `.template.env`.
## Tests
- Tests unitaires sur les 4 fonctions (données `tests/test.parquet`) :
- `rechercher_organisations` : résultats non vides, tri, `limite`, `type` invalide.
- `stats_acheteur` / `stats_titulaire` : forme de sortie, id inconnu → vides,
troncature des `top_*`.
- `rechercher_marches` : fusion params nommés ↔ `filtres_avances`, pagination
(`meta.total`, `page`), `FilterError` → message propre.
- `serialization` : dates ISO, `null`, montants.
- Smoke test : après import de `src.mcp.tools`, le registre MCP contient bien les
4 tools attendus (pas de test du protocole MCP de bout en bout, qui nécessiterait
un client MCP).
- `tests/test.parquet` étant réduit, vérifier que les colonnes utilisées (cpv,
montant, dateNotification, acheteur_id, titulaire_id, departement) y sont
présentes ; sinon compléter la fixture ou marquer les cas concernés.
## Hors périmètre (→ scope B, design séparé)
- Serveur d'autorisation OAuth 2.0 conforme à la spec MCP (2025-06-18) :
metadata protected-resource, PKCE, dynamic client registration.
- Branchement du gate `subscriptions.has_active_subscription(user_id)` sur
l'autorisation MCP.
- Documentation de connexion côté client (`claude mcp add …`).
@@ -0,0 +1,128 @@
# Migration des tables vers Dash AG Grid — Design (Lot 1 : `tableau.py`)
- **Issues** : #41 (migration AG Grid), #97 (requêtes booléennes, abonnés), #112 (partage de vues abonnés par URL courte)
- **Date** : 2026-07-09
- **Décision d'archi de référence** : commentaire sur #41
## Contexte
Les tables de l'application reposent sur `dash_table.DataTable`, que Plotly abandonne au profit d'AG Grid. `colibre` a fortement personnalisé ses DataTable et les utilise sur 6 emplacements. La page vitrine `tableau.py` est la plus riche : paging / filtre / tri **server-side** sur DuckDB (~1,5 M lignes), partage de vue par URL, vues sauvegardées (abonnés), export Excel, sélecteur de colonnes, persistance, tooltips d'en-tête, liens dans les cellules.
Cette migration prépare une **version majeure** (pas de rétro-compatibilité) et doit rendre implémentable #97 (requêtes booléennes OR/AND/NOT + parenthèses, réservé aux abonnés).
## Objectifs
1. **Préserver les fonctionnalités existantes** de `tableau.py` en passant de `DataTable` à `dag.AgGrid`.
2. **Conserver l'apparence de base d'AG Grid** dans un premier temps — le portage de nos overrides CSS (polices Inter, largeurs de colonnes conditionnelles, tailles) est **reporté** au 2e temps.
3. **Poser le moteur de requête** (AST booléen → SQL DuckDB) comme socle canonique, pour que #97 soit une extension incrémentale.
## Non-objectifs (reportés)
- Portage des overrides CSS des DataTable (2e temps).
- Migration des autres pages : `acheteur.py`, `titulaire.py`, `observatoire.py`, `recherche.py`, `admin/liste.py`, `figures.make_table` (Lots 2 et 3).
- UI du champ de requête booléenne avancée #97 (le moteur AST est posé ici, l'UI vient ensuite).
- Partage de vue par URL courte `?vue=<user_id>_<nom>` (#112).
## Décisions d'architecture (rappel)
- **AG Grid = grille d'affichage.** En row model server-side, c'est notre callback Dash qui compile le filtre en SQL ; on ne dépend pas de la puissance de filtrage d'AG Grid.
- **Infinite Row Model** pour `tableau.py` (`rowModelType="infinite"`).
- **Modèle canonique = AST booléen** (`AND`/`OR`/`NOT` + groupement ; feuilles = `colonne op valeur`), compilé en SQL DuckDB paramétré.
- **Deux producteurs** alimentent le même AST : (1) filtres de colonne AG Grid (gratuit, comportement actuel préservé), (2) champ de requête texte inter-colonnes (#97, abonnés — UI reportée).
- **Pas de rétro-compat** : l'encodage riche de vue dans l'URL (`?filtres/tris/colonnes` en DSL DataTable) est **retiré**. Le partage passera par les vues sauvegardées (#112).
- **Pas d'AG Grid Enterprise.**
## Architecture cible (Lot 1)
### Flux de données
```
AG Grid (infinite)
│ getRowsRequest = {startRow, endRow, filterModel, sortModel}
callback Dash `get_rows_tableau`
│ filterModel ──► filtermodel_to_ast() ──► AST
│ AST ──► ast_to_sql() ──► (where_sql, params)
│ sortModel ──► sort_model_to_sql()
DuckDB (via _fetch_page_sql, réutilisé/adapté) ──► page + total
getRowsResponse = {rowData, rowCount}
```
### Moteur de requête — nouveau module `src/utils/query_ast.py`
Représentation canonique et compilateur, indépendants de l'UI :
- **Types AST** : nœuds `And(children)`, `Or(children)`, `Not(child)`, et feuille `Condition(column, operator, value)`.
- `ast_to_sql(node, schema) -> (where_sql, params)` : compile en SQL DuckDB **paramétré**. Valide chaque `column` contre `schema.names()` (jamais de concaténation de valeur utilisateur — même garantie que l'actuel `filter_query_to_sql`).
- Les **feuilles texte** réutilisent la logique de `tokenize_text_filter` (`src/utils/table_sql.py`) : insensible casse/accents, wildcards `*`, phrases `+`, multi-mots en `AND`. Les feuilles numériques/date réutilisent la logique de typage de `filter_query_to_sql`.
Deux traducteurs (producteurs) vers l'AST :
- `filtermodel_to_ast(filter_model, schema) -> node` : convertit le `filterModel` d'AG Grid (`agTextColumnFilter`, `agNumberColumnFilter`, `agDateColumnFilter`, avec `operator: AND/OR` + `condition1/condition2`) en AST. Colonnes combinées en `And`.
- _(reporté #97)_ `query_string_to_ast(text, schema) -> node` : parseur de la syntaxe FR `(béton OR ciment) AND brique AND NOT démolition`. Non implémenté au Lot 1, mais l'AST est prêt à le recevoir.
> On **retire** l'ancien DSL `{col} icontains valeur && …` de `tableau.py` : `filter_query_to_sql` et le JS `clean_filters` (`src/assets/dash_clientside.js`) ne sont plus utilisés par cette page. On les conserve tant que les autres pages (Lots 2/3) s'en servent, puis on les supprime au dernier lot.
### Composant grille — `src/figures.py`
Nouvelle fabrique `ag_grid(...)` (à côté de la classe `DataTable`, qui reste pour les pages non encore migrées) :
- `dag.AgGrid(rowModelType="infinite", ...)`.
- `columnDefs` dérivés de `schema` : `field`, `headerName`, `filter` par type (`agTextColumnFilter` / `agNumberColumnFilter` / `agDateColumnFilter`), `floatingFilter: True`, `headerTooltip` = définition de la colonne (remplace `tooltip_header`), `hide` selon les colonnes masquées.
- Cellules à liens (`marche` 🔍, `acheteur_nom`/`titulaire_nom` avec liens détail + 📊, `uid`, ressource) : `cellRenderer: "markdown"` + `dangerously_allow_code=True` sur la grille → le HTML `<a>` produit par `postprocess_page`/`add_links` se rend tel quel. `linkTarget: "_blank"` au besoin.
- **Scroll infini** (décidé) : pas de pagination numérotée. Grille à **hauteur fixe** (ex. `calc(100vh - …)`) avec scroll interne → **en-têtes toujours figés**, virtualisation des lignes (seules les lignes visibles + buffer sont rendues), chargement des blocs à la volée. Ne PAS utiliser `domLayout: "autoHeight"` (incompatible avec l'infinite row model).
- `dashGridOptions` : `cacheBlockSize` (taille de bloc serveur, ex. 100), `maxBlocksInCache`, `rowBuffer`, `infiniteInitialRowCount`. Optionnel : épingler à gauche les colonnes-clés (lien 🔍 marché, acheteur) via `pinned: "left"` pour rester visibles au scroll horizontal.
- **Apparence de base** : pas de thème custom au Lot 1 (thème AG Grid par défaut).
### Persistance
- `persistence=True`, `persistence_type="local"`, `persisted_props=["filterModel", "columnState"]` — remplace la persistance actuelle (`filter_query`, `sort_by`). Les tris et la visibilité des colonnes vivent dans `columnState`.
### Réécriture des callbacks `tableau.py`
- **Remplacé** : le callback `update_table` (Inputs `page_current/page_size/filter_query/sort_by`) devient `get_rows_tableau` (Input `getRowsRequest` → Output `getRowsResponse`).
- **Sélecteur de colonnes** : les callbacks colonnes pilotent désormais `columnDefs`/`columnState` (`hide`) au lieu de `hidden_columns`. `make_column_picker`, `get_default_hidden_columns`, `invert_columns` réutilisés.
- **Export Excel** (`download_data`) — chemin **DuckDB** (décidé) : recompile le `filterModel` courant (exposé en `State`) → AST → SQL, récupère les lignes filtrées/triées depuis DuckDB (colonnes masquées exclues), puis `write_styled_excel`. Un seul compilateur (AST→SQL), même chemin de données que la grille → pas de divergence filtre-affiché / filtre-exporté. Remplace l'actuel pipeline Polars (`filter_table_data`/`sort_table_data` sur `LazyFrame`).
- **nb_rows / hint téléchargement** : dérivés du `rowCount` et du total (seuil 65 000 lignes conservé).
- **Vues sauvegardées (abonnés)** : `saved_views` stocke désormais l'AST (JSON) + `columnState`, au lieu de la query DSL. `build_view_query` / `restore_view_from_url` remplacés par une sérialisation AST. Le _rappel_ de vue reste ; le _partage par URL riche_ est retiré.
- **Retiré** : `restore_view_from_url` (partie `?filtres/tris/colonnes`), `sync_url_and_reset_button` (URL riche), bouton « Partager la vue » (revient avec #112), `clean_filters` clientside.
- **Conservé** : mode d'emploi (à réécrire pour la nouvelle UX de filtres AG Grid), bouton Réinitialiser, `track_search`.
### Mode d'emploi
Le `dcc.Markdown` d'aide et les **liens d'exemple** codés en dur (qui encodent l'ancien DSL dans l'URL) sont **réécrits** pour décrire les filtres de colonne AG Grid. Les exemples « voirie < 40 k€ » / « clause sociale PME » sont retirés ou reformulés (plus d'URL riche).
## Dépendances
- Ajouter `dash-ag-grid` (non installé actuellement) : `uv add dash-ag-grid`. Version alignée sur Dash 3.4 (dash-ag-grid 35.x). Vérifier la compatibilité au moment de l'ajout.
## Cas limites & erreurs
- `getRowsRequest is None``no_update`.
- `filterModel` vide → AST vide → `where = TRUE`.
- Colonne inconnue dans un filtre → ignorée + `logger.warning` (parité avec l'actuel).
- Valeur numérique/date invalide → ignorée + warning.
- `rowCount` = 0 → la grille affiche « aucune ligne » (gérer le total à 0 sans casser la pagination).
- Sécurité : identifiants de colonnes validés contre le schéma, valeurs toujours paramétrées (jamais concaténées).
## Tests
- **Unitaires `query_ast.py`** : `ast_to_sql` (feuilles texte accent-insensitive, wildcard `*`, phrase `+`, numérique `=/>/<`, date ; `And/Or/Not` ; groupement). Réutiliser/adapter les cas de `tests/test_table.py` (ex. `test_filter_table_data_accent_insensitive`).
- **Unitaires `filtermodel_to_ast`** : chaque type de filtre AG Grid + `operator AND/OR` + `condition1/condition2`.
- **Parité SQL** : un `filterModel` simple doit produire le même résultat que l'ancien DSL équivalent (non-régression).
- **Intégration (Selenium/DashComposite)** : chargement de la grille, filtre de colonne, tri, pagination, sélecteur de colonnes, export Excel, persistance locale, vue sauvegardée (abonné).
- Suite complète `uv run pytest` uniquement en fin de lot.
## Décisions tranchées
- **Pagination** : scroll infini (grille à hauteur fixe, en-têtes figés, virtualisation).
- **Export Excel** : chemin DuckDB (AST → SQL), pipeline Polars retiré.
## Reporté au 2e temps / lots suivants
- Portage des overrides CSS (apparence).
- #97 : `query_string_to_ast` + champ de requête avancé (abonnés).
- #112 : partage de vue `?vue=<user_id>_<nom>`.
- Migration des Lots 2 (`acheteur`/`titulaire`/`observatoire`) et 3 (`recherche`/`admin`/`figures.make_table`), puis suppression de l'ancien DSL (`filter_query_to_sql`, `clean_filters`).
@@ -0,0 +1,67 @@
# Migration Dash 3.4 → 4.4 — Design (#101)
> Statut : design validé le 2026-07-09. Prêt pour le plan d'implémentation.
> Issues liées : #101 (cette migration), #41 (AG Grid, suite), #111 (serveur MCP, nécessite Dash ≥ 4.3).
## 1. Objectif & périmètre
Monter la dépendance `dash` de **3.4.0 à 4.4.x**, sans changement fonctionnel de l'application.
**Dans le périmètre :**
- Consolidation et bump de la déclaration `dash` dans `pyproject.toml`.
- Validation et, si nécessaire, bump des libs de l'écosystème Dash (`dash-leaflet`, `dash-extensions`, `dash[testing]`).
- Acceptation du nouveau style des composants DCC de Dash 4 ; retrait du CSS custom qui entre en conflit.
- Corrections des cassures fonctionnelles éventuelles.
**Hors périmètre (issues dédiées) :**
- Migration DataTable → AG Grid → **#41**. Les `dash_table.DataTable` restent en place. Elles sont dépréciées mais fonctionnelles en Dash 4.x (retrait prévu seulement en Dash 5.0).
- Serveur MCP des données sous auth → **#111** (nécessite Dash ≥ 4.3, donc cette migration en est le prérequis).
- Toute nouvelle fonctionnalité tirant parti de Dash 4.
## 2. Contexte technique constaté
- `pyproject.toml` déclare `dash` de façon dédoublée : `"dash==3.4.0"` **et** `"dash[compress]"` (non épinglé). À consolider.
- Version installée : `dash` 3.4.0, `dash-bootstrap-components` 2.0.4, `dash-leaflet` 1.1.3, `dash-extensions` 2.0.5.
- **Aucun** usage de `run_server`, `long_callback`, `LogoutButton`, `_set_react_version` dans `src/` ni `run.py` → les ruptures Dash 3.0 sont déjà absorbées. React 18.3.1 est déjà le défaut depuis Dash 3.
- Composants DCC utilisés que Dash 4 restyle : `dcc.Dropdown` (×11, filtres et sélecteurs de colonnes), `dcc.Input` (×23), `dcc.Loading` (×5), `dcc.Checklist` (×2), `dcc.RadioItems` (×1). Pas de `Slider`, `DatePicker`, `Tabs`, `TextArea` → surface visuelle limitée.
- `DataTable` est utilisé dans `recherche`, `tableau`, `acheteur`, `titulaire`, `observatoire`, `admin/liste`, et sous-classé dans `src/figures.py` (`class DataTable(dash_table.DataTable)`). `src/utils/table_sql.py` traduit le DSL de filtre DataTable en SQL DuckDB. Tout cela reste inchangé (relève de #41).
## 3. Décisions de design
- **Régressions visuelles : accepter le look Dash 4.** On ne corrige que les cassures fonctionnelles (débordement, illisibilité, comportement rompu). Le CSS custom qui entre en conflit avec le restyling DCC est **retiré** plutôt que patché.
- **Épinglage :** pin exact sur la dernière 4.4.x, cohérent avec le style actuel (`dash==3.4.0`). Ligne consolidée en `"dash[compress]==4.4.x"`.
- **Bump des libs tierces : seulement si nécessaire.** On ne monte `dash-leaflet` / `dash-extensions` que si la compat Dash 4 l'exige.
- **Approche de séquencement : bump groupé (A), dans un worktree isolé basé sur `dev`.** Une seule PR. Justifié par le périmètre contenu et l'absence de ruptures majeures.
## 4. Étapes d'implémentation (haut niveau)
Le détail sera produit par le skill `writing-plans`. Séquence prévue :
1. **Validation de compat (premier, car seul vrai risque).** Dans le worktree : consolider/bumper `dash` dans `pyproject.toml`, `uv sync`, `uv run run.py`. Observer la résolution des dépendances et le démarrage. Point le plus à risque : les cartes **Leaflet** (`dash-leaflet` + clustering via `dash-extensions`). Bumper ces libs si cassées.
- Si un blocage dur apparaît (lib tierce sans release compatible Dash 4), **arrêt et réévaluation** avant d'aller plus loin.
2. **Corrections fonctionnelles.** Vérifier les 11 `dcc.Dropdown` face aux nouveaux défauts Dash 4 (`optionHeight='auto'`, `closeOnSelect` distinct en multi-select) ; corriger uniquement si comportement cassé.
3. **Nettoyage CSS.** Retirer de `src/assets/css/` les overrides devenus inutiles ou cassants suite au restyling DCC.
4. **Alignement `dash[testing]`** sur la même version dans le groupe `dev`.
## 5. Vérification (definition of done)
- `pre-commit` exécuté (ruff) avant tout `git add` / commit (cf. CLAUDE.md).
- `uv run pytest` vert (suite pytest/Selenium, nécessite Chrome/Chromium).
- `uv run run.py` démarre l'app sans erreur.
- Smoke test manuel local des 6 pages principales : `/`, `/acheteur`, `/titulaire`, `/tableau`, `/marche`, `/observatoire`, **plus** cartes Leaflet et exports (xlsx/csv).
## 6. Livraison
- Worktree isolé basé sur `dev` → une PR vers `dev`.
- Le merge dans `dev` déclenche l'auto-deploy vers **test.colibre.fr** (validation en conditions réelles = bonus, hors DoD strict).
## 7. Risques & mitigations
| Risque | Mitigation |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `dash-leaflet` / `dash-extensions` incompatibles Dash 4 | Étape 1 le révèle tôt ; bump ciblé ; blocage dur → réévaluation avant de continuer |
| Régressions des cartes non couvertes par les tests | Smoke manuel explicite dans la DoD (cartes Leaflet) |
| CSS custom cassant un composant DCC restylé | Retrait de l'override plutôt que patch |
| Conflits de peer-dependencies (React/Dash) à `uv sync` | Détectés à l'étape 1 ; résolus par alignement des versions écosystème |
@@ -0,0 +1,206 @@
# Scope B — Connecteur MCP : accès abonné par jeton Bearer
**Issue :** #111 (2ᵉ partie, « le point dur »)
**Date :** 2026-07-10
**Statut :** approuvé (design)
**Prérequis :** scope A livré et fusionné dans `dev` (serveur MCP `/_mcp`, `src/mcp/`).
## Objectif
Conditionner l'accès au serveur MCP colibre (`/_mcp`, livré en scope A) à un
**abonnement colibre actif**, via un **jeton Bearer statique dédié** que l'abonné
génère lui-même depuis son espace compte et colle dans la configuration de son
agent IA.
Ce scope **n'implémente pas** de serveur OAuth. Le flux OAuth 2.1 complet
(bouton « Connecter », enregistrement dynamique de client, PKCE) est explicitement
reporté à un **scope B2** ultérieur, si l'usage le justifie.
## Décisions de conception (arbitrées)
1. **Jeton statique**, pas de serveur OAuth. Réutilise l'infrastructure
`api_tokens` (table SQLite, jetons `colibre_…` hachés) et
`has_active_subscription(user_id)` existantes.
2. **Jetons dédiés MCP** : une colonne `kind` distingue les jetons. Un jeton MCP
ne fonctionne que sur `/_mcp` ; un jeton API (`kind='api'`, tous les jetons
CLI actuels) ne fonctionne que sur `/api/v1`.
3. **Garde d'abonnement uniquement sur `/_mcp`**. Le comportement de l'API REST
existante est inchangé pour les jetons `kind='api'`.
4. **Libellé du menu** dans `/compte` : « Connecteur MCP ».
5. **Instructions de connexion** pour 4 clients : Claude, Gemini, Mistral (jeton
statique — supporté), ChatGPT (voir §7, caveat).
## Périmètre du support client (vérifié 2026-07)
| Client | En-tête Bearer statique | Voie documentée sur la page |
| --------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude** (Code / Desktop) | ✅ | `claude mcp add colibre --transport http <url>/_mcp --header "Authorization: Bearer …"` |
| **Gemini CLI** | ✅ | `gemini mcp add --transport http --header "Authorization: Bearer …"` ou `httpUrl`+`headers` dans `settings.json` |
| **Mistral Le Chat** | ✅ | Connecteur MCP → auth « API Token », en-tête `Authorization: Bearer …` |
| **ChatGPT** (app) | ❌ | L'app exige OAuth 2.1 + PKCE (pas de clé statique). Documenter la voie **développeur** (OpenAI API / Agents SDK, qui accepte un en-tête statique) + note « app ChatGPT = connecteur OAuth, itération future B2 ». |
## Architecture & flux
```
Abonné → /compte/mcp (« Connecteur MCP », gardé require_subscription)
→ [Générer un jeton] → colibre_xxxx (affiché UNE fois) + snippet client
Agent IA → POST /_mcp (Authorization: Bearer colibre_xxxx)
→ before_request guard (src/mcp/auth.py) :
• pas de Bearer / jeton introuvable / révoqué / kind≠'mcp' → 401
• jeton MCP valide mais user_id nul ou abonnement inactif → 403
• OK → increment_usage(token_id) → Dash traite la requête MCP
```
## Composants
### 1. Couche données — `src/api/tokens_db.py`
- **Schéma** : ajouter `kind TEXT NOT NULL DEFAULT 'api'` à `api_tokens`.
- `SCHEMA` (CREATE) inclut la colonne → DB fraîche correcte.
- **Migration** dans `src/migrations.py` `_MIGRATIONS` :
`("0007_add_kind_to_api_tokens", "ALTER TABLE api_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api'")`.
L'erreur _duplicate column name_ est déjà tolérée par `apply_pending()`
(DB fraîche où `SCHEMA` a déjà créé la colonne).
- **Initialisation au démarrage** : `init_api()` appelle
`tokens_db.init_schema(USERS_DB_PATH)` (comme `saved_views`/`roadmap`), pour
garantir que la table existe **avant** que `apply_pending()` (appelée plus tard
dans `init_subscriptions`) ne tente l'`ALTER`. Ordre dans `app.py` :
`init_api` (ligne ~112) précède `init_subscriptions` (ligne ~129). ✅
- **Fonctions** :
- `create_token(db_path, label, user_id=None, kind='api') -> (token, id)`
paramètre `kind` ajouté.
- `list_user_tokens(db_path, user_id, kind='mcp') -> list[dict]` — jetons d'un
utilisateur d'un type donné, triés par `created_at` décroissant.
- `revoke_user_token(db_path, token_id, user_id) -> bool`
`WHERE id=? AND user_id=?` (anti-IDOR). Retourne `True` si une ligne a été
révoquée, `False` sinon (jeton inexistant ou d'un autre utilisateur).
- `increment_usage` et `get_token_by_plaintext` existants, réutilisés tels quels.
### 2. Garde `/_mcp` — nouveau `src/mcp/auth.py`
- `init_mcp_auth(server: Flask) -> None` enregistre un `@server.before_request`
qui **ne s'active que** si `request.path == "/_mcp"` ou commence par `/_mcp/`.
- Logique :
1. Lire l'en-tête `Authorization`. Absent ou pas `Bearer `**401**.
2. `get_token_by_plaintext` → introuvable → **401** ; `revoked_at` non nul →
**401** ; `kind != 'mcp'`**401** (un jeton API ne donne pas accès au MCP).
3. `user_id` nul → **403** ; `has_active_subscription(user_id)` faux (en
respectant `TOUS_ABONNES`) → **403**.
4. Succès → `increment_usage(db_path, token_id)`, laisser passer (`return None`).
- **Codes & en-têtes** :
- 401 : corps JSON `{"error": "unauthorized", "message": …}` +
`WWW-Authenticate: Bearer realm="colibre-mcp"`.
- 403 : corps JSON `{"error": "no_active_subscription", "message": …}`.
- Messages en français, sans divulguer si le jeton existe (401 générique).
- Enregistré dans le bloc `if _mcp_enabled:` de `app.py`, après
`configure_mcp_server(...)`.
### 3. Exemption CSRF — `src/app.py`
- La boucle d'exemption CSRF actuelle cible `/_dash*` et `/_reload*`. Ajouter
`/_mcp` : `_rule.rule.startswith("/_mcp")`. `/_mcp` reçoit des POST JSON-RPC
externes sans jeton CSRF possible. (Le webhook Frisbii est déjà exempté de la
même façon.)
### 4. UI self-service — `src/pages/compte/mcp.py`
- **Section** ajoutée à `src/pages/_compte_shell.py` `SECTIONS` :
`{"key": "mcp", "label": "Connecteur MCP", "href": "/compte/mcp", "require_subscription": True}`.
Placée avant « Abonnement ». La garde de section existante redirige vers
`/compte/abonnement` si pas d'abonnement actif.
- **Page** `@register_page` sur `/compte/mcp`, enveloppée par `account_shell` +
`account_guard("/compte/mcp", require_subscription=True)`, suivant le patron des
autres pages `compte/`.
- **Contenu** :
1. Explication courte : ce qu'est le connecteur MCP, qu'il faut un abonnement
actif, que le jeton vaut identité (à garder secret).
2. **Tableau** des jetons MCP de l'utilisateur : label, créé le, dernière
utilisation, statut (actif/révoqué), bouton **Révoquer** par jeton actif.
3. **Formulaire de création** : champ « label » (ex. « Claude sur mon portable »)
- bouton. À la création, le jeton en clair est **affiché une seule fois**
(jamais re-stocké en clair), avec bouton copier.
4. **Instructions par client** (accordéon/onglets) : Claude, Gemini, Mistral,
ChatGPT — chacune avec le snippet de §7, le jeton fraîchement créé injecté
dans le snippet, et l'URL `<APP_BASE_URL>/_mcp`.
- **Implémentation** : callbacks Dash + inputs CSRF, comme les autres pages
`compte/`. Création via `create_token(..., kind='mcp', user_id=current_user.id)` ;
liste via `list_user_tokens(..., current_user.id, 'mcp')` ; révocation via
`revoke_user_token(..., token_id, current_user.id)`. Toute action vérifie
`current_user.is_authenticated` et l'abonnement côté serveur (pas seulement
masquée dans l'UI — cf. points de vigilance sécurité de l'issue).
### 5. API REST inchangée — `src/api/auth.py`
- `require_token` : ajouter un filtre pour **refuser** les jetons `kind='mcp'`
(401), afin que les jetons dédiés MCP ne fonctionnent pas sur `/api/v1`. Les
jetons `kind='api'` (tous les jetons CLI existants) restent acceptés à
l'identique → aucun changement de comportement pour l'existant.
- Nettoyer le `print(API_AUTH_DISABLED)` de débogage présent ligne 19 (bruit).
### 6. Activation & configuration
- Le garde rend `DASH_MCP_ENABLED=true` **sûr en production** (accès
systématiquement conditionné à l'abonnement). Le défaut reste **`false`**
(tests inchangés, pas de flip automatique dans le code).
- Déploiement recommandé : activer d'abord sur `test.colibre.fr` (branche `dev`)
via la variable d'environnement, valider, puis `main`.
- `.template.env` : documenter que `DASH_MCP_ENABLED=true` requiert le connecteur
(scope B) et un abonnement actif côté client.
- `APP_BASE_URL` (déjà utilisé pour le callback LinkedIn) sert à construire l'URL
`/_mcp` dans les snippets. Si absent, la page affiche l'URL relative + un
avertissement (comportement dégradé, non bloquant).
### 7. Instructions par client (contenu de la page)
> `<URL>` = `<APP_BASE_URL>/_mcp` (ex. `https://colibre.fr/_mcp`) ;
> `<TOKEN>` = jeton fraîchement généré.
- **Claude (Code / Desktop)** :
`claude mcp add colibre --transport http <URL> --header "Authorization: Bearer <TOKEN>"`
- **Gemini CLI** :
`gemini mcp add --transport http --header "Authorization: Bearer <TOKEN>" colibre <URL>`
(ou bloc `settings.json` : `mcpServers.colibre.httpUrl` + `headers.Authorization`).
- **Mistral Le Chat** : dans les connecteurs MCP, ajouter un serveur HTTP
d'URL `<URL>`, authentification « API Token », en-tête `Authorization` =
`Bearer <TOKEN>`.
- **ChatGPT** : l'app grand public exige OAuth 2.1 (pas de jeton statique) →
documenter la voie **développeur** (OpenAI API / Agents SDK) qui accepte un
en-tête `Authorization: Bearer <TOKEN>` sur un serveur MCP distant, et noter
que la prise en charge dans l'app ChatGPT nécessitera le connecteur OAuth
(**scope B2**, itération future).
## Stratégie de test
- **`tests/api/test_tokens_db.py`** (étendre) : colonne `kind` par défaut `'api'` ;
`create_token(kind='mcp')` ; `list_user_tokens` filtre par user_id + kind ;
`revoke_user_token` respecte la propriété (un user ne peut pas révoquer le jeton
d'un autre → retourne `False`, ligne intacte).
- **`tests/mcp/test_auth.py`** (nouveau) : garde `/_mcp`
401 (pas d'en-tête / `Bearer` vide / jeton inconnu / jeton révoqué /
jeton `kind='api'`) ; 403 (jeton `kind='mcp'` valide mais `user_id` nul ou
abonnement inactif) ; passage (jeton MCP + abonnement actif, ou `TOUS_ABONNES`) ;
`increment_usage` appelé en cas de succès ; en-tête `WWW-Authenticate` sur 401.
- **`tests/api/test_api_auth.py`** (étendre) : `require_token` refuse un jeton
`kind='mcp'`, accepte un jeton `kind='api'`.
- **Migration** : `apply_pending()` idempotente sur DB existante (ajoute `kind`)
et sur DB fraîche (tolère _duplicate column_).
- **UI** (`tests/…` selon patron compte) : génération → affichage unique du jeton ;
révocation ; redirection `/compte/abonnement` sans abonnement ; anti-IDOR
(révocation limitée aux jetons de l'utilisateur courant).
## Hors périmètre (YAGNI)
- Serveur d'autorisation OAuth 2.1 / DCR / PKCE / consentement (→ scope B2).
- Rate-limiting, quotas par jeton, scopes fins par tool MCP.
- Refonte de l'API REST (`/api/v1` inchangée hormis le refus des jetons MCP).
- Support natif de l'app ChatGPT (nécessite OAuth → scope B2).
- Rotation / expiration automatique des jetons (révocation manuelle suffit pour V1).
## Points de vigilance sécurité (rappel issue #111)
- Toute règle d'accès (abonnement, propriété du jeton) est **appliquée
explicitement côté serveur**, jamais seulement masquée dans l'UI.
- Le jeton en clair n'est affiché qu'une fois ; seul son hachage SHA-256 est stocké.
- 401 générique (ne pas révéler si un jeton existe).
- Révocation et listing strictement limités au propriétaire (`user_id`).
@@ -0,0 +1,286 @@
# Scope B2 — Serveur d'autorisation OAuth 2.1 pour le connecteur MCP
**Issue :** #114 (« Permettre l'accès aux données colibre via ChatGPT et Claude.ai »),
2ᵉ partie de #111.
**Date :** 2026-07-13
**Statut :** design approuvé
**Prérequis :** scopes A et B livrés et fusionnés dans `dev` (serveur MCP `/_mcp`,
`src/mcp/`, garde jeton statique `src/mcp/auth.py`, page `/compte/mcp`).
## Objectif
Permettre aux clients grand public qui exigent OAuth — **Claude.ai, Claude Desktop,
Claude mobile, ChatGPT** — de se connecter au serveur MCP colibre sans copier-coller
de jeton, en faisant de colibre son **propre serveur d'autorisation OAuth 2.1**
conforme à la spec MCP (authorization, 2025-06-18 / 2025-11-25). L'accès reste
**conditionné à un abonnement colibre actif**, vérifié à chaque requête.
Le chemin « jeton statique » livré en scope B (clients CLI : Claude Code, Gemini,
Mistral) est **conservé sans régression**. Ce scope l'**ajoute** en parallèle.
## Décisions de conception (arbitrées)
1. **colibre = serveur d'autorisation ET resource server**, sur le même Flask
(`app.server`) qui sert déjà `/_mcp`. L'étape de consentement réutilise la
**session flask_login existante**.
2. **authlib** (déjà en dépendance pour LinkedIn) via son cœur OAuth 2.0
(`authlib.oauth2.rfc6749/7591/7636/8414`), avec un **stockage SQLite maison**
(`sqlite3` brut, comme `tokens_db.py` / `auth.db` / `subscriptions.db`). Pas de
SQLAlchemy.
3. **Tokens d'accès opaques**, validés par lookup haché en base (colibre étant AS
**et** RS sur le même hôte). Pas de JWT/JWKS/signature. L'audience (`resource`)
est stockée sur la ligne du token.
4. **DCR (RFC 7591) comme baseline** d'enregistrement client. Claude et ChatGPT le
supportent nativement et retombent dessus si CIMD n'est pas annoncé. Clients
**publics** (`token_endpoint_auth_methods_supported: ["none"]` + PKCE S256).
5. **Gate abonnement bloquant tôt, à `/authorize`**, ET re-vérifié à chaque requête
`/_mcp` et à chaque refresh (défense en profondeur — voir §« Abonnement »).
6. **Détection d'usage (niveau 1)** : table `mcp_usage` journalisant chaque requête
`/_mcp` authentifiée. Pas de rate-limiting actif (hors-périmètre).
7. **Durées de vie** : access token **1 h**, refresh token **60 j**, **rotation du
refresh** à chaque usage (exigence client public).
## Exigences externes vérifiées (2026-07)
Sources : spec MCP authorization (2025-06-18/2025-11-25), doc connecteurs Claude
(`claude.com/docs/connectors/building/authentication`), doc Apps SDK ChatGPT
(`developers.openai.com/apps-sdk/build/auth`).
Communes à Claude **et** ChatGPT :
- **Découverte** : PRM (RFC 9728) à `/.well-known/oauth-protected-resource`
(+ variante suffixée `/_mcp`) ; AS metadata (RFC 8414) à
`/.well-known/oauth-authorization-server` ; **`/.well-known/openid-configuration`**
aussi (sondé par ChatGPT). Le champ `resource` de la PRM doit valoir **exactement**
l'URL saisie par l'utilisateur (`https://colibre.fr/_mcp`) ; `authorization_servers`
liste l'issuer, **1ʳᵉ entrée utilisée** (pas de fallback vers les suivantes).
- **401 + `WWW-Authenticate: Bearer …, resource_metadata="…"`** : c'est ce header
qui déclenche le flux OAuth côté client. Le 401 (pas 200) est requis.
- **PKCE S256** obligatoire ; metadata doit annoncer
`code_challenge_methods_supported: ["S256"]`.
- **Audience RFC 8707** : `resource` envoyé sur `/authorize` et `/token`, copié sur
le token, validé à `/_mcp`.
- **`/token`** accepte `application/x-www-form-urlencoded`, renvoie des codes
d'erreur **RFC 6749** (`invalid_grant`). `/register` en `application/json`.
- **Refresh** : Claude fait la **rotation** (client public) et n'ajoute
`offline_access` que si annoncé dans `scopes_supported`. ChatGPT ne l'exige pas.
→ on supporte le refresh et on annonce `offline_access`.
- **Redirect URIs** validés en **exact-match** contre ceux fournis au DCR :
Claude `https://claude.ai/api/mcp/auth_callback` ; ChatGPT
`https://chatgpt.com/connector/oauth/{id}` (+ legacy
`https://chatgpt.com/connector_platform_oauth_redirect`). Rien à coder en dur.
- **Consentement** : l'écran doit afficher le **hostname du redirect_uri** (risque
d'usurpation loopback, spec 2025-11-25).
- **Latence** : discovery/registration/token < 10 s, refresh < 30 s (les opérations
sqlite sont bien en-deçà).
- **Ops** : l'AS et `/_mcp` doivent rester joignables depuis l'egress Anthropic
`160.79.104.0/21` sans WAF bloquant, en **HTTPS** (localhost toléré en dev).
## Architecture
### Coexistence des deux chemins d'auth sur `/_mcp`
| Chemin | Clients | Mécanisme |
| ---------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
| Jeton statique `colibre_…` (scope B, inchangé) | Claude Code, Gemini, Mistral | collé à la main depuis `/compte/mcp` |
| **OAuth 2.1 (ce scope B2)** | Claude.ai, Claude Desktop/mobile, ChatGPT | « Ajouter un connecteur » → flux OAuth, zéro copier-coller |
### Arborescence
```
src/mcp/oauth/
__init__.py
store.py # stores sqlite bruts : clients (DCR), codes, tokens
server.py # authlib AuthorizationServer : AuthorizationCodeGrant+PKCE, RefreshTokenGrant, DCR
metadata.py # documents JSON RFC 9728 (protected-resource) + RFC 8414 (AS)
consent.py # écran de consentement + gate abonnement
routes.py # blueprint Flask : /.well-known/*, /oauth/register, /oauth/authorize, /oauth/token, /oauth/revoke
src/mcp/usage.py # journal d'usage /_mcp (niveau 1 détection)
```
Fichiers **modifiés** : `src/mcp/auth.py` (garde `/_mcp` accepte aussi les tokens
OAuth + en-tête `resource_metadata` + journal `mcp_usage`), `src/migrations.py`
(nouvelles tables), `src/pages/compte/mcp.py` (instructions Claude.ai / ChatGPT),
`src/app.py` (enregistrement blueprint + exemption CSRF).
### Isolation des unités
- `store.py` : accès sqlite pur (clients/codes/tokens), testable sans HTTP ni authlib.
- `server.py` : configuration authlib (grants, hooks `query_client`/`save_token`),
branchée sur `store.py`.
- `metadata.py` : documents JSON purs (fonctions déterministes de `APP_BASE_URL`).
- `consent.py` : rendu de l'écran + gate abonnement, testable indépendamment.
- `usage.py` : journal `/_mcp`, testable isolément.
## Stockage — 3 tables OAuth + 1 table usage (`users.sqlite`)
Créées via `_MIGRATIONS` et initialisées au démarrage (`init_schema`, comme
`tokens_db`), **avant** `apply_pending()`. Tolérance _duplicate_ déjà gérée.
- **`oauth_clients`** — `client_id` (PK), `client_metadata` (JSON : `redirect_uris`,
`client_name`, `token_endpoint_auth_method='none'`, `grant_types`, `scope`),
`created_at`. Clients **publics**, créés par DCR.
- **`oauth_codes`** — `code_hash` (PK), `client_id`, `user_id`, `redirect_uri`,
`code_challenge`, `code_challenge_method`, `scope`, `resource`, `expires_at`,
`used`. Éphémère (~60 s).
- **`oauth_tokens`** — `access_token_hash`, `refresh_token_hash`, `client_id`,
`user_id`, `scope`, `resource` (audience), `issued_at`, `access_expires_at`
(+1 h), `refresh_expires_at` (+60 j), `revoked_at`. Rotation du refresh à chaque
usage.
- **`mcp_usage`** — `id` (PK), `user_id`, `token_id`, `kind` (`'static'`|`'oauth'`),
`created_at`. Une ligne par requête `/_mcp` **authentifiée** (niveau 1 détection).
Les jetons statiques restent dans `api_tokens` (inchangé).
## Endpoints, discovery & flux OAuth
Blueprint `src/mcp/oauth/routes.py` sur `app.server`, **exempté de CSRF** (comme
`/_mcp` ; POST externes sans jeton CSRF).
| Route | Méthode | Rôle |
| ------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/.well-known/oauth-protected-resource` + `…/oauth-protected-resource/_mcp` | GET | **PRM (RFC 9728)** : `{ resource:"<base>/_mcp", authorization_servers:[issuer], scopes_supported:["mcp","offline_access"] }` |
| `/.well-known/oauth-authorization-server` + `/.well-known/openid-configuration` | GET | **AS metadata (RFC 8414)** : `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, `code_challenge_methods_supported:["S256"]`, `token_endpoint_auth_methods_supported:["none"]`, `grant_types_supported:["authorization_code","refresh_token"]`, `scopes_supported:["mcp","offline_access"]`, `issuer` |
| `/oauth/register` | POST (JSON) | **DCR (RFC 7591)** : crée un `oauth_clients` public, renvoie `client_id` + metadata |
| `/oauth/authorize` | GET / POST | **Consentement** : login flask_login → gate abonnement → écran → code |
| `/oauth/token` | POST (form) | grants `authorization_code` (+PKCE) et `refresh_token` (rotation) |
| `/oauth/revoke` | POST (form) | **RFC 7009** (révocation ; peu coûteux) |
### Flux `/authorize`
1. `server.get_consent_grant()` (authlib) valide `client_id`, `redirect_uri`
(exact-match), `resource`, `code_challenge`.
2. `current_user` non authentifié → `redirect('/connexion?next=<authorize-url>')`,
retour ici après login.
3. Connecté mais **pas d'abonnement actif** (`TOUS_ABONNES or has_active_subscription(user_id)` faux) → page HTML « Abonnement requis » + lien
`/compte/abonnement`, **aucun code émis**.
4. Sinon → écran de consentement minimal : nom du client, **hostname du
redirect_uri**, périmètre (« lire les données colibre en votre nom »),
boutons Autoriser / Refuser.
5. **Autoriser**`server.create_authorization_response(grant_user=current_user)` :
`code_hash` stocké dans `oauth_codes` (avec `resource`, `code_challenge`),
redirection vers le client.
### Flux `/token`
- `authorization_code` : authlib échange code + `code_verifier` (PKCE S256) →
`oauth_tokens` (access 1 h, refresh 60 j, `resource` copié comme audience).
Code invalide / `code_verifier` erroné → `invalid_grant`.
- `refresh_token` : **rotation** (ancien refresh révoqué, nouveau renvoyé dans la
même réponse), et **re-vérification de l'abonnement** : si perdu → `invalid_grant`,
ce qui force le client à relancer le flux complet (lequel rebute au gate
`/authorize`).
## Garde `/_mcp` unifié (`src/mcp/auth.py`)
1. **401 enrichi** :
`WWW-Authenticate: Bearer realm="colibre-mcp", resource_metadata="<base>/.well-known/oauth-protected-resource/_mcp"`.
2. **Routage du Bearer** :
- préfixe `colibre_` → chemin statique existant (`api_tokens`, inchangé) ;
- sinon → chemin OAuth (`oauth_tokens` : lookup haché, **non expiré**,
**audience == `<base>/_mcp`**, non révoqué).
3. Convergence : `user_id` → abonnement actif → `increment_usage`
**`mcp_usage.record(user_id, token_id, kind)`** (best-effort) → laisser passer.
4. Échecs : token invalide / expiré / mauvaise audience / révoqué → **401** (avec
le header) ; `user_id` nul ou abonnement inactif → **403**. Rien n'est journalisé
dans `mcp_usage` sur échec.
## Abonnement : sémantique `TOUS_ABONNES` et « pas de droit acquis »
`TOUS_ABONNES` est lu au démarrage (constante d'`os.getenv`). L'abonnement est
**re-vérifié à chaque requête** `/_mcp`, pas seulement à l'émission du token :
| Moment | Effet sur un token OAuth existant |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TOUS_ABONNES=true` | Tout utilisateur connecté franchit `/authorize`, obtient un token ; chaque requête `/_mcp` passe. |
| `TOUS_ABONNES``false` (après redémarrage) | Token ni supprimé ni révoqué, mais à la requête suivante le garde ré-évalue `False or has_active_subscription(user_id)`. Sans abonnement actif → **403** immédiat ; le token devient inerte. |
| L'utilisateur s'abonne ensuite | Le **même** token refonctionne (garde de nouveau vrai), sans re-login. |
Il n'existe **aucune fenêtre de tolérance** : le check étant par requête, même un
token émis juste avant le basculement est bloqué dès l'appel suivant. Cohérent avec
le refresh (`invalid_grant` si abonnement perdu).
## Détection d'usage — niveau 1 (`src/mcp/usage.py`)
Objectif : rendre l'usage `/_mcp` **interrogeable** (socle d'un futur quota /
rate-limiting), sans encore plafonner.
- **`record(db_path, user_id, token_id, kind)`** : insère `(user_id, token_id, kind, created_at)` dans `mcp_usage`. Appelé par le garde après un succès. **Best-effort**
(jamais bloquant).
- **`count_since(db_path, user_id, iso_ts) -> int`** : nombre de requêtes d'un
utilisateur depuis un horodatage (base d'un futur seuil/minute).
- **`purge_older_than(db_path, days=90)`** : suppression des lignes anciennes,
appelée **au démarrage** (mirror de `db.purge_expired_tokens()`), pour borner la
croissance.
Signaux de détection disponibles **au total** après ce scope :
- **Matomo** (déjà câblé, scope A) : volume par tool dans le temps (anonyme).
- **Compteurs par token** : `count_total` / `last_used_at` (`api_tokens` +
`oauth_tokens`), cumulatifs.
- **`mcp_usage`** : journal par requête, fenêtrable par utilisateur.
- **Logs d'accès** gunicorn/nginx : brut, par IP.
## UI `/compte/mcp` (`src/pages/compte/mcp.py`)
La page conserve son générateur de jetons statiques ; l'accordéon `client_instructions`
évolue :
- **Claude Code / Gemini / Mistral** : inchangés (jeton statique collé).
- **Claude.ai / Desktop / mobile** : nouvelle entrée « Aucun jeton à copier » →
_Paramètres → Connecteurs → Ajouter un connecteur personnalisé → URL
`https://colibre.fr/_mcp` → se connecter avec colibre → Autoriser_. Champ
« Client Secret » laissé vide (client public).
- **ChatGPT** : remplace le texte « votez pour la fonctionnalité » par les étapes
réelles (connecteurs → ajouter par URL `https://colibre.fr/_mcp` → flux OAuth) ;
note que la disponibilité dépend du plan ChatGPT de l'utilisateur.
- Court paragraphe expliquant : **jeton = clients CLI**, **OAuth = apps grand
public**, même abonnement requis.
Les tokens OAuth (éphémères, gérés par le client) ne sont **pas** listés dans le
tableau, qui reste réservé aux jetons statiques.
## Activation & configuration
- Toujours piloté par **`DASH_MCP_ENABLED=true`** (le flux OAuth ne s'enregistre que
dans ce bloc de `app.py`, après `configure_mcp_server`).
- **`APP_BASE_URL`** sert d'**issuer** et à construire `resource` / URLs well-known.
**HTTPS obligatoire** hors dev (exigence spec) ; localhost toléré en dev.
- **Aucun nouveau secret** (tokens opaques hachés ; `SECRET_KEY` déjà présent).
- `.template.env` : documenter que le flux OAuth requiert `APP_BASE_URL` en HTTPS et
que l'egress Anthropic `160.79.104.0/21` doit être joignable.
- Déploiement recommandé : activer d'abord sur `test.colibre.fr` (branche `dev`),
valider un connecteur Claude.ai réel, puis `main`.
## Stratégie de test
- **`tests/mcp/test_oauth_metadata.py`** : PRM (`resource` exact, `authorization_servers`,
`scopes_supported` inclut `offline_access`) ; AS metadata
(`code_challenge_methods_supported:["S256"]`, `none`, `registration_endpoint`,
grants) ; `/.well-known/openid-configuration` = miroir ; variantes suffixées `/_mcp`.
- **`tests/mcp/test_oauth_store.py`** : CRUD clients/codes/tokens, hachage,
expiration, rotation refresh, révocation.
- **`tests/mcp/test_oauth_flow.py`** : DCR → client public ; `/authorize`
(non connecté → redirection ; connecté sans abonnement → « Abonnement requis »,
**aucun code** ; avec abonnement → code) ; `/token` avec PKCE S256 (mauvais
`code_verifier``invalid_grant`) ; `resource`/audience copiée ; redirect_uri
non enregistrée → rejet.
- **`tests/mcp/test_auth.py`** (étendre) : garde accepte un token OAuth valide ;
rejette expiré / mauvaise audience / révoqué (**401 + `resource_metadata`**) ;
**`TOUS_ABONNES=false` + pas d'abonnement → 403 même avec token OAuth valide** ;
refresh refusé (`invalid_grant`) si abonnement perdu.
- **`tests/mcp/test_usage.py`** (nouveau) : `record` insère sur succès ; **aucune
insertion sur 401/403** ; fenêtrage de `count_since` ; `purge_older_than`.
- **Migration** : `apply_pending()` idempotente (DB existante → ajoute les tables ;
DB fraîche → tolère _duplicate_).
## Hors-périmètre (YAGNI → itérations futures)
- **CIMD** (Client ID Metadata Document, spec MCP 2025-11-25) et redirect loopback
port-agnostic (Claude Code passe déjà par jeton statique).
- `oauth_anthropic_creds`, `private_key_jwt`, mTLS, `id_token_hint`, tokens JWT/JWKS.
- **Rate-limiting / quotas / 429 / alerting** (niveaux 2-3) : `mcp_usage` en pose le
socle, le plafonnement effectif fera l'objet d'une issue dédiée.
- Scopes fins par tool ; écran de gestion/révocation des connexions OAuth actives
côté utilisateur.
- Soumission au directory de connecteurs Claude / ChatGPT.
@@ -0,0 +1,145 @@
# Migration des tables vers Dash AG Grid — Design (Lot 2a : `acheteur.py` + `titulaire.py`)
- **Issues** : #41 (migration AG Grid)
- **Date** : 2026-07-13
- **Fait suite à** : `docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md` (Lot 1, `tableau.py`)
## Contexte
Le Lot 1 a migré `tableau.py` de `dash_table.DataTable` vers `dag.AgGrid`, avec un moteur de requête générique (AST booléen → SQL DuckDB, `src/utils/query_ast.py`) et une datasource server-side réutilisable (`fetch_grid_page`, `grid_column_defs`, `apply_persisted_layout` dans `src/utils/grid.py`, déjà paramétrée par `base_where_sql`/`base_params`). Depuis, `ag_grid()` (`src/figures.py`) a aussi reçu un thème ("brique", accentColor `rgb(179, 56, 33)`, police Inter) qui remplace l'apparence de base initialement conservée au Lot 1.
`acheteur.py` et `titulaire.py` sont deux pages quasi-identiques (même auteur, même pattern, juste paramétrées par le type d'entité) affichant les marchés d'un acheteur ou d'un titulaire donné. Chacune a :
- un tableau principal déjà paginé/filtré/trié **server-side** sur DuckDB, mais via l'ancien DSL `DataTable` (`filter_query`/`sort_by`, mono-condition — la même limite qui causait le bug corrigé sur `/tableau`, cf. commit `117ce9a`),
- deux boutons d'export Excel (toutes les données de la fiche / données filtrées, ce dernier via pipeline Polars),
- un sélecteur de colonnes, un bouton Réinitialiser,
- un petit tableau "top 10" agrégé (`get_top_org_table`, données déjà chargées, pagination/filtre natifs sans aller-retour serveur).
Dash abandonnant `dash_table.DataTable`, cette migration doit couvrir ces deux pages avant que la dépréciation ne devienne bloquante.
## Objectifs
1. **Préserver les fonctionnalités existantes** (filtre/tri/pagination server-side scopés à l'entité, export x2, sélecteur de colonnes, reset) en passant à `dag.AgGrid`.
2. **Réutiliser au maximum l'infrastructure du Lot 1** (`ag_grid()`, `grid_column_defs()`, `fetch_grid_page()`, `apply_persisted_layout()`, thème brique) plutôt que de la redupliquer.
3. **Factoriser** la logique commune à acheteur.py/titulaire.py (déjà dupliquée aujourd'hui) dans un nouveau module partagé, plutôt que de dupliquer une troisième fois la logique AG Grid.
4. **Gagner le filtre multi-conditions (ET/OU)** gratuitement, comme sur `/tableau`, en remplaçant le DSL `filter_query` par le `filterModel` AG Grid + AST.
## Non-objectifs (reportés)
- `observatoire.py` : sous-projet séparé (Lot 2b), architecture de données différente (jusqu'à ~1,5M lignes matérialisées, cf. `prepare_dashboard_data`).
- `recherche.py`, `admin/liste.py`, `figures.make_table` (page a-propos/sources) : Lot 3, pages plus simples.
- #97 (requêtes booléennes texte libre, abonnés) — inchangé, le moteur AST du Lot 1 reste le socle.
- Suppression de l'ancien DSL (`filter_query_to_sql`, `clean_filters`) : reportée à la fin du Lot 3 tant que d'autres pages s'en servent encore.
## Décisions d'architecture
### Nouveau module `src/utils/entity_grid.py`
Factorise la logique commune aux deux pages, paramétrée par `org_type: Literal["acheteur", "titulaire"]` :
- `entity_grid_column_defs(org_type, hidden_columns, column_state) -> list[dict]` : `grid_column_defs()` du Lot 1 + `apply_persisted_layout()` par-dessus (réutilisés tels quels, aucune modif requise).
- `fetch_entity_grid_page(filter_model, sort_model, start_row, end_row, base_where_sql, base_params) -> (rows, total, total_unique)` : appelle directement `fetch_grid_page()` du Lot 1 (déjà générique, aucune modif requise).
- `export_entity_grid(filter_model, sort_model, hidden_columns, base_where_sql, base_params) -> pl.DataFrame` : variante de `export_dataframe()` du Lot 1, étendue avec `base_where_sql`/`base_params`.
- `entity_ag_grid(grid_id: dict, ...) -> dag.AgGrid` : appelle `ag_grid()` du Lot 1 (même thème, même config) avec un `id` pattern-matching au lieu d'une string.
- Fabrique de callbacks pattern-matching partagée (datasource, reset, application des colonnes masquées, export filtré) enregistrée une fois, paramétrée par `org_type` — évite de dupliquer les `@callback` dans `acheteur.py` et `titulaire.py`.
**Modifications requises côté Lot 1 (`grid.py` / `figures.py`)** :
- `export_dataframe()` : ajouter les paramètres `base_where_sql="TRUE"`/`base_params=()` (aujourd'hui absents — cf. `src/utils/grid.py`), composés en `(base) AND (filtre)` comme le fait déjà `fetch_grid_page()`. `tableau.py` continue de l'appeler sans ces arguments (valeurs par défaut → comportement inchangé).
- `ag_grid()` : **paramétrer la persistance**, aujourd'hui codée en dur à `persistence=True, persisted_props=["filterModel", "columnState"]` et `id: str`. Il faut accepter (a) un `id` dict (pattern-matching) en plus d'une string, et (b) un `persisted_props` configurable. La grille entité ne persistera que `["filterModel"]` (cf. « Persistance découplée » ci-dessous) ; `tableau.py` garde `["filterModel", "columnState"]` par défaut → comportement inchangé.
`acheteur.py`/`titulaire.py` gardent leur fichier propre (URL, infos annuaire, carte, stats, histogramme, bouton "toutes les données" — tout ce qui n'est pas la grille) et appellent ce module pour tout ce qui est grille.
### `layout` devient une fonction
`layout = [...]` (liste statique) devient `def layout(acheteur_id=None, **kwargs): ...` (resp. `titulaire_id`) — mécanisme standard Dash pour les routes `path_template` dynamiques. Dash rappelle cette fonction à chaque navigation vers `/acheteurs/<acheteur_id>`, ce qui permet de connaître `acheteur_id` **au moment de construire le layout**, sans passer par `dcc.Location`.
**Périmètre du changement** : seuls la grille et les éléments qui doivent être scopés par entité changent d'id/de câblage. Tout le reste (siret, nom, carte, stats, histogramme, bouton "toutes les données", top 10) **garde exactement son câblage actuel** (id fixe + `Input("acheteur_url", "pathname")`) — `dcc.Location(id="acheteur_url")` est conservé pour ces callbacks, qui n'ont pas besoin de changer.
### Id pattern-matching de la grille
```python
grid_id = {"type": "acheteur-grid", "acheteur_id": acheteur_id, "year": ach_year_at_render}
```
Comme `ach_year` (dropdown, pas dans l'URL) n'est connu qu'au runtime et non au premier rendu du `layout()`, l'id inclut une valeur par défaut (`"Toutes les années"`) à la construction ; un changement d'année **remonte la grille** (nouvel id via un `Output(html.Div, "children")` qui reconstruit le composant `dag.AgGrid` avec le nouvel id) plutôt que de tenter un rafraîchissement in-place — le row model infinite d'AG Grid n'a pas de mécanisme pour se rafraîchir sur un changement externe au filtre/tri/scroll. Effet de bord accepté : `filterModel` se réinitialise aussi au changement d'année (raisonnable, non-régression par rapport au DSL actuel qui ne le préservait pas non plus explicitement).
Callbacks pattern-matching (dans `entity_grid.py`, via `MATCH`) :
- datasource : `Input({"type": f"{org_type}-grid", "acheteur_id": MATCH, "year": MATCH}, "getRowsRequest")``Output(..., "getRowsResponse")`.
- reset : bouton (id fixe, hors grille) → efface **filtres ET tris** (le libellé du bouton dit « Supprime tous les filtres et les tris »), donc `Output({"type": f"{org_type}-grid", ...}, "filterModel")` **et** `Output(..., "columnState")` via `ALL` (un seul reset visible à la fois, mais `ALL` reste nécessaire car ce n'est pas le composant qui émet l'événement). On reproduit l'approche #47-safe de `tableau.py::reset_view` : le tri est effacé en réécrivant `columnState` avec `sort=None`/`sortIndex=None`, ce qui **préserve** largeur/ordre/épinglage des colonnes (ne pas utiliser `resetColumnState`, qui les effacerait aussi).
- export filtré : lit `State({"type": f"{org_type}-grid", ...}, "filterModel")`/`"columnState"` via `ALL` (un seul match actif ; le `sortModel` est dérivé du `columnState` comme dans `tableau.py::download_data`).
### Persistance découplée : `filterModel` vs `columnState`
- **`filterModel`** : persistance native AG Grid (`persistence=True, persistence_type="local", persisted_props=["filterModel"]`) sur l'id pattern-matching ci-dessus → une entrée localStorage distincte par `(acheteur_id, year)`, isolée par fiche.
- **`columnState`** : **pas** de persistance native AG Grid pour cette prop (elle serait scopée par le même id, donc par entité — indésirable : la disposition des colonnes est une préférence utilisateur globale). À la place :
- un `dcc.Store(id="entity-grid-columns-state", storage_type="local")` **partagé entre acheteur.py et titulaire.py** (même schéma DECP dans les deux cas),
- un callback pattern-matching `Input({"type": ..., ...}, "columnState")` (`ALL`) → `Output("entity-grid-columns-state", "data")` écrit dedans à chaque changement,
- `entity_grid_column_defs()` lit ce store et réapplique largeur/ordre via `apply_persisted_layout()` (déjà prévu pour ça) avant de construire les `columnDefs` de la grille, peu importe l'acheteur/titulaire affiché.
> **Différence avec `tableau.py`** : là-bas, `apply_hidden_columns` lit `columnState` depuis le `State` **live de la grille** (id fixe, `columnState` persisté nativement). Ici, la grille ne persiste **pas** `columnState` (scoping par entité indésirable), donc le callback qui régénère les `columnDefs` (au chargement / changement de colonnes masquées) doit lire le columnState depuis le **store partagé**, pas depuis la grille.
>
> **Éviter la boucle** : le store est alimenté par `Input(grille.columnState)` et relu pour produire `columnDefs`, qui à leur tour peuvent faire ré-émettre `columnState` par AG Grid. Pour ne pas boucler, le callback qui régénère les `columnDefs` prend le store en `State` (pas en `Input`) — il n'est déclenché que par un changement de colonnes masquées ou un (re)montage de grille, jamais par l'écriture du store elle-même (même logique que `apply_hidden_columns` qui prend déjà `columnState` en `State`).
## Composants non-grille inchangés
`update_acheteur_infos`, `update_acheteur_map`, `update_acheteur_stats`, `update_download_button_acheteur`, `download_acheteur_data`, `get_top_titulaires` (callback autour du top 10, cf. ci-dessous), `toggle_acheteur_columns`, `update_acheteur_distance_histogram` (et équivalents titulaire) : **aucun changement**, toujours pilotés par `Input("acheteur_url", "pathname")`/`Input("acheteur_year", "value")` sur des id fixes.
## Tableaux "top 10" (`get_top_org_table`)
Migrés vers AG Grid **simple** (row model par défaut client-side, `rowData` fournie directement, pas de `getRowsRequest`) :
- Nouvelle fonction dans `figures.py`, ex. `get_top_org_ag_grid(data, org_type, extra_columns, filters=True)`, même signature/usage que l'actuelle `get_top_org_table`, réutilise `ag_grid()` (même thème) mais avec ses **propres** `columnDefs` dérivés de `setup_table_columns()` (le sous-ensemble de colonnes du top 10 n'est pas le schéma DECP complet — pas de réutilisation de `grid_column_defs()` ici).
- Pas de persistance (petit tableau statique, régénéré à chaque changement d'acheteur/année de toute façon).
- Utilisé par `acheteur.py` (`top10_titulaires`) et `titulaire.py` (`top10_acheteurs`). `observatoire.py` réutilise la même fonction dans son propre sous-projet (Lot 2b) sans travail supplémentaire ici.
## Export Excel
Les 2 boutons existants sont conservés (rôles différents, confirmé) :
- **"Téléchargement au format Excel"** (`download_acheteur_data`/`download_titulaire_data`) : inchangé, toutes les données de la fiche pour l'année sélectionnée, ignore l'état de la grille.
- **Bouton "filtré"** (`btn-download-filtered-data-acheteur`/`titulaire`) : passe du pipeline Polars (`filter_table_data`/`sort_table_data` sur `filter_query`/`sort_by`) au chemin DuckDB du Lot 1 — `export_entity_grid()` recompile `filterModel`/`sortModel` courants (lus via `State` pattern-matching `ALL`) → AST → SQL, scopé par `base_where_sql`/`base_params` de l'entité. Seuil de 65 000 lignes conservé (dérivé du `total` retourné par `fetch_grid_page`).
## Compteur de lignes (meta)
On **reproduit l'affichage de `/tableau`** : « _X marchés (Y lignes)_ », où `X` = nombre de marchés **uniques** (`COUNT(DISTINCT uid)`, `total_unique`) et `Y` = nombre de lignes (`total`). Aujourd'hui acheteur/titulaire n'affichent qu'un compteur simple (`acheteur_nb_rows`/`titulaire_nb_rows`).
- `fetch_grid_page()` renvoie déjà `(rows, total, total_unique)` — les deux valeurs sont donc disponibles sans requête supplémentaire.
- Comme sur `tableau.py`, le datasource écrit `total`/`total_unique` dans deux stores (par page, ex. `acheteur-total` / `acheteur-total-unique`), et un callback `update_meta` équivalent produit le libellé + gère le seuil de 65 000 lignes (bouton d'export filtré désactivé au-delà, message d'aide). On réutilise le même format via `format_number()` que `tableau.py::update_meta`.
- Ces stores/meta sont scopés par page (id fixe hors grille), alimentés par le datasource pattern-matching de la grille active.
## Dépendances
Aucune nouvelle dépendance (dash-ag-grid déjà installé au Lot 1).
## Cas limites & erreurs
- Mêmes garanties que le Lot 1 : colonnes validées contre le schéma, valeurs toujours paramétrées, `getRowsRequest is None``no_update`, colonne de filtre inconnue → ignorée + `logger.warning`.
- Changement d'année pendant un chargement de bloc en cours : la grille est remontée (nouvel id), l'ancienne requête devient orpheline sans effet (comportement standard React/Dash au remount).
- `columnState` vide au premier chargement (nouvel utilisateur) : `apply_persisted_layout(defs, None)` retourne `defs` inchangés (déjà géré, cf. Lot 1).
## Tests
- **Unitaires `entity_grid.py`** : datasource scopée (`base_where_sql`/`base_params` appliqués correctement pour acheteur vs titulaire), export scopé, `apply_persisted_layout` avec le store partagé.
- **Unitaires `figures.get_top_org_ag_grid`** : colonnes dérivées correctement, pas de persistance.
- **Mise à jour `tests/test_main.py`** : `test_002_filter_persistence` (sélecteurs `.marches_table th[data-dash-column=...]` → équivalents AG Grid) et `test_003_tableau_download` (signatures `filter_query`/`sort_by``filterModel`/`sortModel` pour les callbacks d'export acheteur/titulaire).
- Suite complète `uv run pytest` uniquement en fin de lot.
## Décisions tranchées
- Factorisation dans `src/utils/entity_grid.py` (pas de duplication acheteur/titulaire pour la nouvelle logique).
- `layout()` dynamique + pattern-matching **limité à la grille et à ce qui doit être scopé par entité** — le reste de la page ne change pas.
- `filterModel` persistant par `(entité, année)` ; `columnState` persistant globalement (partagé acheteur+titulaire), découplé via un store dédié.
- Changement d'année → remontage de la grille (pas de rafraîchissement in-place).
- Thème "brique" du Lot 1 réutilisé tel quel (pas d'apparence de base non-thémée).
- `ag_grid()` doit être paramétré (id dict + `persisted_props` configurable) ; `export_dataframe()` doit recevoir `base_where_sql`/`base_params` — deux extensions rétro-compatibles côté Lot 1.
- Reset efface filtres **et** tris (via réécriture `columnState` `sort=None`, en préservant largeur/ordre/épinglage — approche #47-safe de `tableau.py`).
- Compteur de lignes : on reproduit l'affichage « X marchés (Y lignes) » de `/tableau` (via `total`/`total_unique` déjà renvoyés par `fetch_grid_page`).
- Les 2 boutons d'export conservés (rôles différents).
- Top 10 migré vers AG Grid simple (row model client-side), sans persistance.
## Reporté
- `observatoire.py` (Lot 2b — sous-projet séparé, spec dédiée).
- Lot 3 (`recherche.py`, `admin/liste.py`, `figures.make_table`) puis suppression de l'ancien DSL.
@@ -0,0 +1,197 @@
# Partage de vues sauvegardées par URL courte (#112)
**Date :** 2026-07-13
**Issue :** #112 — dépend de #41 (migration AG Grid, DSL d'URL retiré), à articuler avec #97.
**Dépend de :** vues sauvegardées existantes (voir `2026-06-29-vues-sauvegardees-design.md`).
## Contexte
Avec la migration vers AG Grid (#41), on abandonne l'encodage complet d'une vue
(filtres + tris + colonnes) dans l'URL via l'ancien DSL de la DataTable. Pas de
rétro-compatibilité (version majeure).
Le rappel / partage d'une vue repose désormais sur les **vues sauvegardées** (déjà
réservées aux abonnés), référencées par une URL courte. La brique de sauvegarde
existe déjà (`src/saved_views/`, table `saved_views`, application in-page via le
menu déroulant du tableau). Il manque : une URL partageable qui résout et applique
une vue côté serveur, et l'affordance pour récupérer/coller cette URL.
## Décisions de cadrage
- **Créer une vue** : abonnés uniquement (déjà en place, inchangé).
- **Ouvrir une vue par son URL** : **public** — toute personne disposant du lien,
même non connectée. C'est le sens d'un partage par URL.
- **Anti-énumération** : l'URL n'expose **pas** le `user_id` (séquentiel, devinable)
et le nom de vue seul n'est **pas** une clé (devinable). La clé réelle est un
**jeton aléatoire** non devinable.
## Format d'URL
```
https://colibre.fr/tableau?vue=<slug>_<token>
```
- `token` : **6 caractères base62** (`[0-9a-zA-Z]`) générés par `secrets`. ~5,7×10¹⁰
combinaisons → énumération inutile. C'est l'**identifiant réel et immuable** de la
vue (clé de lookup, unique globalement).
- `slug` : dérivé du nom de la vue, en **tirets** (`-`, convention web / SEO), purement
**cosmétique** et **ignoré** à la résolution. Régénéré depuis le nom à chaque
construction d'URL (le renommage d'une vue change le slug mais **pas** le lien, qui
reste valide via le jeton).
- **Séparateur slug↔jeton : `_`.** Le slug est en tirets donc ne contient aucun `_`,
et le jeton base62 non plus → le **seul** `_` de l'URL est le séparateur, et le
dernier segment après `rsplit("_", 1)` est toujours le jeton. Formes équivalentes
qui résolvent la même vue :
- `?vue=mes-marches-2024_abc123` → jeton `abc123`
- `?vue=zzz_abc123` → jeton `abc123`
- `?vue=abc123` → jeton `abc123` (slug optionnel)
(Pattern GitHub / Medium / Notion : slug lisible + id qui fait foi.)
## Modèle de données
Une seule colonne ajoutée à `saved_views` :
- **`token TEXT`** — jeton base62(6). Aucune colonne `slug` (recalculé à la volée).
- Unicité : `CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_views_token ON saved_views(token)`.
(Un index UNIQUE, pas une contrainte de colonne : autorise plusieurs `NULL`
transitoires pendant le backfill — SQLite traite les NULL comme distincts.)
### Migration
Pattern existant (`src/migrations.py` + `SCHEMA` dans `src/saved_views/db.py`) :
- `_MIGRATIONS += ("0012_add_token_to_saved_views", "ALTER TABLE saved_views ADD COLUMN token TEXT")`
- Sur DB fraîche : la migration s'exécute **avant** `saved_views_db.init_schema()`
(ordre dans `app.py` : `init_subscriptions``apply_pending` en premier), donc
« no such table » → toléré par `apply_pending`. La colonne est aussi présente
dans `SCHEMA`, donc créée par `init_schema`.
- Sur DB existante : `ADD COLUMN` ajoute la colonne (NULL pour les lignes existantes).
- `SCHEMA` (dans `db.py`) : ajouter `token TEXT` à la définition de table + la création
de l'index unique.
### Backfill
Le SQL statique ne peut pas générer un aléatoire par ligne. Dans `init_schema()`,
après création/altération : boucle Python sur les lignes `token IS NULL`, attribue
un jeton unique à chacune, `UPDATE`. Idempotent (ne touche que les lignes NULL) →
sans effet aux démarrages suivants.
## Couche d'accès (`src/saved_views/db.py`)
- `generate_token() -> str` : 6 caractères base62 via `secrets.choice`. Regénère en
cas de collision (extrêmement rare) — la boucle vérifie l'absence en base.
- `upsert(user_id, table_name, name, query)` : à l'**insertion**, génère et stocke un
jeton. À l'**écrasement** d'une vue existante (`ON CONFLICT(user_id, table_name, name)`),
le `DO UPDATE SET` ne touche **pas** `token` → le lien reste stable.
- `get_by_token(token) -> Row | None` : lookup public par jeton, **sans** filtre
`user_id`. Renvoie `None` si inconnu.
## Slugification (`src/saved_views/ui.py` ou util dédié)
`slugify(name) -> str` :
- minuscules,
- accents translittérés (ASCII fold),
- caractères non-alphanumériques → `-`,
- collapse des `-` répétés, trim des `-` en bord.
(Tirets, pas underscores : convention web/SEO, et garantit que le seul `_` de l'URL
est le séparateur slug↔jeton.)
Purement cosmétique. `build_view_url(name, token) -> str` :
`f"https://{DOMAIN_NAME}/tableau?vue={slugify(name)}_{token}"` (réutilise
`DOMAIN_NAME` de `src/utils/__init__.py`, qui vaut `test.colibre.fr` ou `colibre.fr`).
## Résolution `?vue=` sur `/tableau`
Nouveau callback, `Input("tableau_url", "search")` :
1. Parse le param `vue` ; extrait `token = value.rsplit("_", 1)[-1]`.
2. `get_by_token(token)` :
- **trouvé** : décode `{ast, columnState}` (même logique que `apply_saved_view`),
applique `filterModel` + `columnState` + `tableau-hidden-columns`, renseigne le
store `active_view` `{token, url}` et pose `suppress_next=1` (voir §Dérive).
- **introuvable / param malformé / vue supprimée** : tableau à l'état par défaut +
**alerte discrète non bloquante** : « Cette vue est introuvable ou a été
supprimée. » — **message identique dans tous les cas** (ne confirme pas
l'existence d'un compte ; anti-énumération, au prix d'un diagnostic moins précis
pour l'utilisateur légitime).
Aucun contrôle d'abonnement sur ce chemin (ouverture publique).
## UI de partage
### `/tableau`
Sous la barre de boutons (Colonnes, etc.) : un bloc masqué par défaut contenant
- label « URL directe vers cette vue : »,
- un `dcc.Input` **lecture seule** affichant l'URL complète,
- un `dcc.Clipboard` accolé (icône presse-papier, **infobulle au survol**) copiant
le contenu de l'input.
Affiché **quand une vue vient d'être sauvegardée ou ouverte** (URL ou menu), masqué
**à la première modification** de filtre/tri/colonne.
### `/compte/vues`
- Bouton **« Ouvrir »** existant : pointe désormais vers la vraie URL courte
(`build_view_url`) au lieu de `/tableau` nu → applique effectivement la vue.
- Ajout d'un **`dcc.Clipboard`** par vue (même composant que `/tableau` : icône +
infobulle) copiant l'URL courte de la vue. Pas de bouton texte « Copier le lien ».
## Détection de dérive (bloc de partage `/tableau`)
Verrou **« sale » à sens unique**, sans comparaison d'état : c'est le fait de
**changer** un paramètre qui masque la box, pas le fait que l'état diffère. Elle ne
réapparaît pas si l'état revient à celui de la vue.
Écueil : appliquer une vue modifie elle-même `filterModel`/`columnState` (« écho »),
ce qui déclencherait le masquage immédiat. Neutralisé par un drapeau one-shot.
- Stores : `active_view` `{token, url}`, `suppress_next` (compteur).
- **Application/ouverture** (URL ou menu) : renseigne `active_view`, affiche la box,
pose `suppress_next=1` (modifie la grille → un écho attendu).
- **Sauvegarde** : affiche la box, `suppress_next=0` (ne modifie pas la grille, pas
d'écho).
- **Callback « changement → masquer »** (Input `filterModel` + `columnState`) : si
`suppress_next > 0` → le décrémente/consomme et **garde** la box ; sinon → **masque**
(sens unique).
- **Hypothèse à valider par test** : l'application produit **une seule** invocation
coalescée de ce callback (Dash regroupe les Inputs `filterModel` + `columnState`
modifiés dans le même retour de callback). Si AG Grid émet deux mises à jour
distinctes, `suppress_next` devra valoir 2 à l'application.
## Gating (récapitulatif)
| Action | Contrôle |
| ------------------------------------------- | ----------------------------------------- |
| Sauvegarder / créer une vue | Abonné connecté (existant, inchangé) |
| Menu des vues in-page | Abonné connecté (existant, inchangé) |
| Ouvrir `?vue=<token>` | **Public** |
| Copier le lien (`/tableau`, `/compte/vues`) | Propriétaire abonné (surfaces déjà gated) |
## Tests
- `slugify` : accents, espaces, casse, caractères spéciaux → `-`, collapse/trim,
absence d'underscore dans le résultat.
- `build_view_url` : forme attendue avec `DOMAIN_NAME` (slug en tirets, séparateur `_`).
- Parsing du jeton : `slug-en-tirets_token`, `token` nu, param vide/malformé.
- `generate_token` : longueur/alphabet, unicité (mock collision).
- `get_by_token` : trouvé / inconnu.
- `upsert` : insertion génère un jeton ; écrasement (même nom) **préserve** le jeton.
- Backfill : lignes NULL reçoivent un jeton, idempotence au second appel.
- Résolution : `?vue=<token valide>` applique filtres/colonnes ; `?vue=inexistant`
et param malformé → état par défaut + alerte (message identique).
- Dérive (E2E léger) : après ouverture/sauvegarde, box visible ; après un changement
de filtre/tri/colonne, box masquée ; ne réapparaît pas au retour à l'état initial ;
valider le comportement d'écho (une seule invocation coalescée).
## Hors périmètre (YAGNI)
- Rétro-compatibilité avec l'ancien DSL d'URL (`?filtres=…&tris=…&colonnes=…`).
- Migration automatique des vues pré-AG-Grid (déjà géré : dégradation propre).
- Rate-limiting HTTP sur la résolution (le jeton non devinable suffit ; à revoir si
besoin avec #97).
- Vues publiques « découvrables » / listées : le partage reste par lien uniquement.
@@ -0,0 +1,111 @@
# Colonnes configurables pour `rechercher_marches` (MCP) — Design
**Issue liée :** connecteur MCP colibre (#114).
## Objectif
Rendre la sélection des colonnes du tool MCP `rechercher_marches` souple, au lieu
de la liste figée `MARCHES_COLUMNS`. Trois besoins :
1. Un **choix par défaut** (les colonnes actuelles), comportement inchangé si le
client ne demande rien.
2. Un **champ de lien** dynamique vers chaque marché, ajouté à chaque résultat :
`APP_BASE_URL/marche/{uid}`.
3. La possibilité pour l'agent/l'utilisateur de **choisir d'autres colonnes** parmi
celles disponibles, avec la meilleure UX atteignable dans l'interface tool MCP.
## Contexte UX (ce qui est possible, ce qui ne l'est pas)
- **Cases à cocher rendues par le serveur : impossible.** dash 4.4 n'implémente pas
l'_élicitation_ MCP (le serveur n'annonce que les capacités `tools` et
`resources`). Aucun widget interactif ne peut être poussé dans le client.
- **Levier retenu : un `enum` dans le schéma du paramètre.** En typant `colonnes`
avec un `Literal` des colonnes disponibles, l'agent reçoit la liste fermée valide
directement dans le schéma du tool (pas de tâtonnement, pas besoin d'appeler
`schema_donnees` au préalable). Beaucoup de clients (dont Claude) rendent un
paramètre enum comme un sélecteur cochable. C'est aussi une validation au niveau
schéma.
## Source de vérité des colonnes
`DATA_SCHEMA` (issu du TableSchema `base_schema.json`) est la référence, déjà
utilisée par `describe_schema()`. L'ensemble sélectionnable part de l'intersection `DATA_SCHEMA ∩ duckdb_schema`
(exactement le set déjà exposé comme `colonnes_filtrables`), **unie** aux colonnes
du défaut `MARCHES_COLUMNS` — pour que toute colonne du jeu par défaut reste
re-sélectionnable même si elle est enrichie et absente de `DATA_SCHEMA` (ex.
`acheteur_nom`). Ces colonnes du défaut sont toutes présentes dans la table DuckDB
(elles fonctionnent déjà), donc sûres à `SELECT` :
```python
_filtrables = tuple(name for name in DATA_SCHEMA if name in duckdb_schema)
SELECTABLE_COLUMNS = tuple(dict.fromkeys((*MARCHES_COLUMNS, *_filtrables)))
```
Source de vérité unique (schéma de référence + défaut), ni liste « raw DuckDB », ni
sous-ensemble à maintenir à la main.
## Modifications
### `src/mcp/queries.py`
- Construire à l'import (cf. section « Source de vérité ») :
```python
_filtrables = tuple(name for name in DATA_SCHEMA if name in duckdb_schema)
SELECTABLE_COLUMNS = tuple(dict.fromkeys((*MARCHES_COLUMNS, *_filtrables)))
ColonneMarche = Literal[SELECTABLE_COLUMNS]
```
- `search_marches(..., colonnes: list[str] | None = None)` :
- `colonnes is None``MARCHES_COLUMNS` (comportement inchangé).
- liste fournie → **exactement** ces colonnes (remplace le défaut).
- **Validation runtime conservée** (défense en profondeur : le `enum` du schéma
n'est pas toujours imposé par le client). Toute colonne absente de
`SELECTABLE_COLUMNS` → retour `{"error": "colonne inconnue: <col>", "champ": col}`
(même patron que les erreurs de filtre). C'est ce qui protège l'interpolation
SQL brute de `query_marches` (`src/db.py` fait `", ".join(columns)` sans
quoting ni validation, prévu pour des appelants internes seulement).
- **`uid` toujours récupéré** en interne (nécessaire au lien) même s'il n'est pas
demandé, et **toujours présent en sortie** (clé primaire).
- **`lien` toujours ajouté** à chaque marché après la requête (champ virtuel,
calculé en Python comme la colonne `marche`) :
`f"{base}/marche/{uid}"` avec `base = os.getenv("APP_BASE_URL", "").rstrip("/")`
(cohérent avec le reste du code : `src/mcp/auth.py`, `oauth/routes.py`, etc.).
- `describe_schema()` : ajoute la clé `colonnes_disponibles = list(SELECTABLE_COLUMNS)`.
`lien` est mentionné dans `colonnes_retournees`.
### `src/mcp/tools.py`
- `rechercher_marches(..., colonnes: list[ColonneMarche] | None = None)` — c'est
cette annotation `enum` qui porte l'UX.
- Docstring mise à jour : décrit `colonnes` (défaut = jeu standard ; renvoie vers
`schema_donnees().colonnes_disponibles`), et mentionne le champ `lien`.
## Points notables / décisions
- **`lien` non désactivable** (YAGNI). Toujours présent.
- **`APP_BASE_URL` non défini (dev)** → `lien` relatif `/marche/{uid}`. Acceptable
(dev only), cohérent avec le fallback des autres modules.
- **Sémantique « remplace »** (et non « ajoute ») : `colonnes=[...]` renvoie
exactement ce set (+ `uid` + `lien`). Choix validé : l'utilisateur maîtrise
précisément ce qu'il reçoit.
- **Pas de champ `title` d'affichage pour les tools** : abandonné (dash 4.4 n'a pas
de titre séparé du `name`, qui sert à la fois d'identifiant et d'affichage).
## Tests — `tests/mcp/test_queries.py`
- `colonnes=None` → renvoie le jeu par défaut (`MARCHES_COLUMNS`) inchangé, `lien`
présent.
- `colonnes=["objet", "montant"]` → renvoie exactement ces colonnes + `uid` + `lien`.
- Colonne invalide (`["nexiste_pas"]`) → `{"error": ..., "champ": "nexiste_pas"}`,
aucune requête SQL avec la colonne interpolée.
- `lien` bien formé : `<APP_BASE_URL>/marche/<uid>` (monkeypatch `APP_BASE_URL`).
- `uid` présent en sortie même absent de `colonnes`.
- `describe_schema()` expose `colonnes_disponibles` non vide et surensemble de
`colonnes_filtrables` (inclut les colonnes du défaut).
- Le schéma du paramètre `colonnes` du tool `rechercher_marches` contient bien un
`enum` (via `TypeAdapter` sur l'annotation, ou le builder dash).
## Hors périmètre
- Titre d'affichage joli pour les tools (abandonné).
- Toute modification de l'API REST `/data`.
- Élicitation / widgets interactifs (non supportés par dash 4.4).
@@ -0,0 +1,200 @@
# Refonte du système de votes de la roadmap
**Date :** 2026-07-14
**Statut :** proposé (en attente de validation)
## 1. Contexte et objectifs
Les abonné·es votent pour les fonctionnalités à développer en priorité, sur la
page `/compte/roadmap`. La liste des features « au vote » vient des issues GitHub
portant le label `mis au vote` ; elle s'allonge quand une idée est validée et se
raccourcit quand une feature entre en développement (label `en cours`).
Objectifs du responsable projet pour le mécanisme de vote :
1. **Favoriser les utilisateurs fréquents** (qui utilisent l'appli) plutôt que
les visiteurs ponctuels.
2. **Donner une impression d'impact réel** : pouvoir exprimer qu'une feature est
essentielle et une autre secondaire.
3. **Empêcher qu'un seul utilisateur fasse basculer le destin d'une feature** en y
concentrant tous ses votes.
4. **Rester lisible** — une bonne signalétique peut porter un système un peu
subtil, mais le principe doit s'expliquer en une phrase.
**Inconnue structurante :** le nombre de votants. Le problème est bien plus simple
à 50 votants qu'à 5. Cette inconnue est traitée par une précondition de
déploiement (§5), pas par le mécanisme lui-même.
## 2. Système actuel
- **Solde** : chaque abonné actif a un solde plafonné à `VOTES_PER_WEEK = 3`,
rechargé paresseusement de +3 par **semaine glissante de 7 jours propre à chaque
user** (`credit_pending` dans `src/subscriptions/db.py`), **sans accumulation**
(cap à 3).
- **Dépense** : voter = `spend_vote` (débit de 1 si solde > 0) + `record_vote`
(insertion d'une ligne dans `feature_votes`). **Aucune limite par feature** : les
3 votes peuvent aller sur la même.
- **Classement** : `vote_counts` = `COUNT(*)` par `issue_number`, **sur toute
l'histoire**, sans fenêtre temporelle.
Deux problèmes :
- **Comptage cumulatif à vie.** Une feature présente depuis 10 semaines a accumulé
des votes qu'une feature ajoutée cette semaine ne rattrapera jamais. Le classement
mesure autant l'ancienneté dans la liste que la popularité — ce qui mine le
« consensus fidèle » recherché. C'est le problème principal.
- **Objectifs 2 et 3 en tension.** Exprimer l'intensité = pouvoir concentrer ;
empêcher un seul de basculer = empêcher la concentration. Aucun mécanisme ne
satisfait les deux à 100 % ; il s'agit de choisir où placer le curseur.
## 3. Décisions de conception
### 3.1 Saison mensuelle (« championnat »)
Le vote fonctionne par **saisons = mois calendaires**. Le classement d'une feature
n'agrège que **ses votes du mois courant**. Au changement de mois, les compteurs
repartent de zéro et le ballot est renouvelé.
- Résout le comptage cumulatif : dans une saison, N est figé et toutes les features
ont couru la même distance ; on ne compare pas les totaux d'un mois à l'autre
(chaque mois est son propre championnat, on en tire le·s vainqueur·s).
- **Le ballot ne change qu'au 1er** : une idée validée le 3 attend le 1er suivant
(délai ≤ ~4 semaines, acceptable pour un rythme de roadmap). Convention opérateur
sur les labels GitHub (voir §4.3).
### 3.2 Recharge hebdomadaire, lundi, sans report
Le solde reste de **3 votes**, rechargé **chaque lundi à 00h00 (Europe/Paris),
sans report** (cap à 3, use-it-or-lose-it). Cadence **globale calendaire** — « chaque
lundi, tout le monde retrouve ses votes » — remplaçant le timer glissant par-user.
- C'est le rechargement **hebdomadaire** (et non un budget mensuel donné d'un coup)
qui porte l'**objectif 1** : un visiteur ponctuel du 28 ne peut pas rattraper un
habitué qui revient chaque semaine.
- 4 ou 5 lundis par mois → 12 ou 15 votes/mois selon les mois. Non problématique :
tous les votants d'un mois ont les mêmes lundis, et on ne compare pas d'un mois à
l'autre.
### 3.3 Aucun cap par feature (option A)
Un utilisateur peut concentrer tout son budget mensuel (~12 votes) sur une seule
feature.
**Justification vis-à-vis de l'objectif 3 :** à faible nombre de votants, _aucune_
valeur de cap ne règle vraiment le risque qu'un seul fasse basculer une feature —
ce qui le règle, c'est le **nombre de votants** (la part d'un individu se dilue quand
V augmente, pas quand N change). Plutôt qu'un demi-garde-fou peu lisible, on neutralise
le risque à la source via la précondition de déploiement (§5). Le cap par feature
(plafonner à ~3 votes/feature/saison) reste une **option de durcissement future** si
le whale redevient un problème à l'échelle.
### 3.4 Horloges découplées
Le passage de saison (1er) **ne touche pas le solde** ; seul le lundi le recharge.
Conséquence assumée : quand le 1er ne tombe pas un lundi, un user qui a gardé ses
votes les reporte (≤ 3) sur la nouvelle saison, et un user qui a vidé son solde attend
le lundi suivant. Cohérent avec le use-it-or-lose-it hebdo.
## 4. Conception technique
Principe directeur : **tout est dérivé du calendrier, aucun cron.** Ni la remise à
zéro de saison ni le rechargement n'exigent de tâche planifiée. **Aucune migration
de schéma** n'est nécessaire — uniquement de la logique.
### 4.1 Saison = fenêtre sur `created_at`
`feature_votes` porte déjà `created_at`. Le classement se restreint au mois courant :
- Ajouter un helper `season_start(now) -> datetime` = 1er du mois courant à 00h00
Europe/Paris, converti en UTC.
- `vote_counts()` (`src/roadmap/db.py`) ajoute `WHERE created_at >= :season_start`.
Les `created_at` sont stockés en ISO UTC (`...+00:00`) ; comparer avec
`season_start.astimezone(timezone.utc).isoformat()` (comparaison lexicographique ISO
valide car même format/offset).
Aucun effacement : au changement de mois, la fenêtre glisse et les votes du mois
précédent sortent du décompte (ils restent en base comme historique ; purge
éventuelle plus tard).
### 4.2 Recharge = lundi calendaire, paresseux
Réécrire `credit_pending` et `next_recharge_at` (`src/subscriptions/db.py`) autour du
lundi Europe/Paris au lieu de `WEEK_SECONDS` :
- `last_monday(now) -> datetime` = lundi 00h00 Europe/Paris le plus récent (UTC).
- `next_monday(now) -> datetime` = lundi 00h00 Europe/Paris suivant (UTC) — pour
l'affichage « rechargement le … ».
- `credit_pending(user_id)` :
- garde la garde `status == "active"` (les essais ne votent pas).
- première activation (`votes_last_credited_at is None`) → `balance = INITIAL_VOTES`,
cursor = `last_monday(now)`.
- sinon, si `votes_last_credited_at < last_monday(now)``balance = VOTES_PER_WEEK`
(on **fixe** à 3, pas d'ajout : ni report ni accumulation des lundis manqués),
cursor = `last_monday(now)`. Idempotent : sans effet si déjà crédité pour ce lundi.
- `next_recharge_at(user_id)``next_monday(now)` (indépendant du cursor).
- `WEEK_SECONDS` devient inutile (à retirer).
`zoneinfo.ZoneInfo("Europe/Paris")` pour les bornes locales ; cursors stockés en UTC.
### 4.3 Appartenance au ballot
Source de vérité inchangée : les labels GitHub (`fetch_roadmap_issues`). **Convention
opérateur : ne re-labelliser (`mis au vote` / `en cours`) qu'au 1er du mois**, pour que
le ballot reste figé pendant la saison.
_Fragilité connue :_ une re-labellisation en cours de mois ferait apparaître/disparaître
une feature en pleine saison (une nouveauté démarrerait à 0 face à des features déjà
votées). Acceptable en v1 (Colin est seul opérateur). **Durcissement futur possible :**
snapshoter les `issue_number` au vote en début de saison dans une table dédiée.
### 4.4 Déploiement des données existantes
Au déploiement, les lignes `feature_votes` accumulées à vie restent en base mais seules
celles du mois courant comptent → le classement « se réinitialise » de fait sur le mois
en cours. Comportement désiré, aucune action de données requise.
### 4.5 UI
- `src/roadmap/ui.py` : le libellé du solde peut évoquer le rechargement hebdo
(« rechargement le lundi ») et, si utile, la logique de saison. `_balance_item`
affiche déjà `next_recharge`.
- Signalétique à prévoir (texte de `roadmap_content`) : expliquer le championnat mensuel
et les 3 votes/semaine. Détails de rédaction hors périmètre de cette spec.
### 4.6 Ce qui ne change pas
`spend_vote`, `record_vote`, le flux du bouton « + » (`cast_vote`), la source GitHub des
issues, le schéma SQLite.
## 5. Précondition de déploiement
**Ce système n'entre pas en production tant que le nombre d'utilisateurs votant
(système actuel) n'a pas dépassé un seuil** défini par le responsable projet. En
dessous du seuil, on conserve le système actuel.
C'est le levier qui remplace le cap par feature pour l'objectif 3 : on n'ouvre le vote
concentré-libre que lorsqu'il y a assez de votants pour diluer un dumpeur. Le seuil est
un go/no-go opérateur, **pas** un paramètre de code. Pour l'évaluer : nombre de
`user_id` distincts ayant voté sur la dernière saison, ou nombre d'abonnés actifs
pouvant voter.
## 6. Tests
Cibler `tests/subscriptions/test_db.py` et `tests/roadmap/` :
- **Saison** : un vote daté du mois précédent n'est pas compté ; un vote du mois courant
l'est ; bornes autour du 1er 00h00 Europe/Paris.
- **Recharge** : franchissement d'un lundi → solde fixé à 3 (pas 3+report) ; pas de
re-crédit le même lundi (idempotence) ; première activation → `INITIAL_VOTES` ;
`next_recharge_at` = prochain lundi.
- **Fuseau** : un vote/rechargement autour de minuit tombe du bon côté en Europe/Paris
(vérifier notamment l'heure d'été).
- **Découplage** : un passage de mois ne modifie pas le solde.
## 7. Points différés
- Valeur exacte du seuil de déploiement (§5).
- Snapshot du ballot en début de saison pour figer strictement l'appartenance (§4.3).
- Rédaction de la signalétique utilisateur (§4.5).
- Purge éventuelle des vieilles lignes `feature_votes`.
- Cap par feature comme durcissement si le whale réapparaît à l'échelle.
@@ -0,0 +1,34 @@
# Widget de chat Chatwoot (essai) — issue #120
## Contexte
On teste Chatwoot (solution managée, offre d'essai) comme canal de support par
chat. Pas de mail/voix pour l'instant, juste le widget de chat embarqué.
## Design
Suivre le pattern déjà utilisé pour Matomo dans `src/app.py` : injecter le
script d'intégration directement dans `app.index_string`, juste avant
`</body>`.
- Nouvelle variable d'env `CHATWOOT_WEBSITE_TOKEN` (ajoutée à `.template.env`,
vide/commentée par défaut).
- Dans `src/app.py`, lire `chatwoot_token = os.getenv("CHATWOOT_WEBSITE_TOKEN")`
avant la construction de `index_string`.
- Construire le bloc `<script>` Chatwoot conditionnellement : si le token est
absent/vide, le bloc est omis — le widget ne se charge pas. Ça sert à la
fois d'interrupteur (désactivable sans déploiement le temps de l'essai) et
garantit que les tests/CI (qui ne définissent pas cette variable) ne
chargent jamais le widget.
- Interpoler ce bloc dans `index_string` via f-string, à côté du script
Matomo existant.
- `baseUrl` reste codé en dur sur `https://app.chatwoot.com` (offre managée,
comme spécifié dans l'issue) — pas besoin d'une variable pour une URL
self-hosted à ce stade.
## Hors scope
- Auto-hébergement Chatwoot
- Support par mail/voix
- Personnalisation du widget (langue, position, couleurs)
- Affichage conditionnel par page ou par statut de connexion
+189
View File
@@ -0,0 +1,189 @@
# Spécifications fonctionnelles et techniques d'un profil d'acheteur
> Source : [Arrêté du 22 mars 2019](https://www.legifrance.gouv.fr/loda/id/JORFTEXT000038318516) relatif aux fonctionnalités et exigences minimales des profils d'acheteurs (NOR : ECOM1831551A). Annexe 7 du Code de la commande publique. En vigueur depuis le 1er avril 2019.
## 1. Fonctionnalités pour l'acheteur (marchés publics)
_Réf. : Article 1, I_
Le profil d'acheteur doit permettre à l'acheteur de :
| ID | Fonctionnalité | Description |
| ------ | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ACH-01 | Identification et authentification | S'identifier et s'authentifier sur la plateforme |
| ACH-02 | Publication d'avis | Publier des avis d'appel à la concurrence et leurs éventuelles modifications |
| ACH-03 | Mise à disposition des DCE | Mettre à disposition des documents de la consultation |
| ACH-04 | Réception des candidatures | Réceptionner et conserver des candidatures, y compris sous forme de DUME (Document Unique de Marché Européen) électronique (échange de données structurées) |
| ACH-05 | Réception des offres | Réceptionner et conserver des offres, y compris hors délais |
| ACH-06 | Données essentielles | Compléter un formulaire de publication des données essentielles (DECP), ou importer ces données depuis un autre SI |
| ACH-07 | Courrier électronique | Accéder à un service de courrier électronique (au sens de l'article 1 de la loi n° 2004-575) |
| ACH-08 | Historique et traçabilité | Accéder à un historique des événements : enregistrement et traçabilité des actions (retrait, dépôt de documents, etc.) |
| ACH-09 | Réponses aux questions | Répondre aux questions soumises par les entreprises |
| ACH-10 | Justificatifs et preuves | Obtenir les documents justificatifs et moyens de preuve directement auprès d'autres administrations lorsque c'est possible |
## 2. Fonctionnalités pour l'opérateur économique (marchés publics)
_Réf. : Article 1, II_
Le profil d'acheteur doit permettre à l'opérateur économique de :
| ID | Fonctionnalité | Description |
| ----- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OE-01 | Identification et authentification | S'identifier et s'authentifier sur la plateforme |
| OE-02 | Prérequis techniques | Connaître les prérequis techniques et les modules d'extension nécessaires pour utiliser le profil d'acheteur |
| OE-03 | Test de configuration | Accéder à un espace de test permettant de vérifier l'adéquation de la configuration du poste de travail avec les prérequis techniques |
| OE-04 | Recherche | Effectuer une recherche donnant accès aux avis d'appel à la concurrence, aux consultations et aux données essentielles |
| OE-05 | Consultation des documents | Consulter et télécharger en accès gratuit, libre, direct et complet les documents de la consultation, les avis d'appel à la concurrence et leurs modifications |
| OE-06 | Simulation de dépôt | Accéder à un espace permettant de simuler le dépôt de documents |
| OE-07 | Dépôt de candidature | Déposer une candidature, y compris sous forme de DUME électronique (données structurées) |
| OE-08 | Dépôt d'offres | Déposer des offres, y compris les dépôts successifs (quand la procédure le requiert) et les offres signées électroniquement |
| OE-09 | Assistance | Solliciter une assistance ou consulter un support utilisateur pour les problématiques techniques |
| OE-10 | Questions à l'acheteur | Formuler des questions à l'acheteur |
| OE-11 | Données essentielles | Consulter et télécharger les données essentielles de la commande publique |
## 3. Exigences techniques, de sécurité et d'accessibilité (marchés publics)
_Réf. : Article 2_
### 3.1. Conformité aux référentiels
| ID | Exigence | Référentiel |
| ------ | ---------------- | ------------------------------------------------------------------------------------------------ |
| SEC-01 | Sécurité | Conformité au Référentiel Général de Sécurité (RGS) — art. 9 de l'ordonnance n° 2005-1516 |
| SEC-02 | Interopérabilité | Conformité au Référentiel Général d'Interopérabilité (RGI) — art. 9 de l'ordonnance n° 2005-1516 |
| SEC-03 | Accessibilité | Conformité au Référentiel Général d'Accessibilité (RGAA) — art. 11 de l'ordonnance n° 2005-1516 |
### 3.2. Exigences techniques
| ID | Exigence | Description |
| ------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TECH-01 | Formats de fichiers | Accepter les fichiers communément disponibles, et notamment les formats `.XML` et `.JSON` |
| TECH-02 | Information sur les formats | Indiquer la taille et les formats des documents et avis d'appel à la concurrence |
| TECH-03 | Horodatage qualifié | L'horodatage doit être qualifié conformément au règlement eIDAS (n° 910/2014) |
| TECH-04 | Intégrité des données | Assurer l'intégrité des données |
| TECH-05 | Responsive design | Permettre une visualisation adaptée au média utilisé (responsive) |
| TECH-06 | Confidentialité des soumissions | Garantir la confidentialité des candidatures, offres et demandes de participation jusqu'à l'expiration du délai de soumission. Les documents sont inaccessibles avant cette date, puis accessibles uniquement aux personnes autorisées. Recours obligatoire à des moyens de cryptologie, de gestion des droits d'accès/privilèges, ou technique équivalente |
| TECH-07 | Interopérabilité | Être interopérable avec les autres outils et dispositifs de communication électronique et d'échanges d'informations utilisés dans le cadre de la commande publique |
### 3.3. Accusé de réception
_Réf. : Article 2, III_
Chaque dépôt de documents par un opérateur économique doit déclencher **immédiatement** l'envoi d'un accusé de réception automatique contenant :
| ID | Champ | Description |
| ----- | -------------------------- | -------------------------------------------------------- |
| AR-01 | Identification du déposant | Identification de l'opérateur économique auteur du dépôt |
| AR-02 | Nom de l'acheteur | Nom de l'acheteur public |
| AR-03 | Consultation | Intitulé et objet de la consultation concernée |
| AR-04 | Horodatage | Date et heure de réception des documents |
| AR-05 | Liste des documents | Liste détaillée des documents transmis |
## 4. Fonctionnalités spécifiques aux concessions
_Réf. : Article 3_
### 4.1. Fonctionnalités pour l'autorité concédante
| ID | Fonctionnalité | Description |
| ----------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| CONC-ACH-01 | Identification et authentification | S'identifier et s'authentifier |
| CONC-ACH-02 | Mise à disposition des DCE | Mettre à disposition des documents de la consultation |
| CONC-ACH-03 | Réception des candidatures | Réceptionner et conserver des candidatures |
| CONC-ACH-04 | Réception des offres | Réceptionner et conserver des offres, y compris hors délais |
| CONC-ACH-05 | Données essentielles | Compléter un formulaire de publication des données essentielles ou importer ces données depuis un autre SI |
| CONC-ACH-06 | Historique et traçabilité | Accéder à un historique des événements : enregistrement et traçabilité des actions (retrait, dépôt de documents, etc.) |
### 4.2. Fonctionnalités pour l'opérateur économique (concessions)
| ID | Fonctionnalité | Description |
| ---------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- |
| CONC-OE-01 | Identification et authentification | S'identifier et s'authentifier |
| CONC-OE-02 | Prérequis techniques | Connaître les prérequis techniques et modules d'extension nécessaires |
| CONC-OE-03 | Test de configuration | Accéder à un espace de test de la configuration du poste de travail |
| CONC-OE-04 | Recherche | Effectuer une recherche donnant accès aux consultations et aux données essentielles |
| CONC-OE-05 | Consultation des documents | Consulter et télécharger en accès gratuit, libre, direct et complet les documents de la consultation |
| CONC-OE-06 | Simulation de dépôt | Accéder à un espace de simulation de dépôt de documents |
| CONC-OE-07 | Dépôt de candidature | Déposer une candidature |
| CONC-OE-08 | Dépôt d'offres | Déposer des offres |
| CONC-OE-09 | Assistance | Solliciter une assistance ou consulter un support utilisateur |
| CONC-OE-10 | Données essentielles | Consulter et télécharger les données essentielles |
### 4.3. Exigences techniques (concessions)
| ID | Exigence | Description |
| ----------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CONC-SEC-01 | Confidentialité | Garantir la confidentialité des candidatures, offres et demandes de participation jusqu'à expiration du délai. Recours à la cryptologie, gestion des droits d'accès, ou technique équivalente |
| CONC-SEC-02 | Intégrité | Assurer l'intégrité des données |
| CONC-SEC-03 | Responsive design | Permettre une visualisation adaptée au média utilisé |
| CONC-SEC-04 | Conformité aux référentiels | Conformité au RGS, RGI et RGAA (art. 9 et 11 de l'ordonnance n° 2005-1516) |
### 4.4. Accusé de réception (concessions)
Mêmes exigences que pour les marchés publics (section 3.3), à l'exception du champ AR-02 qui mentionne le nom de **l'autorité concédante** au lieu de l'acheteur public.
## 5. Référencement du profil d'acheteur
_Réf. : Article 4_
### 5.1. Publication
Le profil d'acheteur doit figurer sur une liste publiée sur le portail unique interministériel de données ouvertes (data.gouv.fr).
### 5.2. Identification
Chaque profil d'acheteur est identifié par :
| ID | Champ | Description |
| ------ | ------------- | ------------------------------------------------------ |
| REF-01 | SIRET | Numéro SIRET de l'acheteur |
| REF-02 | URL du profil | Adresse URL du profil d'acheteur |
| REF-03 | URL du DCAT | Adresse URL du catalogue DCAT des données essentielles |
| REF-04 | Coordonnées | Coordonnées du ou des acheteurs concernés |
### 5.3. Déclaration
La déclaration du profil est effectuée par l'acheteur ou une personne habilitée sur le portail de données ouvertes. Elle comporte :
- L'identité du déclarant
- L'identité de l'organisme chargé de la gestion du profil d'acheteur
- L'adresse URL du profil d'acheteur
- L'adresse URL du DCAT
- Les coordonnées du ou des acheteurs concernés
## 6. Dispositions particulières outre-mer
_Réf. : Article 5_
| Territoire | Adaptation |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Saint-Barthélemy, Saint-Pierre-et-Miquelon | Horodatage qualifié conformément aux dispositions applicables en métropole en vertu du règlement eIDAS |
| Nouvelle-Calédonie, Polynésie française | Horodatage qualifié conformément aux dispositions applicables localement. Applicable uniquement aux contrats de l'État et de ses établissements publics |
| Wallis-et-Futuna, TAAF | Horodatage qualifié conformément aux dispositions applicables en métropole en vertu du règlement eIDAS. Applicable uniquement aux contrats de l'État et de ses établissements publics |
## 7. Matrice de correspondance fonctionnelle
Récapitulatif des fonctionnalités par type de contrat et rôle utilisateur :
| Fonctionnalité | Acheteur (marchés) | OE (marchés) | Autorité concédante | OE (concessions) |
| ------------------------------------------ | :----------------: | :----------: | :-----------------: | :--------------: |
| Authentification | x | x | x | x |
| Publication d'avis | x | | | |
| Mise à disposition DCE | x | | x | |
| Consultation/téléchargement DCE | | x | | x |
| Réception candidatures | x | | x | |
| Dépôt candidature (dont DUME) | | x | | x |
| Réception offres | x | | x | |
| Dépôt offres (dont signature électronique) | | x | | x |
| Données essentielles (saisie/import) | x | | x | |
| Données essentielles (consultation) | | x | | x |
| Courrier électronique | x | | | |
| Historique/traçabilité | x | | x | |
| Réponse aux questions | x | | | |
| Questions à l'acheteur | | x | | |
| Justificatifs via autres administrations | x | | | |
| Prérequis techniques | | x | | x |
| Test de configuration | | x | | x |
| Recherche | | x | | x |
| Simulation de dépôt | | x | | x |
| Assistance/support | | x | | x |
+26 -5
View File
@@ -1,12 +1,11 @@
[project] [project]
name = "decp.info" name = "colibre"
description = "Interface d'exploration et d'analyse des marchés publics français." description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.8.1" version = "3.0.0"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }]
dependencies = [ dependencies = [
"dash==3.4.0", "dash[compress]==4.4.0",
"dash[compress]",
"polars", "polars",
"gunicorn", "gunicorn",
"dash-bootstrap-components", "dash-bootstrap-components",
@@ -21,9 +20,18 @@ dependencies = [
"duckdb", "duckdb",
"flask-caching", "flask-caching",
"pyarrow>=23.0.1", "pyarrow>=23.0.1",
"flask-login",
"brevo-python==5.0.0rc1",
"flask-wtf",
"email-validator",
"authlib",
"orjson",
"flask-cors>=6.0.2", "flask-cors>=6.0.2",
"flask-smorest>=0.46.0", "flask-smorest>=0.46.0",
"marshmallow>=3.20.0", "marshmallow>=3.20.0",
"boto3",
"cryptography",
"dash-ag-grid>=35.2.0",
] ]
[dependency-groups] [dependency-groups]
@@ -33,8 +41,10 @@ dev = [
"pre-commit", "pre-commit",
"selenium", "selenium",
"webdriver-manager", "webdriver-manager",
"dash[testing]", "dash[testing]==4.4.0",
"fastexcel", "fastexcel",
"openpyxl",
"moto[s3]",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]
@@ -42,11 +52,22 @@ pythonpath = ["src"]
testpaths = ["tests"] testpaths = ["tests"]
env = [ env = [
"DATA_FILE_PARQUET_PATH=tests/test.parquet", "DATA_FILE_PARQUET_PATH=tests/test.parquet",
"CACHE_DIR=tests/cache",
"DEVELOPMENT=true", "DEVELOPMENT=true",
"REBUILD_DUCKDB=true", "REBUILD_DUCKDB=true",
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json", "DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
"USERS_DB_PATH=tests/users.test.sqlite", "USERS_DB_PATH=tests/users.test.sqlite",
"SECRET_KEY=test-secret-do-not-use-in-prod",
"MAIL_FROM=test@colibre.fr",
"MAIL_SUPPRESS_SEND=true",
"APP_BASE_URL=http://localhost:8050",
"SMTP_HOST=localhost",
"SMTP_PORT=25",
"WTF_CSRF_ENABLED=False",
"MATOMO_TRACKING_ENABLED=false", "MATOMO_TRACKING_ENABLED=false",
"DATA_SCHEMA_LOCAL=/home/colin/git/decp-processing/dist/schema.json", "DATA_SCHEMA_LOCAL=/home/colin/git/decp-processing/dist/schema.json",
] ]
addopts = "-p no:warnings" addopts = "-p no:warnings"
markers = [
"integration: tests touchant des services externes (Brevo) ; skippés par défaut en CI",
]
+197
View File
@@ -0,0 +1,197 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "c5b66458e29113e9",
"metadata": {},
"outputs": [],
"source": [
"import polars as pl\n",
"\n",
"pl.Config(\n",
" fmt_str_lengths=120,\n",
" fmt_table_cell_list_len=50,\n",
" set_tbl_rows=100,\n",
" tbl_cols=-1,\n",
")\n",
"\n",
"for methode_dividendes in (\"flat\", \"progressif\"):\n",
" df: pl.DataFrame = pl.DataFrame()\n",
"\n",
" for i in range(0, 11):\n",
" benefice_reel = 3000\n",
" ca = 50000\n",
" depenses_pro = 5000\n",
" remuneration_plus_is = ca - depenses_pro - benefice_reel\n",
" is_minimal = benefice_reel * 0.15\n",
"\n",
" salaires_brut = (remuneration_plus_is - is_minimal) * i / 10\n",
"\n",
" def taux_dividendes():\n",
" taux = {}\n",
" if methode_dividendes == \"flat\":\n",
" taux = {\"retenue_source\": 0.7, \"abattement\": 0}\n",
" elif methode_dividendes == \"progressif\":\n",
" taux = {\"retenue_source\": 0.828, \"abattement\": 0.6}\n",
" return taux\n",
"\n",
" def calcul_cout_dividendes():\n",
" return remuneration_plus_is - salaires_brut - is_minimal\n",
"\n",
" def calcul_dividendes_bruts():\n",
" return calcul_cout_dividendes() / 1.15\n",
"\n",
" def calcul_cout_remuneration():\n",
" return calcul_dividendes_bruts() * 1.15 + salaires_brut\n",
"\n",
" def calcul_dividendes_net():\n",
" dividendes_bruts = calcul_dividendes_bruts()\n",
" dividendes_net = dividendes_bruts * taux_dividendes()[\"retenue_source\"]\n",
" return dividendes_net\n",
"\n",
" def calcul_salaires_net():\n",
" return salaires_brut * 0.563\n",
"\n",
" def calcul_revenu_imposable():\n",
" return (calcul_salaires_net() * 0.9) + (\n",
" calcul_dividendes_net() * taux_dividendes()[\"abattement\"]\n",
" )\n",
"\n",
" def calcul_revenu_net():\n",
" return calcul_salaires_net() + calcul_dividendes_net()\n",
"\n",
" def calcul_benefice():\n",
" return ca - depenses_pro - salaires_brut\n",
"\n",
" def calcul_is():\n",
" return (calcul_benefice() - is_minimal) * 0.15\n",
"\n",
" def calcul_ir(revenu):\n",
" tranches_ir = [[11488, 0], [29315, 0.11], [83283, 0.3]]\n",
" revenu_restant = revenu\n",
" ir = 0\n",
" for tranche in tranches_ir:\n",
" if revenu_restant > 0:\n",
" ir_tranche = min(revenu_restant, tranche[0]) * tranche[1]\n",
" ir = +ir_tranche\n",
" revenu_restant = revenu_restant - tranche[0]\n",
"\n",
" return ir\n",
"\n",
" def calcul_revenu_ae():\n",
" apres_cotisations = calcul_cout_remuneration() * 0.76 - depenses_pro\n",
" impots = calcul_ir(apres_cotisations * 0.66)\n",
" return apres_cotisations - impots\n",
"\n",
" def fmt(value):\n",
" return str(int(value))\n",
"\n",
" # print(salaires_brut)\n",
" # print(calcul_cout_dividendes())\n",
" # print(calcul_dividendes_bruts())\n",
" # print(calcul_is())\n",
" # print(\"\")\n",
" # print(\"\")\n",
" #\n",
" # print(salaires_brut + calcul_dividendes_bruts() + calcul_is(), \" == \", remuneration_plus_is)\n",
"\n",
" # assert salaires_brut + calcul_dividendes_bruts() + calcul_is() == remuneration_plus_is\n",
"\n",
" row: dict = {\n",
" \"Salaires bruts\": [fmt(salaires_brut / 12)],\n",
" \"Dividendes bruts\": [fmt(calcul_cout_dividendes() / 12)],\n",
" \"Benefice\": [fmt(calcul_benefice())],\n",
" \"Benefice réél\": [\n",
" fmt(calcul_benefice() - calcul_is() - calcul_dividendes_bruts())\n",
" ],\n",
" \"IS\": [fmt(calcul_is())],\n",
" \"IR\": [fmt(calcul_ir(calcul_revenu_imposable()) / 12)],\n",
" \"IS + IR\": [fmt(calcul_is() + calcul_ir(calcul_revenu_imposable()))],\n",
" \"Salaires net\": [fmt(calcul_salaires_net() / 12)],\n",
" \"Dividendes net\": [fmt(calcul_dividendes_net() / 12)],\n",
" \"Revenu net ap. impôts\": [\n",
" fmt((calcul_revenu_net() - calcul_ir(calcul_revenu_imposable())) / 12)\n",
" ],\n",
" \"Revenue AE ap. impôts\": [fmt(calcul_revenu_ae() / 12)],\n",
" }\n",
"\n",
" df = pl.concat([df, pl.from_dict(row)])\n",
"\n",
" print(df)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "initial_id",
"metadata": {
"collapsed": true
},
"outputs": [],
"source": [
"from dash import ALL, Dash, Input, Output, Patch, State, callback, dcc, html\n",
"\n",
"app = Dash()\n",
"\n",
"app.layout = html.Div(\n",
" [\n",
" html.Button(\"Add Filter\", id=\"add-filter-btn\", n_clicks=0),\n",
" html.Div(id=\"dropdown-container-div\", children=[]),\n",
" html.Div(id=\"dropdown-container-output-div\"),\n",
" ]\n",
")\n",
"\n",
"\n",
"@callback(\n",
" Output(\"dropdown-container-div\", \"children\"), Input(\"add-filter-btn\", \"n_clicks\")\n",
")\n",
"def display_dropdowns(n_clicks):\n",
" patched_children = Patch()\n",
" new_dropdown = dcc.Dropdown(\n",
" [\"NYC\", \"MTL\", \"LA\", \"TOKYO\"],\n",
" id={\"type\": \"city-filter-dropdown\", \"index\": n_clicks},\n",
" )\n",
" patched_children.append(new_dropdown)\n",
" return patched_children\n",
"\n",
"\n",
"@callback(\n",
" Output(\"dropdown-container-output-div\", \"children\"),\n",
" Input({\"type\": \"city-filter-dropdown\", \"index\": ALL}, \"value\"),\n",
" State({\"type\": \"city-dynamic-dropdown\", \"index\": ALL}, \"id\"),\n",
")\n",
"def display_output(values, ids):\n",
" return html.Div(\n",
" [html.Div(f\"Dropdown {i + 1} = {value}\") for (i, value) in enumerate(values)]\n",
" + [html.P(ids)],\n",
" )\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" app.run(debug=True)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 2
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython2",
"version": "2.7.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
View File
+26
View File
@@ -0,0 +1,26 @@
import sqlite3
from datetime import datetime, timezone
from src.auth.db import get_conn
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def log_action(
admin_email: str, action: str, target_user_id: int | None, details: str | None
) -> None:
get_conn().execute(
"INSERT INTO admin_actions (admin_email, action, target_user_id, details, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(admin_email, action, target_user_id, details, _now()),
)
def list_actions(limit: int = 200) -> list[sqlite3.Row]:
return (
get_conn()
.execute("SELECT * FROM admin_actions ORDER BY id DESC LIMIT ?", (limit,))
.fetchall()
)
+12
View File
@@ -0,0 +1,12 @@
import os
from flask_login import current_user
def is_admin() -> bool:
admin_email = os.getenv("ADMIN_EMAIL")
return bool(
admin_email
and current_user.is_authenticated
and current_user.email.lower() == admin_email.lower()
)
+187
View File
@@ -0,0 +1,187 @@
import sqlite3
from dataclasses import dataclass
from typing import Callable
from src.auth.db import get_conn
from src.subscriptions.db import SUBSCRIPTION_STATUSES
from src.subscriptions.plans import PLANS
@dataclass(frozen=True)
class TableConfig:
columns: list[str]
editable_columns: frozenset[str]
pk: str
column_types: dict[str, type]
dropdowns: dict[str, list[str]]
target_user_id: Callable[[dict], int | None]
sort_by: str
join_user_email: bool = False
TABLES: dict[str, TableConfig] = {
"users": TableConfig(
columns=[
"id",
"email",
"email_verified",
"siret",
"pending_email",
"created_at",
"updated_at",
],
editable_columns=frozenset(
{"email", "email_verified", "siret", "pending_email"}
),
pk="id",
column_types={
"email": str,
"email_verified": int,
"siret": str,
"pending_email": str,
},
dropdowns={"email_verified": ["0", "1"]},
target_user_id=lambda row: row["id"],
sort_by="updated_at",
),
"subscriptions": TableConfig(
columns=[
"id",
"user_id",
"email",
"frisbii_customer_handle",
"frisbii_subscription_handle",
"plan",
"prix_ht",
"status",
"current_period_end",
"created_at",
"updated_at",
],
editable_columns=frozenset({"plan", "prix_ht", "status", "current_period_end"}),
pk="id",
column_types={
"plan": str,
"prix_ht": float,
"status": str,
"current_period_end": str,
},
dropdowns={
"status": list(SUBSCRIPTION_STATUSES),
"plan": list(PLANS.keys()),
},
target_user_id=lambda row: row["user_id"],
sort_by="updated_at",
join_user_email=True,
),
"subscriber_state": TableConfig(
columns=[
"user_id",
"email",
"trial_used",
"votes_balance",
"votes_last_credited_at",
"updated_at",
],
editable_columns=frozenset(
{"trial_used", "votes_balance", "votes_last_credited_at"}
),
pk="user_id",
column_types={
"trial_used": int,
"votes_balance": int,
"votes_last_credited_at": str,
},
dropdowns={"trial_used": ["0", "1"]},
target_user_id=lambda row: row["user_id"],
sort_by="updated_at",
join_user_email=True,
),
"admin_actions": TableConfig(
columns=[
"id",
"admin_email",
"action",
"target_user_id",
"details",
"created_at",
],
editable_columns=frozenset(),
pk="id",
column_types={},
dropdowns={},
target_user_id=lambda row: None,
sort_by="created_at",
),
}
def get_rows(table: str) -> list[dict]:
cfg = TABLES[table]
if cfg.join_user_email:
real_cols = [col for col in cfg.columns if col != "email"]
cols_sql = ", ".join(f"{table}.{col}" for col in real_cols)
query = (
f"SELECT {cols_sql}, users.email AS email FROM {table} "
f"LEFT JOIN users ON users.id = {table}.user_id "
f"ORDER BY {table}.{cfg.sort_by} DESC"
)
else:
cols_sql = ", ".join(cfg.columns)
query = f"SELECT {cols_sql} FROM {table} ORDER BY {cfg.sort_by} DESC"
rows = get_conn().execute(query).fetchall()
return [dict(row) for row in rows]
def _coerce_value(table: str, column: str, value):
cfg = TABLES[table]
if column not in cfg.editable_columns:
raise ValueError(f"Colonne non éditable : {column}")
if column in cfg.dropdowns and str(value) not in cfg.dropdowns[column]:
raise ValueError(f"Valeur non autorisée pour {column} : {value!r}")
expected_type = cfg.column_types[column]
try:
if expected_type is int:
return int(value)
if expected_type is float:
return float(value)
return str(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Valeur invalide pour {column} : {value!r}") from exc
def set_cell(table: str, pk_value, column: str, value) -> None:
if table not in TABLES:
raise ValueError(f"Table inconnue : {table}")
cfg = TABLES[table]
coerced = _coerce_value(table, column, value)
try:
cursor = get_conn().execute(
f"UPDATE {table} SET {column} = ? WHERE {cfg.pk} = ?", (coerced, pk_value)
)
except sqlite3.IntegrityError as exc:
raise ValueError(
f"Écriture refusée pour {column} (contrainte violée) : {value!r}"
) from exc
if cursor.rowcount == 0:
raise ValueError(
f"Ligne introuvable (table={table}, {cfg.pk}={pk_value!r}) — "
"probablement supprimée entre-temps."
)
def find_changed_cell(
data: list[dict], data_previous: list[dict] | None
) -> tuple[int, str, object, object] | None:
if data_previous is None or len(data) != len(data_previous):
return None
if not data or not data_previous:
return None
if set(data[0].keys()) != set(data_previous[0].keys()):
return None
for i, (new_row, old_row) in enumerate(zip(data, data_previous)):
for col, new_val in new_row.items():
old_val = old_row.get(col)
if new_val != old_val:
return i, col, old_val, new_val
return None
+9 -5
View File
@@ -5,7 +5,15 @@ from src.api import routes
def init_api(server) -> None: def init_api(server) -> None:
"""Enregistre le blueprint d'API privée sur le serveur Flask.""" """Enregistre le blueprint d'API privée sur le serveur Flask."""
server.config.setdefault("API_TITLE", "decp.info API") import os
from src.api import tokens_db, tracking
# Garantit que api_tokens existe avant que apply_pending (init_subscriptions,
# plus tard) ne tente l'ALTER de la migration 0007.
tokens_db.init_schema(os.environ["USERS_DB_PATH"])
server.config.setdefault("API_TITLE", "colibre API")
server.config.setdefault("API_VERSION", "v1") server.config.setdefault("API_VERSION", "v1")
server.config.setdefault("OPENAPI_VERSION", "3.0.3") server.config.setdefault("OPENAPI_VERSION", "3.0.3")
server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1") server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1")
@@ -27,8 +35,4 @@ def init_api(server) -> None:
api = Api(server) api = Api(server)
api.register_blueprint(routes.bp) api.register_blueprint(routes.bp)
import os
from src.api import tracking
tracking.start_worker(os.environ["USERS_DB_PATH"]) tracking.start_worker(os.environ["USERS_DB_PATH"])
+3
View File
@@ -29,6 +29,9 @@ def require_token(fn):
_abort_401("invalid_token") _abort_401("invalid_token")
if row["revoked_at"] is not None: if row["revoked_at"] is not None:
_abort_401("revoked_token") _abort_401("revoked_token")
if row["kind"] == "mcp":
# jeton dédié MCP : non valable sur l'API REST
_abort_401("invalid_token")
g.token_id = row["id"] g.token_id = row["id"]
return fn(*args, **kwargs) return fn(*args, **kwargs)
+6 -2
View File
@@ -7,6 +7,7 @@ OPERATORS = {
"exact", "exact",
"contains", "contains",
"notcontains", "notcontains",
"startswith",
"differs", "differs",
"less", "less",
"greater", "greater",
@@ -181,11 +182,14 @@ def build_where(
where_parts.append(f'"{col}" {op_sql[op]} ?') where_parts.append(f'"{col}" {op_sql[op]} ?')
params.append(v) params.append(v)
elif op == "contains": elif op == "contains":
where_parts.append(f'"{col}" LIKE ?') where_parts.append(f'"{col}" ILIKE ?')
params.append(f"%{v}%") params.append(f"%{v}%")
elif op == "notcontains": elif op == "notcontains":
where_parts.append(f'"{col}" NOT LIKE ?') where_parts.append(f'"{col}" NOT ILIKE ?')
params.append(f"%{v}%") params.append(f"%{v}%")
elif op == "startswith":
where_parts.append(f'"{col}" ILIKE ?')
params.append(f"{v}%")
elif op == "differs": elif op == "differs":
where_parts.append(f'"{col}" IS DISTINCT FROM ?') where_parts.append(f'"{col}" IS DISTINCT FROM ?')
params.append(v) params.append(v)
+30 -22
View File
@@ -1,4 +1,5 @@
from flask import g, request import orjson
from flask import Response, g, request
from flask_smorest import Blueprint, abort from flask_smorest import Blueprint, abort
from src.api import tracking from src.api import tracking
@@ -12,7 +13,7 @@ bp = Blueprint(
"api_v1", "api_v1",
"api_v1", "api_v1",
url_prefix="/api/v1", url_prefix="/api/v1",
description="API privée decp.info — accès tabulaire aux marchés publics.", description="API privée colibre — accès tabulaire aux marchés publics.",
) )
MAX_PAGE_SIZE = 1000 MAX_PAGE_SIZE = 1000
@@ -129,7 +130,10 @@ def schema():
"**Filtres** (`<colonne>__<op>=<valeur>`) :\n" "**Filtres** (`<colonne>__<op>=<valeur>`) :\n"
"- `exact` : égal à la valeur\n" "- `exact` : égal à la valeur\n"
"- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n" "- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n"
"- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n" "- `contains` / `notcontains` : contient / ne contient pas "
"(insensible à la casse)\n"
"- `startswith` : commence par (préfixe, insensible à la casse, "
"ex. `codeCPV__startswith=72`)\n"
"- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n" "- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n"
"- `less` / `greater` : ≤ / ≥\n" "- `less` / `greater` : ≤ / ≥\n"
"- `strictly_less` / `strictly_greater` : < / >\n" "- `strictly_less` / `strictly_greater` : < / >\n"
@@ -156,8 +160,9 @@ def data():
"""Récupère des marchés publics filtrés, triés ou agrégés. """Récupère des marchés publics filtrés, triés ou agrégés.
Filtres en query string : `<colonne>__<opérateur>=<valeur>`. Filtres en query string : `<colonne>__<opérateur>=<valeur>`.
Opérateurs de filtre : exact, differs, contains, notcontains, in, notin, Opérateurs de filtre : exact, differs, contains, notcontains, startswith,
less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort. in, notin, less, greater, strictly_less, strictly_greater, isnull,
isnotnull, sort.
Agrégation (drapeaux sans valeur) : `<colonne>__groupby`, Agrégation (drapeaux sans valeur) : `<colonne>__groupby`,
`<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées `<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées
@@ -172,9 +177,6 @@ def data():
Exemple d'agrégation : Exemple d'agrégation :
`?acheteur_departement_code__groupby&uid__count&montant__sum` `?acheteur_departement_code__groupby&uid__count&montant__sum`
""" """
import polars as pl
import polars.selectors as cs
page, page_size = _parse_pagination() page, page_size = _parse_pagination()
columns = _parse_columns() columns = _parse_columns()
count_results = request.args.get("count_results", "true").lower() != "false" count_results = request.args.get("count_results", "true").lower() != "false"
@@ -201,16 +203,20 @@ def data():
limit=page_size, limit=page_size,
offset=(page - 1) * page_size, offset=(page - 1) * page_size,
) )
df_ready = df.with_columns(cs.temporal().cast(pl.String))
# Si la page est partielle, on connaît le total exact ; sinon on ne sait pas. # Si la page est partielle, on connaît le total exact ; sinon on ne sait pas.
agg_total = ( agg_total = (
(page - 1) * page_size + df.height if df.height < page_size else None (page - 1) * page_size + df.height if df.height < page_size else None
) )
return { return Response(
"data": df_ready.to_dicts(), orjson.dumps(
"meta": {"page": page, "page_size": page_size}, {
"links": _build_links(page, page_size, agg_total), "data": df.to_dicts(),
} "meta": {"page": page, "page_size": page_size},
"links": _build_links(page, page_size, agg_total),
}
),
mimetype="application/json",
)
df = query_marches( df = query_marches(
where_sql=where_sql, where_sql=where_sql,
@@ -221,16 +227,18 @@ def data():
offset=(page - 1) * page_size, offset=(page - 1) * page_size,
) )
# JSON ne sérialise pas date/datetime nativement → cast en string ISO
df_ready = df.with_columns(cs.temporal().cast(pl.String))
total = count_marches(where_sql, params) if count_results else None total = count_marches(where_sql, params) if count_results else None
meta = {"page": page, "page_size": page_size} meta = {"page": page, "page_size": page_size}
if total is not None: if total is not None:
meta["total"] = total meta["total"] = total
return { return Response(
"data": df_ready.to_dicts(), orjson.dumps(
"meta": meta, {
"links": _build_links(page, page_size, total), "data": df.to_dicts(),
} "meta": meta,
"links": _build_links(page, page_size, total),
}
),
mimetype="application/json",
)
+72 -6
View File
@@ -1,22 +1,28 @@
import base64
import hashlib import hashlib
import os
import secrets import secrets
import sqlite3 import sqlite3
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
TOKEN_PREFIX = "decpinfo_" from cryptography.fernet import Fernet, InvalidToken
TOKEN_PREFIX = "colibre_"
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS api_tokens ( CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
token_enc TEXT,
label TEXT NOT NULL, label TEXT NOT NULL,
user_id INTEGER, user_id INTEGER,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_used_at TEXT, last_used_at TEXT,
count_total INTEGER NOT NULL DEFAULT 0, count_total INTEGER NOT NULL DEFAULT 0,
revoked_at TEXT revoked_at TEXT,
kind TEXT NOT NULL DEFAULT 'api'
); );
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
""" """
@@ -30,6 +36,19 @@ def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest() return hashlib.sha256(token.encode()).hexdigest()
def _fernet_key() -> bytes | None:
"""Clé Fernet dérivée de SECRET_KEY (séparation de domaine).
Renvoie None si SECRET_KEY est absent : le jeton n'est alors pas chiffré
ni -affichable (dégradation propre, sans échec).
"""
secret = os.getenv("SECRET_KEY")
if not secret:
return None
digest = hashlib.sha256(f"mcp-token-enc:{secret}".encode()).digest()
return base64.urlsafe_b64encode(digest)
@contextmanager @contextmanager
def _connect(db_path): def _connect(db_path):
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
@@ -47,13 +66,17 @@ def init_schema(db_path) -> None:
conn.commit() conn.commit()
def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]: def create_token(
db_path, label: str, user_id: int | None = None, kind: str = "api"
) -> tuple[str, int]:
token = TOKEN_PREFIX + secrets.token_hex(32) token = TOKEN_PREFIX + secrets.token_hex(32)
key = _fernet_key()
token_enc = Fernet(key).encrypt(token.encode()).decode() if key else None
with _connect(db_path) as conn: with _connect(db_path) as conn:
cur = conn.execute( cur = conn.execute(
"INSERT INTO api_tokens (token_hash, label, user_id, created_at) " "INSERT INTO api_tokens (token_hash, token_enc, label, user_id, kind, "
"VALUES (?, ?, ?, ?)", "created_at) VALUES (?, ?, ?, ?, ?, ?)",
(_hash(token), label, user_id, _utcnow_iso()), (_hash(token), token_enc, label, user_id, kind, _utcnow_iso()),
) )
conn.commit() conn.commit()
return token, cur.lastrowid return token, cur.lastrowid
@@ -68,6 +91,28 @@ def get_token_by_plaintext(db_path, token: str) -> dict | None:
return dict(row) if row else None return dict(row) if row else None
def get_token_plaintext_for_user(db_path, token_id: int, user_id: int) -> str | None:
"""Déchiffre et renvoie le jeton en clair, uniquement pour son propriétaire.
Renvoie None si : SECRET_KEY absent, jeton inexistant/appartenant à un autre
utilisateur, jamais chiffré (token_enc NULL), ou indéchiffrable (clé changée).
"""
key = _fernet_key()
if key is None:
return None
with _connect(db_path) as conn:
row = conn.execute(
"SELECT token_enc FROM api_tokens WHERE id = ? AND user_id = ?",
(token_id, user_id),
).fetchone()
if row is None or row["token_enc"] is None:
return None
try:
return Fernet(key).decrypt(row["token_enc"].encode()).decode()
except InvalidToken:
return None
def revoke_token(db_path, token_id: int) -> None: def revoke_token(db_path, token_id: int) -> None:
with _connect(db_path) as conn: with _connect(db_path) as conn:
conn.execute( conn.execute(
@@ -92,3 +137,24 @@ def list_tokens(db_path) -> list[dict]:
with _connect(db_path) as conn: with _connect(db_path) as conn:
rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall() rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def list_user_tokens(db_path, user_id: int, kind: str = "mcp") -> list[dict]:
with _connect(db_path) as conn:
rows = conn.execute(
"SELECT * FROM api_tokens WHERE user_id = ? AND kind = ? "
"ORDER BY created_at DESC, id DESC",
(user_id, kind),
).fetchall()
return [dict(r) for r in rows]
def revoke_user_token(db_path, token_id: int, user_id: int) -> bool:
with _connect(db_path) as conn:
cur = conn.execute(
"UPDATE api_tokens SET revoked_at = ? "
"WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
(_utcnow_iso(), token_id, user_id),
)
conn.commit()
return cur.rowcount > 0
+1 -1
View File
@@ -59,7 +59,7 @@ def enqueue_matomo_event(
site_id = os.getenv("MATOMO_SITE_ID") site_id = os.getenv("MATOMO_SITE_ID")
if not url or not site_id: if not url or not site_id:
return return
full_url = f"https://decp.info{path}" full_url = f"https://colibre.fr{path}"
if query_string: if query_string:
full_url += f"?{query_string}" full_url += f"?{query_string}"
params = { params = {
+210 -29
View File
@@ -1,15 +1,41 @@
# ruff: noqa: E402 -- sys.path manipulation must precede third-party imports
import os import os
import sys
from pathlib import Path
from shutil import rmtree from shutil import rmtree
# Sur les serveurs où le package est installé en mode éditable, pip peut ajouter
# src/ à sys.path via un fichier .pth. Combiné à la racine du projet (déjà dans
# sys.path via gunicorn), Dash's use_pages enregistre alors chaque page deux fois :
# une fois comme pages.X et une fois comme src.pages.X → erreur "duplicate paths".
_src_dir = str(Path(__file__).parent.resolve())
while _src_dir in sys.path:
sys.path.remove(_src_dir)
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads
import tomllib import tomllib
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry from dash import (
ALL,
Dash,
Input,
Output,
State,
callback,
ctx,
dcc,
html,
page_container,
page_registry,
)
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, Response from flask import Flask, Response, redirect
from flask_login import current_user
from src.auth.setup import init_auth
from src.utils import DEVELOPMENT from src.utils import DEVELOPMENT
from src.utils.cache import cache from src.utils.cache import cache
from src.utils.chatwoot import build_widget_script
load_dotenv() load_dotenv()
@@ -20,7 +46,7 @@ META_TAGS = [
{"name": "viewport", "content": "width=device-width, initial-scale=1"}, {"name": "viewport", "content": "width=device-width, initial-scale=1"},
{ {
"name": "keywords", "name": "keywords",
"content": "commande publique, decp, marchés publics, données essentielles", "content": "commande publique, decp, marchés publics, données essentielles, colibre",
}, },
] ]
@@ -32,7 +58,7 @@ if DEVELOPMENT:
# fonctions memoizées (@cache.memoize) dès l'import (ex. tableau.py). # fonctions memoizées (@cache.memoize) dès l'import (ex. tableau.py).
server = Flask(__name__) server = Flask(__name__)
cache_dir = os.getenv("CACHE_DIR", "/tmp/decp-cache") cache_dir = os.getenv("CACHE_DIR", "/tmp/colibre-cache")
if os.path.exists(cache_dir): if os.path.exists(cache_dir):
rmtree(cache_dir) rmtree(cache_dir)
@@ -49,52 +75,158 @@ cache.init_app(
}, },
) )
_mcp_enabled = os.getenv("DASH_MCP_ENABLED", "").lower() == "true"
app: Dash = Dash( app: Dash = Dash(
server=server, server=server,
title="decp.info", # name="src" (et non "src.app") pour que use_pages enregistre les pages sous
# le namespace `src.pages.*`, identique aux imports inter-pages (ex.
# inscription.py: `from src.pages.connexion import linkedin_button`). Sinon
# Dash découvre `pages.connexion` tandis que l'import explicite crée
# `src.pages.connexion` : deux identités, même URL → "duplicate paths" →
# check_for_duplicate_pathnames lève à chaque requête → 500 sur toute l'app.
name="src",
title="Colibre",
use_pages=True, use_pages=True,
suppress_callback_exceptions=True,
compress=True, compress=True,
enable_mcp=_mcp_enabled,
meta_tags=META_TAGS, meta_tags=META_TAGS,
) )
init_auth(app.server)
# Exempter les routes internes de Dash de la protection CSRF.
# Dash enregistre ses routes directement sur le serveur Flask (pas via blueprint),
# donc on itère la url_map après init_auth pour cibler les bons endpoints.
from src.auth.setup import _csrf as _auth_csrf # noqa: E402
if _auth_csrf is not None:
for _rule in app.server.url_map.iter_rules():
if _rule.rule.startswith("/_dash") or _rule.rule.startswith("/_reload"):
_vf = app.server.view_functions.get(_rule.endpoint)
if _vf is not None:
_auth_csrf.exempt(_vf)
from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready first from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready first
init_api(app.server) init_api(app.server)
# Serveur MCP (issue #111, scope A) : n'expose QUE les fonctions @mcp_enabled,
# jamais les callbacks/layout/pages d'UI. Activé via DASH_MCP_ENABLED=true.
if _mcp_enabled:
from dash.mcp import configure_mcp_server # noqa: E402
configure_mcp_server(
include_layout=False,
include_callbacks=False,
include_pages=False,
include_clientside_callbacks=False,
)
import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled
# Les routes /_mcp existent maintenant : exempter du CSRF (POST JSON-RPC
# externe sans jeton) puis brancher le garde d'abonnement.
from src.mcp.auth import init_mcp_auth # noqa: E402
if _auth_csrf is not None:
for _rule in app.server.url_map.iter_rules():
if _rule.rule.startswith("/_mcp"):
_vf = app.server.view_functions.get(_rule.endpoint)
if _vf is not None:
_auth_csrf.exempt(_vf)
init_mcp_auth(app.server)
# Serveur d'autorisation OAuth 2.1 (scope B2, #114) : découverte, DCR,
# authorize/token, révocation. Réutilise la session flask_login.
from src.mcp import usage # noqa: E402
from src.mcp.oauth import routes as oauth_routes # noqa: E402
from src.mcp.oauth import store as oauth_store # noqa: E402
_users_db = os.environ["USERS_DB_PATH"]
oauth_store.init_schema(_users_db)
usage.init_schema(_users_db)
usage.purge_older_than(_users_db)
oauth_routes.init_oauth(app.server)
# Exempter du CSRF les endpoints OAuth machine-à-machine (POST externes sans
# cookie) et les documents de découverte (GET). /oauth/authorize N'EST PAS
# exempté : endpoint de consentement authentifié par cookie de session, donc
# protégé par CSRF via un jeton dans le formulaire de consentement.
if _auth_csrf is not None:
_csrf_exempt_oauth = ("/oauth/token", "/oauth/register", "/oauth/revoke")
for _rule in app.server.url_map.iter_rules():
if _rule.rule in _csrf_exempt_oauth or _rule.rule.startswith(
"/.well-known/"
):
_vf = app.server.view_functions.get(_rule.endpoint)
if _vf is not None:
_auth_csrf.exempt(_vf)
from src.subscriptions.setup import init_subscriptions # noqa: E402
init_subscriptions(app.server)
from src.saved_views import db as saved_views_db # noqa: E402
saved_views_db.init_schema()
from src.roadmap import db as roadmap_db # noqa: E402
roadmap_db.init_schema()
from src.mcp.account import mcp_account_bp # noqa: E402
app.server.register_blueprint(mcp_account_bp)
# robots.txt # robots.txt
@app.server.route("/robots.txt") @app.server.route("/robots.txt")
def robots(): def robots():
text = """User-agent: * text = """User-agent: *
Allow: / Allow: /
""" Sitemap: https://colibre.fr/sitemap.xml
"""
return Response(text, mimetype="text/plain") return Response(text, mimetype="text/plain")
# Index de sitemaps + sous-sitemaps paginés (voir src.utils.sitemap).
from src.utils import sitemap as _sitemap # noqa: E402 # src.db doit être prêt
@app.server.route("/sitemap.xml") @app.server.route("/sitemap.xml")
def sitemap(): def sitemap():
base_url = "https://decp.info" return Response(_sitemap.build_index(), mimetype="application/xml")
pages = [
"/",
"/observatoire", @app.server.route("/sitemap-pages.xml")
"/tableau", def sitemap_pages():
"/a-propos", return Response(_sitemap.build_pages(), mimetype="application/xml")
"/etapes",
]
xml = '<?xml version="1.0" encoding="UTF-8"?>\n' @app.server.route("/sitemap-<segment>-<int:page>.xml")
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' def sitemap_org(segment: str, page: int):
for page in pages: xml = _sitemap.build_org_page(segment, page)
xml += " <url>\n" if xml is None:
xml += f" <loc>{base_url}{page}</loc>\n" return Response("Not found", status=404)
xml += " </url>\n" return Response(xml, mimetype="application/xml")
xml += "</urlset>"
return Response(xml, mimetype="text/xml")
@app.server.route("/llms.txt")
def llms():
return redirect("/assets/llms.md")
with open("./pyproject.toml", "rb") as f: with open("./pyproject.toml", "rb") as f:
pyproject = tomllib.load(f) pyproject = tomllib.load(f)
version = "v" + pyproject["project"]["version"] version = "v" + pyproject["project"]["version"]
# Widget de chat Chatwoot (essai, issue #120) : chaîne vide si la variable
# d'env n'est pas définie, ce qui désactive le widget (utilisé notamment
# pour ne jamais le charger pendant les tests/CI).
chatwoot_script = build_widget_script(os.getenv("CHATWOOT_WEBSITE_TOKEN"))
app.index_string = """ app.index_string = """
<!DOCTYPE html> <!DOCTYPE html>
@@ -102,9 +234,21 @@ app.index_string = """
<head> <head>
{%metas%} {%metas%}
<title>{%title%}</title> <title>{%title%}</title>
{%favicon%} <link rel="shortcut icon" href="/assets/icons/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png">
<link rel="manifest" href="/assets/icons/site.webmanifest">
{%css%} {%css%}
<!-- canonical link --> <!-- canonical auto-référent : l'index_string Dash est partagé par toutes
les pages, donc on pose le href côté client d'après l'URL courante
(sans query string). Google exécute le JS au rendu. -->
<link rel="canonical" id="canonical-link">
<script type="application/javascript">
document.getElementById('canonical-link').setAttribute(
'href', window.location.origin + window.location.pathname
);
</script>
</head> </head>
<body> <body>
{%app_entry%} {%app_entry%}
@@ -127,9 +271,10 @@ app.index_string = """
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})(); })();
</script> </script>
__CHATWOOT_SCRIPT__
</body> </body>
</html> </html>
""" """.replace("__CHATWOOT_SCRIPT__", chatwoot_script)
navbar = dbc.Navbar( navbar = dbc.Navbar(
dbc.Container( dbc.Container(
@@ -139,12 +284,12 @@ navbar = dbc.Navbar(
children=[ children=[
html.Div( html.Div(
[ [
dcc.Link(html.H1("decp.info"), href="/", className="logo"), dcc.Link(html.H1("colibre"), href="/", className="logo"),
html.P( html.P(
[ [
html.A( html.A(
version, version,
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md", href="/a-propos/roadmap",
) )
], ],
className="version", className="version",
@@ -177,14 +322,17 @@ navbar = dbc.Navbar(
dbc.NavItem( dbc.NavItem(
dbc.NavLink( dbc.NavLink(
page["name"].replace(" ", " "), page["name"].replace(" ", " "),
href=page["relative_path"], href=page["relative_path"] + "/presentation"
if page["name"] == "À propos"
else page["relative_path"],
active="exact", active="exact",
) )
) )
for page in page_registry.values() for page in page_registry.values()
if page["name"] if page["name"]
in ["Recherche", "À propos", "Tableau", "Observatoire"] in ["Recherche", "À propos", "Tableau", "Observatoire"]
], ]
+ [html.Div(id="auth-nav-slot")],
className="ms-auto", className="ms-auto",
navbar=True, navbar=True,
), ),
@@ -201,6 +349,7 @@ navbar = dbc.Navbar(
app.layout = html.Div( app.layout = html.Div(
[ [
dcc.Store(id="csrf-token"),
navbar, navbar,
dbc.Container( dbc.Container(
page_container, page_container,
@@ -221,3 +370,35 @@ def toggle_navbar_collapse(n, is_open):
if n: if n:
return not is_open return not is_open
return is_open return is_open
@callback(
Output("auth-nav-slot", "children"),
Input("auth-nav-slot", "id"),
)
def _auth_nav(_):
if current_user.is_authenticated:
# email = current_user.email
# display = email if len(email) <= 30 else email[:27] + "..."
display = "★★★"
return dbc.NavItem(dbc.NavLink(display, href="/compte/admin"))
return dbc.NavItem(dbc.NavLink("Connexion", href="/connexion"))
@callback(
Output("csrf-token", "data"),
Input("_pages_location", "pathname"),
Input("auth-nav-slot", "children"),
)
def _generate_csrf_token(*_):
from flask_wtf.csrf import generate_csrf
return generate_csrf()
@callback(
Output({"type": "csrf-input", "index": ALL}, "value"),
Input("csrf-token", "data"),
)
def _fill_csrf_inputs(token):
return [token] * len(ctx.outputs_list)
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>decp.info — indisponible</title>
<style>
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600);
:root {
--primary-color: rgb(179, 56, 33);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter", sans-serif;
font-weight: 400;
background-color: rgb(255 240 240 / 40%);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 20px 24px;
border-bottom: 1px solid #eee;
background: #fff;
}
.logo-wrapper {
display: flex;
align-items: baseline;
gap: 12px;
}
a.logo {
color: black;
text-decoration: none;
}
a.logo h1 {
font-weight: 400;
font-size: 1.5rem;
line-height: 1;
}
a.logo h1 span {
color: var(--primary-color);
}
p.version {
font-family: "Fira Code", monospace;
font-size: 0.85rem;
color: #666;
}
main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
}
.card {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 40px 48px;
max-width: 520px;
width: 100%;
text-align: center;
}
.card h2 {
font-weight: 600;
font-size: 1.25rem;
color: var(--primary-color);
margin-bottom: 16px;
}
.card p {
color: #444;
line-height: 1.6;
margin-bottom: 10px;
}
.card p:last-child {
margin-bottom: 0;
color: #888;
font-size: 0.9rem;
}
</style>
</head>
<body>
<header>
<div class="logo-wrapper">
<a class="logo" href="/">
<h1>decp<span>.info</span></h1>
</a>
</div>
</header>
<main>
<div class="card">
<h2>Site temporairement indisponible</h2>
<p>
decp.info rencontre actuellement un problème technique. Une alerte m'a
été envoyée et je travaille à rétablir le service.
</p>
<p>
Le site devrait être de nouveau disponible prochainement. Merci de
votre patience.
</p>
<p>Colin Maudry (colin@colmo.tech)</p>
</div>
</main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

+370 -22
View File
@@ -1,12 +1,20 @@
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600); @import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600|akshar:500);
/* ========================================================================== /* ==========================================================================
Variables Variables
========================================================================== */ ========================================================================== */
:root { :root {
--main-font: "Inter", sans-serif;
--bs-font-monospace: "Fira Code"; --bs-font-monospace: "Fira Code";
--primary-color: rgb(179, 56, 33); --primary-color: rgb(179, 56, 33);
--primary-color-text: #b33821; --primary-color-text: #b33821;
/* Dash 4 utilise cette variable pour l'état actif/focus des composants
dcc (Dropdown, Input, Tabs, DatePicker...) ; défaut violet remplacé
par notre couleur primaire. */
--Dash-Fill-Interactive-Strong: var(--primary-color);
/* Override AG Grid */
--ag-font-family: var(--main-font);
} }
/* ========================================================================== /* ==========================================================================
@@ -39,21 +47,37 @@ h3 {
margin: 36px 0 20px 0; margin: 36px 0 20px 0;
} }
/* Base Button Styles /* ==========================================================================
button { Boutons charte à 3 rôles (couleur = fonction, remplissage = emphase)
font-weight: 400; Voir docs/superpowers/specs/2026-06-30-charte-boutons-design.md
background-color: #fff; Override scopé aux classes .btn-* uniquement (n'affecte pas les alertes,
border-radius: 3px; badges, ni text-danger qui héritent de la sémantique Simplex).
appearance: auto; ========================================================================== */
border: solid var(--primary-color) 1px;
} */
:root {
--btn-terracotta: #b33821;
--btn-terracotta-text: #fff;
--btn-secondary-text: #344054;
--btn-secondary-border: #5a6570;
--btn-secondary-hover-bg: #f1f3f5;
--btn-danger: #c0392b;
--btn-danger-text: #fff;
}
/* Base commune : rayon + typo */
.btn {
border-radius: 3px;
font-family: "Inter", sans-serif;
font-weight: 400;
}
/* --- PRIMARY plein : action principale validante (1 par contexte) --- */
button.btn.btn-primary, button.btn.btn-primary,
a.btn.btn-primary,
button.show-hide { button.show-hide {
display: block; display: block;
border-radius: 3px;
outline: 0; outline: 0;
color: #fff; color: var(--btn-terracotta-text);
border: 0; border: 0;
height: 30px; height: 30px;
padding-top: 2px; padding-top: 2px;
@@ -65,7 +89,9 @@ button.show-hide {
} }
button.btn.btn-primary:hover, button.btn.btn-primary:hover,
a.btn.btn-primary:hover,
button.show-hide:hover { button.show-hide:hover {
color: var(--btn-terracotta-text);
background-image: linear-gradient( background-image: linear-gradient(
rgb(239, 126, 103), rgb(239, 126, 103),
rgb(209, 86, 63) 26%, rgb(209, 86, 63) 26%,
@@ -73,18 +99,82 @@ button.show-hide:hover {
); );
} }
/* --- PRIMARY outline : action affirmative de moindre emphase --- */
.btn.btn-outline-primary {
color: var(--btn-terracotta);
border: 1px solid var(--btn-terracotta);
background-color: transparent;
background-image: none;
}
.btn.btn-outline-primary:hover,
.btn.btn-outline-primary:focus-visible {
color: var(--btn-terracotta-text);
background-color: var(--btn-terracotta);
border-color: var(--btn-terracotta);
}
/* --- SECONDARY : action neutre / alternative (gris ardoise) --- */
.btn.btn-secondary,
.btn.btn-outline-secondary {
color: var(--btn-secondary-text);
border: 1px solid var(--btn-secondary-border);
background-color: transparent;
background-image: none;
}
.btn.btn-secondary:hover,
.btn.btn-secondary:focus-visible,
.btn.btn-outline-secondary:hover,
.btn.btn-outline-secondary:focus-visible {
color: var(--btn-secondary-text);
background-color: var(--btn-secondary-hover-bg);
border-color: var(--btn-secondary-border);
}
/* --- DANGER outline : action destructive dans le flux courant --- */
.btn.btn-outline-danger {
color: var(--btn-danger);
border: 1px solid var(--btn-danger);
background-color: transparent;
background-image: none;
}
.btn.btn-outline-danger:hover,
.btn.btn-outline-danger:focus-visible {
color: var(--btn-danger-text);
background-color: var(--btn-danger);
border-color: var(--btn-danger);
}
/* --- DANGER plein : confirmation finale destructive (modale) --- */
.btn.btn-danger {
color: var(--btn-danger-text);
border: 1px solid var(--btn-danger);
background-color: var(--btn-danger);
background-image: none;
}
.btn.btn-danger:hover,
.btn.btn-danger:focus-visible {
color: var(--btn-danger-text);
background-color: #a93226;
border-color: #a93226;
}
/* --- État désactivé commun (conserve l'existant) --- */
.btn[disabled],
button[disabled] { button[disabled] {
border-color: #ccc; border-color: #ccc;
color: #666; color: #666;
background-image: none;
} }
button:hover:not([disabled]) { @media (prefers-reduced-motion: no-preference) {
background-color: #fee; .btn {
} transition: background-color 0.15s ease, color 0.15s ease,
border-color 0.15s ease;
/* Global Link Styles */ }
#_pages_content a {
color: #993333;
} }
/* ========================================================================== /* ==========================================================================
@@ -138,6 +228,24 @@ p.version > a {
font-weight: 600; font-weight: 600;
} }
.account-nav .nav-link {
color: inherit;
padding-left: 10px;
border-left: 3px solid transparent;
border-radius: 0;
}
.account-nav .nav-link:hover {
background-color: rgba(255, 240, 240, 0.9);
}
.account-nav .nav-link.active {
color: #d9230f;
font-weight: 600;
border-left-color: #d9230f;
background-color: transparent;
}
#announcements { #announcements {
margin: 25px 40px 0 60px; margin: 25px 40px 0 60px;
font-size: 90%; font-size: 90%;
@@ -181,13 +289,14 @@ p.version > a {
text-wrap: wrap; text-wrap: wrap;
} }
/* --- Dashboard inputs --- */ /* Account form */
.Select--multi .Select-value { input[type="email"] {
color: var(--primary-color) !important; max-width: 400px;
background-color: rgba(255, 240, 240, 0.4) !important;
} }
/* --- Dashboard inputs --- */
#filters .row > * { #filters .row > * {
margin-bottom: 6px; margin-bottom: 6px;
} }
@@ -214,6 +323,55 @@ p.version > a {
margin: 8px 16px 8px 0; margin: 8px 16px 8px 0;
} }
/* Barre d'outils du tableau (/tableau) : une rangée d'actions compactes
au-dessus d'une ligne d'infos discrète. .table-menu (autres pages) intacte. */
.table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 14px 0 6px 0;
}
.table-toolbar > * {
margin: 0 12px 8px 0;
}
/* Mode d'emploi : aide auxiliaire, rejetée en fin de barre sans toucher au DOM */
.table-toolbar #tableau_help_open {
order: 1;
}
.table-meta {
margin: 0 0 16px 0;
color: #666;
font-size: 14px;
}
/* Raison du téléchargement bloqué : visible (pas une infobulle), mise en avant
sobrement avec la couleur terracotta de la marque. */
.table-meta .dl-hint {
color: #b33821;
}
/* Légende des boutons en tête du mode d'emploi : boutons reproduits à
l'identique mais inertes, alignés en face de leur fonction. */
.help-legend-table {
border-collapse: collapse;
}
.help-legend-table td {
padding: 4px 16px 4px 0;
vertical-align: middle;
}
.help-legend-btn {
white-space: nowrap;
}
.help-legend .btn {
pointer-events: none;
}
#source_table { #source_table {
margin-bottom: 25px; margin-bottom: 25px;
} }
@@ -354,15 +512,84 @@ table.cell-table th {
font-weight: 400; font-weight: 400;
} }
/* Bootstrap's CSS cascade defeats dash_table's own inline `display: none`
toggle on dropdown-cell menus (react-select v1 renders the menu hidden
first to measure it, then flips to visible Bootstrap's reset wins that
fight, so the menu never becomes visible even though the cell's "open"
state is correct). Applies to every editable dropdown column in every
DataTable, not just one table. See:
https://github.com/plotly/dash-table/issues/956 */
.Select-menu-outer {
display: block !important;
}
.marches_table.stuck { .marches_table.stuck {
position: relative; position: relative;
right: 200px; right: 200px;
} }
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
/* Contenir le scroll horizontal dans le conteneur Dash (élimine la scrollbar native en bas de page) */
/* overflow-y:clip évite la conversion CSS visible→auto qui ajouterait une scrollbar verticale */
.marches_table .dash-spreadsheet-container {
overflow-x: hidden !important;
overflow-y: clip !important;
}
/* L'inner reste visible pour que le tableau se déploie librement en largeur */
.marches_table .dash-spreadsheet-inner {
overflow: visible !important;
}
.marches_table .cell-table tr:nth-child(even) td { .marches_table .cell-table tr:nth-child(even) td {
background-color: rgb(255 240 240 / 40%); background-color: rgb(255 240 240 / 40%);
} }
/* Colonne "Marché" : lien loupe centré et sans soulignement */
table.cell-table td[data-dash-column="marche"] .dash-cell-value {
text-align: center !important;
}
td[data-dash-column="marche"] a {
text-decoration: none;
}
th[data-dash-column="marche"].dash-filter input {
display: none;
}
/* Barre de défilement horizontale, collée en haut du tableau */
.marches_table .dt-hscroll {
position: sticky;
top: 0;
z-index: 11;
height: 12px;
background-color: #e0e0e0;
overflow: hidden;
cursor: pointer;
}
.marches_table .dt-hscroll.is-hidden {
display: none;
}
/* Thumb custom — toujours visible, draggable */
.marches_table .dt-hscroll-thumb {
position: absolute;
top: 2px;
height: calc(100% - 4px);
min-width: 40px;
background-color: orange;
border-radius: 8px;
cursor: grab;
user-select: none;
}
.marches_table .dt-hscroll-thumb:active {
cursor: grabbing;
}
/* Column Visibility Menu */ /* Column Visibility Menu */
.column-actions { .column-actions {
margin-right: 8px; margin-right: 8px;
@@ -397,10 +624,82 @@ button.show-hide {
margin-right: 10px; margin-right: 10px;
} */ } */
/* Override des styles AG Grid */
/* La ligne de filtres flottants reprend le fond des lignes paires (rosé),
distinct du fond rouge brique appliqué à la ligne d'en-têtes (theme
params AG Grid n'exposant pas de couleur de fond dédiée à cette ligne). */
.marches_table .ag-floating-filter {
background-color: var(--ag-odd-row-background-color);
}
.ag-row {
--ag-internal-content-line-height: 20px;
}
.ag-cell {
padding-top: 3px;
}
.ag-header-cell-text {
font-family: "Akshar", sans-serif;
font-size: 18px;
}
/* Lien "voir le marché" (loupe) : pas de soulignement, c'est une icône. */
.ag-cell[col-id="marche"] a {
text-decoration: none;
}
/* Numéro de ligne sous la loupe : repère de position pendant le défilement,
discret pour ne pas concurrencer le lien. */
.ag-cell[col-id="marche"] {
text-align: center;
}
.marche-row-number {
color: #bbb;
font-size: 12px;
line-height: 1.4;
}
/* Infobulle : largeur bornée avec retour à la ligne plutôt qu'une largeur
qui suit tout le texte. .ag-tooltip = infobulle native (ex. colonne
"objet" tronquée) ; .ag-tooltip-custom = infobulle rendue par un
tooltipComponent personnalisé (ex. en-têtes de colonnes, cf.
grid_column_defs) AG Grid n'ajoute PAS la classe .ag-tooltip dans ce
cas, il faut donc cibler les deux. */
.ag-tooltip,
.ag-tooltip-custom {
max-width: 400px;
white-space: normal;
word-wrap: break-word;
background-color: #fdd;
}
/* .ag-tooltip-custom n'a, contrairement à .ag-tooltip, aucun style visuel
par défaut côté AG Grid (voir dash_ag_grid.min.js : seuls position/z-index
sont posés) on reprend ici les mêmes variables de thème que .ag-tooltip
pour un rendu identique (bordure grise, padding, coins arrondis). */
.ag-tooltip-custom {
border: var(--ag-tooltip-border);
border-radius: var(--ag-border-radius);
color: var(--ag-tooltip-text-color);
padding: var(--ag-widget-container-vertical-padding)
var(--ag-widget-container-horizontal-padding);
}
/* fin overrides AG Grid*/
#btn-copy-url:before { #btn-copy-url:before {
} }
/* Dropdowns */ /* Dropdowns */
/* Bootstrap 5.3 redéfinit les variables --bs-dropdown-* SUR le sélecteur
.dropdown-menu : un override sur :root est masqué (déclaration composant plus
spécifique). On pose donc la surcharge sur .dropdown-menu. */
.dropdown-menu {
--bs-dropdown-link-hover-bg: rgba(255, 240, 240, 1);
--bs-dropdown-link-active-bg: rgba(255, 240, 240, 1);
}
.Select-placeholder { .Select-placeholder {
color: #333 !important; color: #333 !important;
} }
@@ -551,6 +850,13 @@ input[type="number"] {
-moz-appearance: textfield; -moz-appearance: textfield;
} }
/* Dash 4 ajoute des boutons +/- (steppers) sur les dcc.Input type="number" ;
inutiles ici (pas d'incrémentation pertinente pour des montants), on les
masque pour que le champ récupère l'espace (flex: 1 1 0 sur l'input). */
.dash-input-stepper {
display: none;
}
/* ===== Page /etapes : graphique données par étape et par seuil ===== */ /* ===== Page /etapes : graphique données par étape et par seuil ===== */
.etapes-chart-scroll { .etapes-chart-scroll {
@@ -755,6 +1061,11 @@ input[type="number"] {
display: none; display: none;
} }
/* Roadmap */
.roadmap-vote-list input[type="text"] {
}
/* --- Bascule desktop / mobile au point de rupture 768 px --- */ /* --- Bascule desktop / mobile au point de rupture 768 px --- */
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -765,3 +1076,40 @@ input[type="number"] {
display: block; display: block;
} }
} }
/* Écran de chargement initial (avant hydratation de l'app Dash) */
._dash-loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0;
}
._dash-loading::after {
content: "Chargement...";
font-size: 1.1rem;
color: #ccc;
}
.plan-selectable {
cursor: pointer;
}
.plan-selectable .card {
transition: background-color 0.15s, border-color 0.15s;
}
.plan-selectable.selected .card {
background-color: var(--bs-primary-bg-subtle);
border-color: var(--bs-primary);
}
/* Lien de partage affiché en texte (span) mais stylé comme un champ :
bordure gris clair, coins arrondis, texte légèrement plus petit. */
.share-url-text {
font-size: 90%;
padding: 0.2rem 0.5rem;
border: 1px solid var(--bs-border-color);
border-radius: 3px;
background-color: var(--bs-body-bg);
}
+30
View File
@@ -0,0 +1,30 @@
// Fonctions AG Grid exposées à Dash AG Grid via le namespace
// window.dashAgGridFunctions (cf. eventListeners de la grille du Tableau).
var dagfuncs = (window.dashAgGridFunctions = window.dashAgGridFunctions || {});
// Sources d'événements AG Grid qui ne correspondent PAS à une action de
// l'utilisateur : application programmatique d'une vue (props filterModel /
// columnState → source 'api') et initialisation de la grille. On les ignore ;
// toute autre source (ex. 'columnFilter', 'uiColumnSorted', 'toolPanelUi',
// 'columnMenu') est une modification volontaire de l'état par l'utilisateur.
var SHARE_PROGRAMMATIC_SOURCES = [
"api",
"gridInitializing",
"gridOptionsChanged",
"gridOptionsUpdated",
"columnDefsUpdated",
];
// Masque le bloc « URL directe » (share-url-box) dès que l'utilisateur modifie
// filtre / tri / visibilité de colonne, en effaçant le store active-view.
// L'écho de l'application d'une vue (source 'api') est ignoré : plus de course
// d'ordonnancement ni de diff d'état fragile.
dagfuncs.hideShareOnUserAction = function (params) {
var source = params && params.source;
if (SHARE_PROGRAMMATIC_SOURCES.indexOf(source) !== -1) {
return;
}
if (window.dash_clientside && window.dash_clientside.set_props) {
window.dash_clientside.set_props("active-view", { data: null });
}
};
+49 -5
View File
@@ -1,6 +1,54 @@
// --- Garde du Leaflet global (issue #121) ---
// Le bundle dash-ag-grid (async-community.js) fuit un helper esbuild sous le
// nom global `L`, écrasant le `window.L` de Leaflet. Nos fonctions
// pointToLayer/clusterToLayer (ci-dessous) ont besoin du vrai Leaflet. Comme
// dash-leaflet ne passe pas Leaflet en argument, on capture en privé la
// dernière valeur de `window.L` exposant `circleMarker` (= le vrai Leaflet) en
// écoutant les affectations, SANS modifier ce que `window.L` renvoie (AG Grid
// continue d'utiliser son helper). `leafletReal()` renvoie ce vrai Leaflet.
(function () {
var real = window.L && window.L.circleMarker ? window.L : null;
var last = window.L;
try {
Object.defineProperty(window, "L", {
configurable: true,
get: function () {
return last;
},
set: function (v) {
last = v;
if (v && v.circleMarker) real = v;
},
});
} catch (e) {
/* propriété non configurable : on garde le comportement natif */
}
window.leafletReal = function () {
return real || last;
};
})();
window.dash_clientside = Object.assign({}, window.dash_clientside, { window.dash_clientside = Object.assign({}, window.dash_clientside, {
leaflet: { leaflet: {
pointToLayer: function (feature, latlng, context) { pointToLayer: function (feature, latlng, context) {
const L = window.leafletReal ? window.leafletReal() : window.L;
// Le point de l'organisme consulté (is_home) est rendu comme une
// icône (comme les clusters), pas comme un circleMarker SVG : les
// icônes vivent dans le markerPane, toujours au-dessus du overlayPane
// (SVG) où se trouvent les circleMarkers, et gardent une taille CSS
// fixe (contrairement aux tracés SVG, redimensionnés visuellement
// pendant l'animation de zoom).
if (feature.properties.is_home) {
const size = 22;
const icon = L.divIcon({
html: `<div style="background-color: ${feature.properties.marker_color}; width: ${size}px; height: ${size}px; border-radius: 50%; border: 2px solid white; box-sizing: border-box;"></div>`,
className: "org-home-marker",
iconSize: L.point(size, size),
});
return L.marker(latlng, { icon: icon, zIndexOffset: 1000 }).bindTooltip(
feature.properties.tooltip
);
}
return L.circleMarker(latlng, { return L.circleMarker(latlng, {
radius: 5, radius: 5,
fillColor: feature.properties.marker_color, fillColor: feature.properties.marker_color,
@@ -11,13 +59,9 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, {
}).bindTooltip(feature.properties.tooltip); }).bindTooltip(feature.properties.tooltip);
}, },
clusterToLayer: function (feature, latlng, index, context) { clusterToLayer: function (feature, latlng, index, context) {
console.log(feature); const L = window.leafletReal ? window.leafletReal() : window.L;
console.log(index);
console.log(context);
const count = feature.properties.point_count; const count = feature.properties.point_count;
const size = count < 100 ? 30 : count < 1000 ? 40 : 50; const size = count < 100 ? 30 : count < 1000 ? 40 : 50;
const color = "#555"; // Default cluster color
const icon = L.divIcon({ const icon = L.divIcon({
html: `<div style="background-color: ${context.fillColor}; width: ${size}px; height: ${size}px; border-radius: 50%; display: flex; align-items:center; justify-content:center; color: white; border: 2px solid white; font-weight: bold;">${count}</div>`, html: `<div style="background-color: ${context.fillColor}; width: ${size}px; height: ${size}px; border-radius: 50%; display: flex; align-items:center; justify-content:center; color: white; border: 2px solid white; font-weight: bold;">${count}</div>`,
className: "marker-cluster", className: "marker-cluster",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"colibre","short_name":"colibre","icons":[{"src":"/assets/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#b33821","background_color":"#ffffff","display":"standalone"}
+26
View File
@@ -0,0 +1,26 @@
# colibre
colibre est une plateforme permettant d'explorer, filtrer et visualiser les données des marés publics français. Elle est alimentée par des données publiées en Open Data et propose également une API REST JSON accessible sur abonnement (contacter <colin@colmo.tech>).
## Schéma des données
Le schéma des données est au format TableSchema et peut être librement téléchargé à cette adresse : <https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a>
## Données source
Les données sont publiées en Open Data aux formats Parquet et CSV :
- Parquet : <https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432>
- CSV : <https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52>
## API
L'API nécessite un jeton d'authentification pour se connecter. Elle utilise le même schéma que les données et renvoie les données au format JSON.
- Point d'accès : <https://colibre.fr/api/v1/data>
- Spec Open API : <https://colibre.fr/api/v1/openapi.json>
- Health : <https://colibre.fr/api/v1/health>
## Sources de données
Les données proviennent de nombreuses sources de données, le projet publie des statistiques au format CSV : <https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234>
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More