Compare commits

...

122 Commits

Author SHA1 Message Date
Colin Maudry 8135029174 Gitea flow copie de Github
Déploiement / deploy (push) Failing after 33s
2026-07-19 15:29:12 +02:00
Colin Maudry b265930e6f Rédactionnel chapeau page d'accueil 2026-07-19 13:30:59 +02:00
Colin Maudry 41738b60ef fix(recherche): centrer verticalement la flèche du bouton de recherche
Le padding vertical (.375rem) et le line-height (1.5) de .btn nécessitent 38px
alors que le bouton a une hauteur fixe de 34px (alignée sur l'input) : le
glyphe débordait vers le bas au lieu d'être centré.

Centrage en flex + neutralisation du padding vertical, pour que l'alignement
ne dépende plus du line-height.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:30:35 +02:00
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 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 7941b08d4a docs(mcp): spec du connecteur MCP par jeton abonné (scope B #111) 2026-07-10 14:24:19 +02:00
113 changed files with 13522 additions and 1060 deletions
+51
View File
@@ -0,0 +1,51 @@
name: Déploiement
on:
workflow_dispatch:
pull_request:
types:
- closed
push:
branches:
- dev
jobs:
deploy:
# Trigger deploy workflow if
# ...I clicked on "Run workflow" in Github actions, thus deploy main to production env (main)
# or
# ...I merged a PR or pushed on the dev branch, thus deploy to the test env (dev)
if: |
(github.event_name == 'workflow_dispatch' && github.ref_name == 'main') ||
((github.event_name == 'pull_request' || github.event_name == 'push') && github.ref_name == 'dev')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up SSH key
run: |
env
mkdir -p ~/.ssh
echo "${{ secrets.ARTIFACT_SSH_KEY }}" > /home/runner/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -p ${{ secrets.ARTIFACT_PORT }} ${{ secrets.ARTIFACT_HOST }} >> ~/.ssh/known_hosts
sudo apt-get install sshpass python3 python3-pip
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.ARTIFACT_HOST }}
username: ${{ secrets.USER }}
port: ${{ secrets.ARTIFACT_PORT }}
key: ${{ secrets.ARTIFACT_SSH_KEY }}
passphrase: ${{ secrets.SSH_PSWD }}
command_timeout: 5m
script: |
systemctl stop ${{ vars.APP_NAME }}
cd /var/www/${{ vars.APP_NAME }}
git pull
source .venv/bin/activate
pip install .
deactivate
chown -R ${{ vars.APP_NAME }}:www-data *
systemctl start ${{ vars.APP_NAME }}
+7
View File
@@ -19,6 +19,13 @@ users.sqlite
*.sqlite-* *.sqlite-*
!tests/*.sqlite !tests/*.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 # LLM plugins
.superpowers/ .superpowers/
+18 -5
View File
@@ -22,6 +22,10 @@ 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
API_AUTH_DISABLED="false" API_AUTH_DISABLED="false"
MATOMO_URL=https://analytics.maudry.com/matomo.php MATOMO_URL=https://analytics.maudry.com/matomo.php
@@ -30,6 +34,9 @@ MATOMO_TRACKING_ENABLED=true
# Comptes utilisateurs # Comptes utilisateurs
USERS_DB_PATH=./users.sqlite 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))" SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
APP_BASE_URL=http://localhost:8050 APP_BASE_URL=http://localhost:8050
@@ -71,10 +78,16 @@ FRISBII_PLAN_SOUTIEN=soutien-colibre # handle du plan "abonnement de
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
# Accès gratuit temporaire # Accès gratuit temporaire
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte TOUS_ABONNES=false
# Serveur MCP (issue #111). Laisser à false tant que l'authentification # Active le serveur MCP (/_mcp) ET le connecteur d'abonné (scope B, #111).
# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP # À true, l'accès à /_mcp exige un jeton MCP (généré dans /compte/mcp) lié à un
# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels : # abonnement actif. Laisser false tant que le connecteur n'est pas déployé.
# créer un Custom Dimension slot 1 (scope Action). # Déploiement recommandé : activer d'abord sur test.colibre.fr (branche dev).
DASH_MCP_ENABLED=false 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
+4 -17
View File
@@ -3,36 +3,23 @@
**Fonctionnalités par abonnement** **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)) - 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, réservée aux abonné·es ([#95](https://github.com/ColinMaudry/colibre/issues/95)) - 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)) - 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 la commande publique via un serveur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111)) - 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** **Autres améliorations**
- Comptes utilisateurs : inscription avec vérification d'email, connexion (email/mot de passe ou LinkedIn), réinitialisation de mot de passe, gestion du compte ([#73](https://github.com/ColinMaudry/colibre/issues/73), [#88](https://github.com/ColinMaudry/colibre/issues/88)) - Nouvelle interface de tableau : plus d'options de filtres, plus performante ([#47](https://github.com/ColinMaudry/colibre/issues/47))
- Nouvel espace "Mon compte" avec navigation par sections
- Sauvegarde régulière et chiffrée de la base des utilisateurs ([#89](https://github.com/ColinMaudry/colibre/issues/89))
- Ajout des codes et libellés NAF des titulaires, affichés sur leur page /titulaire - Ajout des codes et libellés NAF des titulaires, affichés sur leur page /titulaire
- Scroll horizontal ergonomique et en-têtes de colonnes collants dans la page Tableau ([#82](https://github.com/ColinMaudry/colibre/issues/82))
- Export Excel mis en forme (styles) ([#83](https://github.com/ColinMaudry/colibre/issues/83)) - 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)) - 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))
- Barre d'outils de la page Tableau plus compacte, avec légende d'aide
- Refonte et mise en page pleine largeur de la page À propos - 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)) - 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 du référencement (sitemaps acheteurs/titulaires)
- Amélioration des performances des pages acheteur et titulaire - Amélioration des performances des pages acheteur et titulaire
- Ajout des organismes liés sur la carte des pages acheteur et titulaire
- decp.info devient **colibre** : nouveau nom, nouvelles icônes ([#57](https://github.com/ColinMaudry/colibre/issues/57)) - decp.info devient **colibre** : nouveau nom, nouvelles icônes ([#57](https://github.com/ColinMaudry/colibre/issues/57))
**Bugs résolus**
- Redirection Frisbii corrigée après l'ajout d'un moyen de paiement lors du premier abonnement
- Un abonnement en échec ne bloque plus une nouvelle tentative de réabonnement
- Meilleure gestion de l'affichage d'un abonnement expiré
- Correction de l'affichage du nombre de jours d'essai restants
- Correction d'une erreur lors du renommage d'une vue sauvegardée, avec un meilleur retour visuel
- Le champ de filtrage n'apparaît plus sur la colonne "Marché" (non filtrable)
##### 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
+1 -1
View File
@@ -77,7 +77,7 @@ Avant d'ajouter des fichier dans git (`git add` ou `git commit -a`), exécute `p
### 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/`
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,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
+1
View File
@@ -52,6 +52,7 @@ 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",
+8 -4
View File
@@ -5,6 +5,14 @@ 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."""
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_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")
@@ -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 -1
View File
@@ -16,7 +16,6 @@ def _abort_401(message: str):
def require_token(fn): def require_token(fn):
@wraps(fn) @wraps(fn)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
print(API_AUTH_DISABLED)
if not API_AUTH_DISABLED: if not API_AUTH_DISABLED:
header = request.headers.get("Authorization", "") header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "): if not header.startswith("Bearer "):
@@ -30,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)
+7 -3
View File
@@ -130,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"
@@ -157,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
+71 -5
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
from cryptography.fernet import Fernet, InvalidToken
TOKEN_PREFIX = "colibre_" 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 ré-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
+52 -2
View File
@@ -35,6 +35,7 @@ from flask_login import current_user
from src.auth.setup import init_auth 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()
@@ -74,7 +75,7 @@ cache.init_app(
}, },
) )
_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true" _mcp_enabled = os.getenv("DASH_MCP_ENABLED", "").lower() == "true"
app: Dash = Dash( app: Dash = Dash(
server=server, server=server,
@@ -124,6 +125,46 @@ if _mcp_enabled:
) )
import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled 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 from src.subscriptions.setup import init_subscriptions # noqa: E402
init_subscriptions(app.server) init_subscriptions(app.server)
@@ -136,6 +177,10 @@ from src.roadmap import db as roadmap_db # noqa: E402
roadmap_db.init_schema() 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")
@@ -178,6 +223,10 @@ 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>
@@ -222,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(
+87 -1
View File
@@ -1,9 +1,10 @@
@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;
@@ -11,6 +12,9 @@
dcc (Dropdown, Input, Tabs, DatePicker...) ; défaut violet remplacé dcc (Dropdown, Input, Tabs, DatePicker...) ; défaut violet remplacé
par notre couleur primaire. */ par notre couleur primaire. */
--Dash-Fill-Interactive-Strong: var(--primary-color); --Dash-Fill-Interactive-Strong: var(--primary-color);
/* Override AG Grid */
--ag-font-family: var(--main-font);
} }
/* ========================================================================== /* ==========================================================================
@@ -620,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;
} }
@@ -1027,3 +1103,13 @@ input[type="number"] {
background-color: var(--bs-primary-bg-subtle); background-color: var(--bs-primary-bg-subtle);
border-color: var(--bs-primary); 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 });
}
};
+32 -5
View File
@@ -1,6 +1,37 @@
// --- 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 // Le point de l'organisme consulté (is_home) est rendu comme une
// icône (comme les clusters), pas comme un circleMarker SVG : les // icône (comme les clusters), pas comme un circleMarker SVG : les
// icônes vivent dans le markerPane, toujours au-dessus du overlayPane // icônes vivent dans le markerPane, toujours au-dessus du overlayPane
@@ -28,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",
+6 -1
View File
@@ -99,7 +99,12 @@ def verify_email():
return redirect("/verification-email?error=invalid_token") return redirect("/verification-email?error=invalid_token")
db.set_email_verified(user_id) db.set_email_verified(user_id)
login_user(User(db.get_user_by_id(user_id)), remember=True) login_user(User(db.get_user_by_id(user_id)), remember=True)
return redirect("/compte/abonnement/mes-infos") from src.utils import TOUS_ABONNES
# Sous TOUS_ABONNES, la page carte bancaire (mes-infos) est un cul-de-sac
# (pas de prestataire de paiement) : on renvoie vers la page abonnement.
dest = "/compte/abonnement" if TOUS_ABONNES else "/compte/abonnement/mes-infos"
return redirect(dest)
@auth_bp.route("/login", methods=["POST"]) @auth_bp.route("/login", methods=["POST"])
+3
View File
@@ -32,6 +32,9 @@ def init_auth(app: Flask) -> None:
app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = not DEVELOPMENT app.config["SESSION_COOKIE_SECURE"] = not DEVELOPMENT
app.config["REMEMBER_COOKIE_SAMESITE"] = "Lax"
app.config["REMEMBER_COOKIE_SECURE"] = not DEVELOPMENT
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 jours app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 jours
db.init_schema() db.init_schema()
+186 -31
View File
@@ -794,15 +794,15 @@ def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int,
Clés renvoyées : Clés renvoyées :
- "champs_renseignes" : (count_ren_sociales, pct / total) - "champs_renseignes" : (count_ren_sociales, pct / total)
- "{key}_renseignees" : (count_ren, pct positifs parmi renseignés) - "{key}_renseignees" : (count_ren, count_pos_ren, pct positifs parmi renseignés)
Colonne absente -> (0, 0). Colonne absente/vide -> "champs_renseignes" (0, 0), "{key}_renseignees" (0, 0, 0).
""" """
names = lff.collect_schema().names() names = lff.collect_schema().names()
present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names} present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names}
stats: dict[str, tuple[int, int, int]] = {"champs_renseignes": (0, 0)} stats: dict[str, tuple[int, ...]] = {"champs_renseignes": (0, 0)}
for key in CONSIDERATIONS_COLUMNS: for key in CONSIDERATIONS_COLUMNS:
stats[f"{key}_renseignees"] = (0, 0) stats[f"{key}_renseignees"] = (0, 0, 0)
if not present: if not present:
return stats return stats
@@ -972,21 +972,14 @@ def make_donut(
def make_column_picker(page: str): def make_column_picker(page: str):
table_data = [] table_data = []
table_columns = [ for col in schema.names():
{ column_data_schema = DATA_SCHEMA[col]
column_data = {
"id": col, "id": col,
"name": DATA_SCHEMA[col]["title"], "name": column_data_schema["title"],
"description": DATA_SCHEMA[col]["description"], "description": column_data_schema["description"],
} }
for col in schema.names() table_data.append(column_data)
]
for column in table_columns:
new_column = {
"id": column["id"],
"name": column["name"],
"description": DATA_SCHEMA[column["id"]]["description"],
}
table_data.append(new_column)
table = ( table = (
DataTable( DataTable(
@@ -1024,18 +1017,24 @@ def make_column_picker(page: str):
return table return table
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True): def _top_org_aggregate(data, org_type: str, extra_columns: list):
"""Agrégation « top N » commune à get_top_org_table et get_top_org_ag_grid.
Renvoie le DataFrame agrégé (colonnes castées en str, tri par Attributions
décroissant), AVANT add_links, ou None si vide/colonne manquante.
"""
if isinstance(data, pl.LazyFrame): if isinstance(data, pl.LazyFrame):
lff = data lff = data
else: else:
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000) lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
extra = list(extra_columns) # copie : ne pas muter la liste du caller
if org_type == "titulaire": if org_type == "titulaire":
extra_columns.append("titulaire_typeIdentifiant") extra.append("titulaire_typeIdentifiant")
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra_columns columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra
lff = lff.select(columns) lff = lff.select(columns)
lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra_columns).agg( lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra).agg(
pl.len().alias("Attributions") pl.len().alias("Attributions")
) )
lff = lff.sort(by="Attributions", descending=True, nulls_last=True) lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
@@ -1044,11 +1043,18 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
try: try:
dff: pl.DataFrame = lff.collect(engine="streaming") dff: pl.DataFrame = lff.collect(engine="streaming")
except ColumnNotFoundError: except ColumnNotFoundError as e:
logger.warning(f"get_top_org_table: column not found. {lff.collect_schema()}") # Ne pas rappeler lff.collect_schema() ici : sur le même plan lazy
return html.Div() # cassé, la résolution du schéma lève à nouveau ColumnNotFoundError
# (non rattrapée), au lieu de renvoyer None comme prévu.
logger.warning(f"_top_org_aggregate: column not found. {e}")
return None
return dff if dff.height > 0 else None
if dff.height == 0:
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
dff = _top_org_aggregate(data, org_type, extra_columns)
if dff is None:
return html.Div() return html.Div()
columns, tooltip = setup_table_columns( columns, tooltip = setup_table_columns(
@@ -1056,7 +1062,6 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
) )
dff = add_links(dff) dff = add_links(dff)
data = dff.to_dicts() data = dff.to_dicts()
# data = add_links_in_dict(data, f"{org_type}")
return DataTable( return DataTable(
dtid=f"top10_{org_type}", dtid=f"top10_{org_type}",
@@ -1069,15 +1074,129 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
) )
def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid": def get_top_org_ag_grid(data, org_type: str, extra_columns: list, filters: bool = True):
"""Top N acheteurs/titulaires en AG Grid client-side (rowData directe).
Remplace get_top_org_table (dash_table) : même agrégation (helper partagé),
rendu AG Grid (thème brique, liens markdown). html.Div() si vide/erreur.
"""
dff = _top_org_aggregate(data, org_type, extra_columns)
if dff is None:
return html.Div()
dff = add_links(dff)
# columnDefs : on masque l'id (lien porté par le nom), on rend le nom en
# markdown (HTML <a>), on cache les colonnes techniques *_tooltip.
id_col = f"{org_type}_id"
nom_col = f"{org_type}_nom"
link_cols = {nom_col}
column_defs = []
for col in dff.columns:
if col == id_col or col.endswith("_tooltip"):
continue
col_def = {
"field": col,
"headerName": DATA_SCHEMA.get(col, {}).get("title", col),
"sortable": True,
"filter": "agTextColumnFilter" if filters else False,
}
if col in link_cols:
col_def["cellRenderer"] = "markdown"
col_def["flex"] = 1
column_defs.append(col_def)
# ag_grid() du Lot 1 est server-side (rowModelType="infinite", pas de
# rowData) : inadapté au top 10, qui est client-side. On construit donc une
# AG Grid client-side directe, en réutilisant le thème + localeText.
return dag.AgGrid(
id=f"top10_{org_type}",
columnDefs=column_defs,
rowData=dff.to_dicts(),
dangerously_allow_code=True, # rend le HTML <a> des cellules liens
columnSize="responsiveSizeToFit",
dashGridOptions={
"domLayout": "autoHeight", # OK ici : row model client-side
"pagination": True,
"paginationPageSize": 10,
"suppressCellFocus": True,
"localeText": AG_GRID_LOCALE_FR,
"theme": {
"function": (
"themeQuartz.withParams({"
"accentColor: 'rgb(179, 56, 33)',"
"headerTextColor: 'white',"
"headerBackgroundColor: 'rgb(179, 56, 33)',"
"oddRowBackgroundColor: 'rgba(255, 240, 240, 0.4)',"
"borderColor: '#ccc',"
"fontFamily: 'Inter, sans-serif',"
"fontSize: 16"
"})"
)
},
},
style={"width": "100%"},
persistence=False,
)
# Libellés du menu de filtre AG Grid, traduits en français (option native
# AG Grid localeText, exposée via dashGridOptions — n'affecte pas l'apparence
# de base conservée au Lot 1, seulement le texte).
AG_GRID_LOCALE_FR = {
# Filtres texte / nombre / date
"contains": "Contient",
"notContains": "Ne contient pas",
"equals": "Égal à",
"notEqual": "Différent de",
"startsWith": "Commence par",
"endsWith": "Se termine par",
"blank": "Vide",
"notBlank": "Non vide",
"empty": "Choisir une option",
"lessThan": "Inférieur à",
"lessThanOrEqual": "Inférieur ou égal à",
"greaterThan": "Supérieur à",
"greaterThanOrEqual": "Supérieur ou égal à",
"inRange": "Entre",
"inRangeStart": "de",
"inRangeEnd": "à",
"before": "Avant",
"after": "Après",
"to": "à",
"of": "de",
"pageSizeSelectorLabel": "Lignes par page",
# Chrome du menu de filtre
"filterOoo": "Filtrer...",
"applyFilter": "Appliquer",
"resetFilter": "Réinitialiser",
"clearFilter": "Effacer",
"cancelFilter": "Annuler",
"andCondition": "ET",
"orCondition": "OU",
"noRowsToShow": "Aucune ligne à afficher",
"loadingOoo": "Chargement...",
}
def ag_grid(
grid_id: "str | dict",
column_defs: list[dict],
persisted_props=("filterModel", "columnState"),
persistence: bool = True,
event_listeners: "dict | None" = None,
) -> "dag.AgGrid":
"""Grille AG Grid server-side (infinite) pour la page Tableau. """Grille AG Grid server-side (infinite) pour la page Tableau.
Apparence de base d'AG Grid (aucun thème custom au Lot 1). Thème aligné sur les dash_table.DataTable du reste du site (en-tête
rouge brique, lignes alternées, police Inter) ; libellés de filtre
traduits en français via localeText.
""" """
return dag.AgGrid( return dag.AgGrid(
id=grid_id, id=grid_id,
columnDefs=column_defs, columnDefs=column_defs,
defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True}, defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True},
eventListeners=event_listeners,
rowModelType="infinite", rowModelType="infinite",
dangerously_allow_code=True, # rend le HTML <a> des cellules liens dangerously_allow_code=True, # rend le HTML <a> des cellules liens
dashGridOptions={ dashGridOptions={
@@ -1085,11 +1204,47 @@ def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
"maxBlocksInCache": 10, "maxBlocksInCache": 10,
"rowBuffer": 0, "rowBuffer": 0,
"infiniteInitialRowCount": 100, "infiniteInitialRowCount": 100,
# rowHeight fixe (pas autoHeight, non supporté par rowModelType
# "infinite") pour laisser la place au texte replié à la ligne
# de la colonne "objet" (cf. grid_column_defs).
"rowHeight": 60,
"suppressCellFocus": True, "suppressCellFocus": True,
"enableCellTextSelection": True,
"ensureDomOrder": True,
# Permet de sélectionner/copier le texte des infobulles (ex.
# colonne "objet" tronquée, cf. tooltipField dans grid_column_defs).
"tooltipInteraction": True,
# Délai avant apparition des infobulles natives (200ms au lieu
# des 2000ms par défaut d'AG Grid).
"tooltipShowDelay": 200,
"localeText": AG_GRID_LOCALE_FR,
"theme": {
"function": (
"themeQuartz.withParams({"
"accentColor: 'rgb(179, 56, 33)',"
"headerTextColor: 'white',"
"headerBackgroundColor: 'rgb(179, 56, 33)',"
"oddRowBackgroundColor: 'rgba(255, 240, 240, 0.4)',"
"borderColor: '#ccc',"
"fontFamily: 'Inter, sans-serif',"
# "headerFontFamily: '\"Inter Tight\", sans-serif',"
"fontSize: 16"
"})"
)
},
# Réserve l'espace de la barre de défilement horizontale en permanence
# (comportement natif de Chrome). Sans effet sur Firefox : le curseur
# de la barre y reste en overlay au survol, géré par l'OS/le navigateur
# (ex. réglage GTK overlay-scrollbars), non contrôlable par l'appli.
"alwaysShowHorizontalScroll": True,
}, },
columnSize="responsiveSizeToFit", # Pas de columnSize="responsiveSizeToFit" : les colonnes gardent leur
# largeur définie dans columnDefs (cf. _column_width dans
# src.utils.grid) même à 50 colonnes affichées, quitte à faire
# apparaître le défilement horizontal natif d'AG Grid plutôt que de
# les compresser/étirer toutes à la même largeur.
style={"height": "70vh", "width": "100%"}, style={"height": "70vh", "width": "100%"},
persistence=True, persistence=persistence,
persistence_type="local", persistence_type="local",
persisted_props=["filterModel", "columnState"], persisted_props=list(persisted_props),
) )
+48
View File
@@ -0,0 +1,48 @@
import os
from flask import Blueprint, redirect, request, session
from flask_login import current_user
from src.api import tokens_db
mcp_account_bp = Blueprint("mcp_account", __name__)
_LABEL_MAX = 100
def _has_active_subscription() -> bool:
# Réutilise la vérification canonique (gère TOUS_ABONNES).
from src.pages._compte_shell import current_user_has_subscription
return current_user_has_subscription()
def _guard():
"""Renvoie une redirection si l'accès est refusé, sinon None."""
if not current_user.is_authenticated:
return redirect("/connexion?next=/compte/mcp")
if not _has_active_subscription():
return redirect("/compte/abonnement")
return None
@mcp_account_bp.route("/compte/mcp/creer", methods=["POST"])
def creer():
denied = _guard()
if denied is not None:
return denied
label = (request.form.get("label") or "").strip()[:_LABEL_MAX] or "Sans nom"
token, _ = tokens_db.create_token(
os.environ["USERS_DB_PATH"], label, user_id=current_user.id, kind="mcp"
)
session["mcp_new_token"] = token
return redirect("/compte/mcp")
@mcp_account_bp.route("/compte/mcp/revoquer/<int:token_id>", methods=["POST"])
def revoquer(token_id):
denied = _guard()
if denied is not None:
return denied
tokens_db.revoke_user_token(os.environ["USERS_DB_PATH"], token_id, current_user.id)
return redirect("/compte/mcp")
+102
View File
@@ -0,0 +1,102 @@
import os
from datetime import datetime, timezone
from flask import Flask, jsonify, request
from src.api import tokens_db
from src.mcp import usage
from src.mcp.oauth import metadata, store
from src.subscriptions.db import has_active_subscription
from src.utils import TOUS_ABONNES
def _base() -> str:
return os.getenv("APP_BASE_URL", "").rstrip("/")
def _resource_metadata_url() -> str:
return f"{_base()}/.well-known/oauth-protected-resource/_mcp"
def _unauthorized():
resp = jsonify(
{"error": "unauthorized", "message": "Jeton MCP absent ou invalide."}
)
resp.status_code = 401
resp.headers["WWW-Authenticate"] = (
f'Bearer realm="colibre-mcp", resource_metadata="{_resource_metadata_url()}"'
)
return resp
def _forbidden():
resp = jsonify(
{
"error": "no_active_subscription",
"message": "Un abonnement colibre actif est requis pour le connecteur MCP.",
}
)
resp.status_code = 403
return resp
def _expired(iso_ts: str) -> bool:
return datetime.fromisoformat(iso_ts) < datetime.now(timezone.utc)
def _authorize_static(db_path, token):
row = tokens_db.get_token_by_plaintext(db_path, token)
if row is None or row["revoked_at"] is not None or row["kind"] != "mcp":
return None, None
return row["user_id"], ("static", row["id"])
def _authorize_oauth(db_path, token):
row = store.get_token_by_access(db_path, token)
if row is None or row["revoked_at"] is not None:
return None, None
if _expired(row["access_expires_at"]):
return None, None
if row["resource"] != metadata.mcp_resource(_base()):
return None, None
return row["user_id"], ("oauth", row["id"])
def _authenticate_mcp():
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return _unauthorized()
token = header[len("Bearer ") :].strip()
if not token:
return _unauthorized()
db_path = os.environ["USERS_DB_PATH"]
if token.startswith("colibre_"):
user_id, meta = _authorize_static(db_path, token)
else:
user_id, meta = _authorize_oauth(db_path, token)
if meta is None:
return _unauthorized()
if user_id is None:
return _forbidden()
if not (TOUS_ABONNES or has_active_subscription(user_id)):
return _forbidden()
kind, token_id = meta
if kind == "static":
tokens_db.increment_usage(db_path, token_id)
else:
store.increment_usage(db_path, token_id)
usage.record(db_path, user_id, token_id, kind)
return None
def init_mcp_auth(server: Flask) -> None:
"""Enregistre le garde d'authentification du serveur MCP (/_mcp)."""
@server.before_request
def _guard_mcp():
if request.path == "/_mcp" or request.path.startswith("/_mcp/"):
return _authenticate_mcp()
return None
View File
+44
View File
@@ -0,0 +1,44 @@
from urllib.parse import urlencode
from flask import redirect, request
from flask_login import current_user
from flask_wtf.csrf import generate_csrf
from src.mcp.oauth import consent
def _login_redirect():
target = f"/oauth/authorize?{urlencode(request.args)}"
return redirect(f"/connexion?next={target}")
def authorize():
from src.mcp.oauth.routes import _server
if not current_user.is_authenticated:
return _login_redirect()
if not consent.subscription_ok(int(current_user.id)):
return consent.render_subscription_required(), 403
if request.method == "GET":
grant = _server.get_consent_grant(end_user=current_user)
client = grant.client
scope = grant.request.scope or "mcp"
return consent.render_consent(
client.client_metadata.get("client_name", client.get_client_id()),
grant.request.redirect_uri or client.get_default_redirect_uri(),
scope,
csrf_token=generate_csrf(),
)
# POST
if request.form.get("confirm") != "yes":
return _server.create_authorization_response(grant_user=None)
return _server.create_authorization_response(grant_user=int(current_user.id))
def token():
from src.mcp.oauth.routes import _server
return _server.create_token_response()
+40
View File
@@ -0,0 +1,40 @@
from urllib.parse import urlparse
from markupsafe import escape
from src.subscriptions.db import has_active_subscription
from src.utils import TOUS_ABONNES
def subscription_ok(user_id: int) -> bool:
return bool(TOUS_ABONNES or has_active_subscription(user_id))
def render_subscription_required() -> str:
return (
"<!doctype html><html lang=fr><head><meta charset=utf-8>"
"<title>Abonnement requis — colibre</title></head><body>"
"<h1>Abonnement requis</h1>"
"<p>Le connecteur MCP colibre nécessite un abonnement actif.</p>"
'<p><a href="/compte/abonnement">Gérer mon abonnement</a></p>'
"</body></html>"
)
def render_consent(
client_name: str, redirect_uri: str, scope: str, csrf_token: str = ""
) -> str:
host = urlparse(redirect_uri).netloc or redirect_uri
return (
"<!doctype html><html lang=fr><head><meta charset=utf-8>"
"<title>Autoriser l'accès — colibre</title></head><body>"
f"<h1>Autoriser {escape(client_name)} ?</h1>"
f"<p><strong>{escape(client_name)}</strong> demande à lire les données "
"colibre en votre nom via le connecteur MCP.</p>"
f"<p>Vous serez redirigé vers <strong>{escape(host)}</strong>.</p>"
'<form method="post">'
f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
'<button name="confirm" value="yes" type="submit">Autoriser</button> '
'<button name="confirm" value="no" type="submit">Refuser</button>'
"</form></body></html>"
)
+31
View File
@@ -0,0 +1,31 @@
SCOPES = ["mcp", "offline_access"]
def mcp_resource(base_url: str) -> str:
return f"{base_url.rstrip('/')}/_mcp"
def protected_resource_metadata(base_url: str) -> dict:
base = base_url.rstrip("/")
return {
"resource": mcp_resource(base),
"authorization_servers": [base],
"scopes_supported": SCOPES,
"bearer_methods_supported": ["header"],
}
def authorization_server_metadata(base_url: str) -> dict:
base = base_url.rstrip("/")
return {
"issuer": base,
"authorization_endpoint": f"{base}/oauth/authorize",
"token_endpoint": f"{base}/oauth/token",
"registration_endpoint": f"{base}/oauth/register",
"revocation_endpoint": f"{base}/oauth/revoke",
"scopes_supported": SCOPES,
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
}
+50
View File
@@ -0,0 +1,50 @@
import os
from flask import Blueprint, jsonify
from src.mcp.oauth import metadata, server
oauth_bp = Blueprint("mcp_oauth", __name__)
_server = None # AuthorizationServer, initialisé par init_oauth
def _base() -> str:
return os.getenv("APP_BASE_URL", "").rstrip("/")
@oauth_bp.route("/.well-known/oauth-protected-resource")
@oauth_bp.route("/.well-known/oauth-protected-resource/_mcp")
def protected_resource():
return jsonify(metadata.protected_resource_metadata(_base()))
@oauth_bp.route("/.well-known/oauth-authorization-server")
@oauth_bp.route("/.well-known/oauth-authorization-server/_mcp")
@oauth_bp.route("/.well-known/openid-configuration")
def as_metadata():
return jsonify(metadata.authorization_server_metadata(_base()))
@oauth_bp.route("/oauth/register", methods=["POST"])
def register():
return _server.create_endpoint_response("client_registration")
@oauth_bp.route("/oauth/revoke", methods=["POST"])
def revoke():
return _server.create_endpoint_response("revocation")
def init_oauth(app) -> None:
global _server
_server = server.create_authorization_server(app)
from src.mcp.oauth.authorize import authorize as _authorize # Task 9
from src.mcp.oauth.authorize import token as _token # Task 9
app.add_url_rule(
"/oauth/authorize", "oauth_authorize", _authorize, methods=["GET", "POST"]
)
app.add_url_rule("/oauth/token", "oauth_token", _token, methods=["POST"])
app.register_blueprint(oauth_bp)
+244
View File
@@ -0,0 +1,244 @@
import os
import time
from datetime import datetime, timezone
from authlib.integrations.flask_oauth2 import AuthorizationServer
from authlib.oauth2.rfc6749 import grants
from authlib.oauth2.rfc7009 import RevocationEndpoint
from authlib.oauth2.rfc7591 import ClientRegistrationEndpoint
from authlib.oauth2.rfc7636 import CodeChallenge
from src.mcp.oauth import metadata, store
from src.utils import DEVELOPMENT
ACCESS_TTL = 3600 # 1 h
REFRESH_TTL = 5184000 # 60 j
if DEVELOPMENT:
# authlib refuse tout URI non-https (InsecureTransportError), y compris
# http://colibre.fr utilisé par le client de test Flask (pas de TLS local).
# Comportement identique à SESSION_COOKIE_SECURE = not DEVELOPMENT dans
# src/auth/setup.py : ne s'applique jamais en production (DEVELOPMENT=false).
os.environ.setdefault("AUTHLIB_INSECURE_TRANSPORT", "1")
def _db() -> str:
return os.environ["USERS_DB_PATH"]
def _base() -> str:
return os.getenv("APP_BASE_URL", "").rstrip("/")
def _iso(epoch: float) -> str:
return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="seconds")
def _expired(iso_ts: str) -> bool:
return datetime.fromisoformat(iso_ts) < datetime.now(timezone.utc)
class _Client:
def __init__(self, row: dict):
self.client_id = row["client_id"]
self.client_metadata = row["client_metadata"]
def get_client_id(self):
return self.client_id
def get_default_redirect_uri(self):
uris = self.client_metadata.get("redirect_uris") or []
return uris[0] if uris else None
def get_allowed_scope(self, scope):
if not scope:
return ""
allowed = set((self.client_metadata.get("scope") or "").split())
return " ".join(s for s in scope.split() if s in allowed)
def check_redirect_uri(self, redirect_uri):
return redirect_uri in (self.client_metadata.get("redirect_uris") or [])
def check_client_secret(self, client_secret):
return False # client public
def check_endpoint_auth_method(self, method, endpoint):
return method == "none"
def check_response_type(self, response_type):
return response_type == "code"
def check_grant_type(self, grant_type):
return grant_type in ("authorization_code", "refresh_token")
def query_client(client_id):
row = store.get_client(_db(), client_id)
return _Client(row) if row else None
def save_token(token, request):
resource = (
request.form.get("resource")
or request.args.get("resource")
or metadata.mcp_resource(_base())
)
now = time.time()
store.save_token(
_db(),
access_token=token["access_token"],
refresh_token=token.get("refresh_token"),
client_id=request.client.get_client_id(),
user_id=int(request.user),
scope=token.get("scope", ""),
resource=resource,
access_expires_at=_iso(now + token.get("expires_in", ACCESS_TTL)),
refresh_expires_at=_iso(now + REFRESH_TTL),
)
class _AuthCode:
def __init__(self, row: dict):
self._row = row
self._raw_code = None
self.user_id = row["user_id"]
self.code_challenge = row["code_challenge"]
self.code_challenge_method = row["code_challenge_method"]
def get_redirect_uri(self):
return self._row["redirect_uri"]
def get_scope(self):
return self._row["scope"] or ""
class _Token(dict):
"""Wraps an ``oauth_tokens`` row (a plain dict from ``store``) so it also
satisfies authlib's TokenMixin-ish protocol (``check_client``/
``get_scope``) needed by RefreshTokenGrant/RevocationEndpoint, while
still supporting the dict-style ``row["..."]`` access used elsewhere in
this module (e.g. ``credential["user_id"]``, ``token["id"]``).
"""
def check_client(self, client):
return self["client_id"] == client.get_client_id()
def get_scope(self):
return self["scope"] or ""
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
TOKEN_ENDPOINT_AUTH_METHODS = ["none"]
def save_authorization_code(self, code, request):
store.save_code(
_db(),
code,
client_id=request.client.get_client_id(),
user_id=int(request.user),
redirect_uri=request.payload.redirect_uri,
code_challenge=request.payload.data.get("code_challenge"),
code_challenge_method=request.payload.data.get("code_challenge_method"),
scope=request.scope,
resource=request.payload.data.get("resource")
or metadata.mcp_resource(_base()),
expires_at=_iso(time.time() + 300),
)
def query_authorization_code(self, code, client):
row = store.get_code(_db(), code)
if (
row
and row["client_id"] == client.get_client_id()
and not _expired(row["expires_at"])
):
obj = _AuthCode(row)
obj._raw_code = code
return obj
return None
def delete_authorization_code(self, authorization_code):
# code_challenge est unique par code ; on supprime par le hash côté store
# via le code d'origine reconstruit impossible → on stocke le code clair
# dans l'objet. Ici authorization_code provient de query ; on supprime par
# la ligne complète.
store.delete_code(_db(), authorization_code._raw_code)
def authenticate_user(self, authorization_code):
return authorization_code.user_id
class RefreshTokenGrant(grants.RefreshTokenGrant):
TOKEN_ENDPOINT_AUTH_METHODS = ["none"]
INCLUDE_NEW_REFRESH_TOKEN = True # rotation
def authenticate_refresh_token(self, refresh_token):
from src.mcp.oauth.consent import subscription_ok
row = store.get_token_by_refresh(_db(), refresh_token)
if not row or row["revoked_at"] is not None:
return None
if row["refresh_expires_at"] and _expired(row["refresh_expires_at"]):
return None
# Re-vérifie l'abonnement au refresh ici (pas dans authenticate_user) :
# authlib traite un authenticate_refresh_token → None comme
# InvalidGrantError (invalid_grant), alors qu'un authenticate_user →
# None y devient InvalidRequestError (invalid_request). Claude exige
# invalid_grant pour redéclencher le flux OAuth quand l'abonnement
# est perdu.
if not subscription_ok(row["user_id"]):
return None
return _Token(row)
def authenticate_user(self, credential):
return credential["user_id"]
def revoke_old_credential(self, credential):
store.revoke_token(_db(), credential["id"])
class _RegistrationEndpoint(ClientRegistrationEndpoint):
def authenticate_token(self, request):
# DCR ouverte (client public) : pas de jeton d'enregistrement requis.
return True
def resolve_public_key(self, request):
return None
def get_server_metadata(self):
return metadata.authorization_server_metadata(_base())
def save_client(self, client_info, client_metadata, request):
client_id = client_info["client_id"]
store.create_client(_db(), client_id, dict(client_metadata))
return _Client(
{"client_id": client_id, "client_metadata": dict(client_metadata)}
)
class _RevocationEndpoint(RevocationEndpoint):
# Clients publics (DCR, pas de secret) : authentification "none", comme
# pour les grants authorization_code/refresh_token ci-dessus.
CLIENT_AUTH_METHODS = ["none"]
def query_token(self, token_string, token_type_hint):
row = store.get_token_by_access(
_db(), token_string
) or store.get_token_by_refresh(_db(), token_string)
return _Token(row) if row else None
def revoke_token(self, token, request):
store.revoke_token(_db(), token["id"])
def create_authorization_server(app) -> AuthorizationServer:
# authlib n'émet un refresh_token que si ce flag est activé (défaut: False) ;
# requis puisque RefreshTokenGrant est enregistré ci-dessous et que le scope
# "offline_access" (DCR) suppose l'émission d'un refresh_token.
app.config.setdefault("OAUTH2_REFRESH_TOKEN_GENERATOR", True)
server = AuthorizationServer(app, query_client=query_client, save_token=save_token)
server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)])
server.register_grant(RefreshTokenGrant)
server.register_endpoint(_RegistrationEndpoint)
server.register_endpoint(_RevocationEndpoint)
return server
+207
View File
@@ -0,0 +1,207 @@
import hashlib
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
SCHEMA = """
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id TEXT PRIMARY KEY,
client_metadata TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS oauth_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
redirect_uri TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
scope TEXT,
resource TEXT,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY,
access_token_hash TEXT NOT NULL UNIQUE,
refresh_token_hash TEXT UNIQUE,
client_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
scope TEXT,
resource TEXT NOT NULL,
issued_at TEXT NOT NULL,
access_expires_at TEXT NOT NULL,
refresh_expires_at TEXT,
revoked_at TEXT,
count_total INTEGER NOT NULL DEFAULT 0,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash);
"""
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _hash(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
@contextmanager
def _connect(db_path):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def init_schema(db_path) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
with _connect(db_path) as conn:
conn.executescript(SCHEMA)
conn.commit()
def create_client(db_path, client_id: str, metadata: dict) -> None:
with _connect(db_path) as conn:
conn.execute(
"INSERT INTO oauth_clients (client_id, client_metadata, created_at) "
"VALUES (?, ?, ?)",
(client_id, json.dumps(metadata), _utcnow_iso()),
)
conn.commit()
def get_client(db_path, client_id: str) -> dict | None:
with _connect(db_path) as conn:
row = conn.execute(
"SELECT * FROM oauth_clients WHERE client_id = ?", (client_id,)
).fetchone()
if row is None:
return None
d = dict(row)
d["client_metadata"] = json.loads(d["client_metadata"])
return d
def save_code(
db_path,
code: str,
*,
client_id: str,
user_id: int,
redirect_uri: str | None,
code_challenge: str | None,
code_challenge_method: str | None,
scope: str | None,
resource: str | None,
expires_at: str,
) -> None:
with _connect(db_path) as conn:
conn.execute(
"INSERT INTO oauth_codes (code_hash, client_id, user_id, redirect_uri, "
"code_challenge, code_challenge_method, scope, resource, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
_hash(code),
client_id,
user_id,
redirect_uri,
code_challenge,
code_challenge_method,
scope,
resource,
expires_at,
),
)
conn.commit()
def get_code(db_path, code: str) -> dict | None:
with _connect(db_path) as conn:
row = conn.execute(
"SELECT * FROM oauth_codes WHERE code_hash = ?", (_hash(code),)
).fetchone()
return dict(row) if row else None
def delete_code(db_path, code: str) -> None:
with _connect(db_path) as conn:
conn.execute("DELETE FROM oauth_codes WHERE code_hash = ?", (_hash(code),))
conn.commit()
def save_token(
db_path,
*,
access_token: str,
refresh_token: str | None,
client_id: str,
user_id: int,
scope: str | None,
resource: str,
access_expires_at: str,
refresh_expires_at: str | None,
) -> int:
with _connect(db_path) as conn:
cur = conn.execute(
"INSERT INTO oauth_tokens (access_token_hash, refresh_token_hash, "
"client_id, user_id, scope, resource, issued_at, access_expires_at, "
"refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
_hash(access_token),
_hash(refresh_token) if refresh_token else None,
client_id,
user_id,
scope,
resource,
_utcnow_iso(),
access_expires_at,
refresh_expires_at,
),
)
conn.commit()
return cur.lastrowid
def get_token_by_access(db_path, access_token: str) -> dict | None:
with _connect(db_path) as conn:
row = conn.execute(
"SELECT * FROM oauth_tokens WHERE access_token_hash = ?",
(_hash(access_token),),
).fetchone()
return dict(row) if row else None
def get_token_by_refresh(db_path, refresh_token: str) -> dict | None:
with _connect(db_path) as conn:
row = conn.execute(
"SELECT * FROM oauth_tokens WHERE refresh_token_hash = ?",
(_hash(refresh_token),),
).fetchone()
return dict(row) if row else None
def revoke_token(db_path, token_id: int) -> None:
with _connect(db_path) as conn:
conn.execute(
"UPDATE oauth_tokens SET revoked_at = ? WHERE id = ?",
(_utcnow_iso(), token_id),
)
conn.commit()
def increment_usage(db_path, token_id: int) -> None:
with _connect(db_path) as conn:
conn.execute(
"UPDATE oauth_tokens SET count_total = count_total + 1, last_used_at = ? "
"WHERE id = ?",
(_utcnow_iso(), token_id),
)
conn.commit()
+57 -5
View File
@@ -1,11 +1,13 @@
# src/mcp/queries.py # src/mcp/queries.py
import os
import re import re
from typing import Literal
from src.api.filters import FilterError, build_where from src.api.filters import OPERATORS, FilterError, build_where
from src.db import aggregate_marches, count_marches, query_marches from src.db import aggregate_marches, count_marches, query_marches
from src.db import schema as duckdb_schema from src.db import schema as duckdb_schema
from src.mcp.serialization import to_json_records from src.mcp.serialization import to_json_records
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES from src.utils.data import DATA_SCHEMA, DF_ACHETEURS, DF_TITULAIRES
from src.utils.search import search_org from src.utils.search import search_org
PAGE_SIZE = 50 PAGE_SIZE = 50
@@ -26,12 +28,21 @@ MARCHES_COLUMNS = [
"titulaire_nom", "titulaire_nom",
] ]
# 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]
# (param nommé, colonne decp, opérateur du moteur de filtres API). # (param nommé, colonne decp, opérateur du moteur de filtres API).
# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =. # `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =.
_NAMED_FILTERS = [ _NAMED_FILTERS = [
("acheteur_id", "acheteur_id", "exact"), ("acheteur_id", "acheteur_id", "exact"),
("titulaire_id", "titulaire_id", "exact"), ("titulaire_id", "titulaire_id", "exact"),
("cpv", "codeCPV", "contains"), ("cpv", "codeCPV", "startswith"),
("objet_contient", "objet", "contains"), ("objet_contient", "objet", "contains"),
("montant_min", "montant", "greater"), ("montant_min", "montant", "greater"),
("montant_max", "montant", "less"), ("montant_max", "montant", "less"),
@@ -41,6 +52,31 @@ _NAMED_FILTERS = [
] ]
def describe_schema() -> dict:
"""Schéma des marchés (même source que /schema de l'API REST).
Limité aux colonnes réellement filtrables via filtres_avances (présentes
dans la table DuckDB). L'agrégation (groupby/sum/...) n'est pas supportée
ici : c'est une fonctionnalité de l'API REST /data.
"""
colonnes = {
name: {
"type": field.get("type"),
"titre": field.get("title"),
"description": field.get("description"), # inclut les énumérations
}
for name, field in DATA_SCHEMA.items()
if name in duckdb_schema # exclut la colonne virtuelle "marche"
}
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},
}
def build_where_args( def build_where_args(
named: dict, filtres_avances: dict | None named: dict, filtres_avances: dict | None
) -> list[tuple[str, str]]: ) -> list[tuple[str, str]]:
@@ -69,6 +105,7 @@ def search_marches(
departement: str | None = None, departement: str | None = None,
page: int = 1, page: int = 1,
filtres_avances: dict | None = None, filtres_avances: dict | None = None,
colonnes: list[str] | None = None,
) -> dict: ) -> dict:
"""Recherche paginée de marchés. Même sémantique de filtres que l'API REST.""" """Recherche paginée de marchés. Même sémantique de filtres que l'API REST."""
named = { named = {
@@ -88,21 +125,36 @@ def search_marches(
except FilterError as e: except FilterError as e:
return {"error": str(e), "champ": e.field} 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) ; dédoublonne
# toute la liste (un client peut répéter une colonne malgré l'enum, ce
# qui produirait des noms de colonnes dupliqués au SELECT).
out_columns = list(dict.fromkeys(["uid", *colonnes]))
page = max(1, int(page)) page = max(1, int(page))
offset = (page - 1) * PAGE_SIZE offset = (page - 1) * PAGE_SIZE
order_by = order_sql or '"dateNotification" DESC, "uid" DESC' order_by = order_sql or '"dateNotification" DESC, "uid" DESC'
df = query_marches( df = query_marches(
where_sql, where_sql,
params, params,
columns=MARCHES_COLUMNS, columns=out_columns,
order_by=order_by, order_by=order_by,
limit=PAGE_SIZE, limit=PAGE_SIZE,
offset=offset, offset=offset,
) )
total = count_marches(where_sql, params) 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 { return {
"meta": {"page": page, "page_size": PAGE_SIZE, "total": total}, "meta": {"page": page, "page_size": PAGE_SIZE, "total": total},
"marches": to_json_records(df), "marches": marches,
} }
+31 -3
View File
@@ -1,6 +1,7 @@
from dash.mcp import mcp_enabled from dash.mcp import mcp_enabled
from src.mcp import queries from src.mcp import queries
from src.mcp.queries import ColonneMarche
from src.utils.tracking import track_mcp_tool from src.utils.tracking import track_mcp_tool
@@ -45,6 +46,22 @@ def stats_titulaire(titulaire_id: str) -> dict:
return queries.compute_org_stats("titulaire", titulaire_id) return queries.compute_org_stats("titulaire", titulaire_id)
@mcp_enabled(name="schema_donnees", expose_docstring=True)
def schema_donnees() -> dict:
"""Schéma des données marchés (DECP) pour construire des filtres.
À consulter avant d'utiliser `filtres_avances` de rechercher_marches.
- colonnes_filtrables : {colonne: {type, titre, description}}, utilisables
comme "colonne__operateur" (la description inclut les valeurs possibles).
- colonnes_retournees : colonnes présentes dans chaque marché renvoyé.
- operateurs : opérateurs de filtre valides (exact, contains, greater, less,
in, isnull, sort). L'agrégation n'est pas supportée ici (API REST /data).
- filtres_nommes : correspondance paramètre nommé -> "colonne__operateur".
"""
track_mcp_tool("schema_donnees")
return queries.describe_schema()
@mcp_enabled(name="rechercher_marches", expose_docstring=True) @mcp_enabled(name="rechercher_marches", expose_docstring=True)
def rechercher_marches( def rechercher_marches(
acheteur_id: str | None = None, acheteur_id: str | None = None,
@@ -58,15 +75,25 @@ def rechercher_marches(
departement: str | None = None, departement: str | None = None,
page: int = 1, page: int = 1,
filtres_avances: dict | None = None, filtres_avances: dict | None = None,
colonnes: list[ColonneMarche] | None = None,
) -> dict: ) -> dict:
"""Recherche paginée de marchés publics (DECP). """Recherche paginée de marchés publics (DECP).
Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, filtre par
partielle), objet_contient (texte de l'objet), montant_min, montant_max, préfixe ex. « 72 » = informatique, « 7220 » = logiciels),
objet_contient (texte de l'objet), montant_min, montant_max,
date_min / date_max (format YYYY-MM-DD, sur dateNotification), date_min / date_max (format YYYY-MM-DD, sur dateNotification),
departement (code département de l'acheteur). departement (code département de l'acheteur).
filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les
besoins pointus (mêmes colonnes/opérateurs que l'API REST colibre). 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). page : numéro de page (50 résultats par page).
Retourne {meta: {page, page_size, total}, marches: [...]}. Retourne {meta: {page, page_size, total}, marches: [...]}.
""" """
@@ -83,4 +110,5 @@ def rechercher_marches(
departement=departement, departement=departement,
page=page, page=page,
filtres_avances=filtres_avances, filtres_avances=filtres_avances,
colonnes=colonnes,
) )
+68
View File
@@ -0,0 +1,68 @@
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
SCHEMA = """
CREATE TABLE IF NOT EXISTS mcp_usage (
id INTEGER PRIMARY KEY,
user_id INTEGER,
token_id INTEGER,
kind TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mcp_usage_user ON mcp_usage(user_id, created_at);
"""
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@contextmanager
def _connect(db_path):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def init_schema(db_path) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
with _connect(db_path) as conn:
conn.executescript(SCHEMA)
conn.commit()
def record(db_path, user_id: int, token_id: int, kind: str) -> None:
"""Journalise une requête /_mcp authentifiée. Best-effort : n'échoue jamais."""
try:
with _connect(db_path) as conn:
conn.execute(
"INSERT INTO mcp_usage (user_id, token_id, kind, created_at) "
"VALUES (?, ?, ?, ?)",
(user_id, token_id, kind, _utcnow_iso()),
)
conn.commit()
except sqlite3.Error:
pass
def count_since(db_path, user_id: int, iso_ts: str) -> int:
with _connect(db_path) as conn:
row = conn.execute(
"SELECT COUNT(*) FROM mcp_usage WHERE user_id = ? AND created_at >= ?",
(user_id, iso_ts),
).fetchone()
return row[0]
def purge_older_than(db_path, days: int = 90) -> None:
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(
timespec="seconds"
)
with _connect(db_path) as conn:
conn.execute("DELETE FROM mcp_usage WHERE created_at < ?", (cutoff,))
conn.commit()
+48 -2
View File
@@ -42,6 +42,46 @@ _MIGRATIONS: list[tuple[str, str]] = [
"details TEXT, " "details TEXT, "
"created_at TEXT NOT NULL)", "created_at TEXT NOT NULL)",
), ),
(
"0007_add_kind_to_api_tokens",
"ALTER TABLE api_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api'",
),
(
"0008_create_oauth_clients",
"CREATE TABLE IF NOT EXISTS oauth_clients ("
"client_id TEXT PRIMARY KEY, client_metadata TEXT NOT NULL, "
"created_at TEXT NOT NULL)",
),
(
"0009_create_oauth_codes",
"CREATE TABLE IF NOT EXISTS oauth_codes ("
"code_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, user_id INTEGER NOT NULL, "
"redirect_uri TEXT, code_challenge TEXT, code_challenge_method TEXT, "
"scope TEXT, resource TEXT, expires_at TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0)",
),
(
"0010_create_oauth_tokens",
"CREATE TABLE IF NOT EXISTS oauth_tokens ("
"id INTEGER PRIMARY KEY, access_token_hash TEXT NOT NULL UNIQUE, "
"refresh_token_hash TEXT UNIQUE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL, "
"scope TEXT, resource TEXT NOT NULL, issued_at TEXT NOT NULL, "
"access_expires_at TEXT NOT NULL, refresh_expires_at TEXT, revoked_at TEXT, "
"count_total INTEGER NOT NULL DEFAULT 0, last_used_at TEXT)",
),
(
"0011_create_mcp_usage",
"CREATE TABLE IF NOT EXISTS mcp_usage ("
"id INTEGER PRIMARY KEY, user_id INTEGER, token_id INTEGER, "
"kind TEXT NOT NULL, created_at TEXT NOT NULL)",
),
(
"0012_add_token_to_saved_views",
"ALTER TABLE saved_views ADD COLUMN token TEXT",
),
(
"0013_add_token_enc_to_api_tokens",
"ALTER TABLE api_tokens ADD COLUMN token_enc TEXT",
),
] ]
@@ -62,9 +102,15 @@ def apply_pending() -> None:
except sqlite3.OperationalError as exc: except sqlite3.OperationalError as exc:
# Sur une DB fraîche (schéma déjà à jour), certaines migrations # Sur une DB fraîche (schéma déjà à jour), certaines migrations
# sont sans effet : ADD COLUMN → "duplicate column name", # sont sans effet : ADD COLUMN → "duplicate column name",
# RENAME COLUMN → "no such column". On les ignore. # RENAME COLUMN → "no such column", et un ALTER sur une table pas
# encore créée → "no such table" (elle sera créée plus tard par
# init_schema avec la colonne déjà dans SCHEMA).
err = str(exc) err = str(exc)
if "duplicate column name" not in err and "no such column" not in err: if (
"duplicate column name" not in err
and "no such column" not in err
and "no such table" not in err
):
raise raise
conn.execute( conn.execute(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)", "INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)",
+6
View File
@@ -17,6 +17,12 @@ SECTIONS = [
"href": "/compte/roadmap", "href": "/compte/roadmap",
"require_subscription": True, "require_subscription": True,
}, },
{
"key": "mcp",
"label": "Connecteur MCP",
"href": "/compte/mcp",
"require_subscription": True,
},
{ {
"key": "abonnement", "key": "abonnement",
"label": "Abonnement", "label": "Abonnement",
+16 -10
View File
@@ -17,8 +17,10 @@ register_page(
) )
abonnement_features = dcc.Markdown(""" abonnement_features = dcc.Markdown("""
- sauvegarde de vues dans la page Tableau - sauvegardez et partagez des **vues** dans la page Tableau
- vote pour les [fonctionnalités à développer](/a-propos/roadmap) en priorité (une fois la période d'essai terminée) - interrogez les données depuis un agent IA via le **connecteur MCP** (ChatGPT, Claude, Mistral...)
- votez pour les fonctionnalités à développer en priorité dans la **roadmap**
- participez à la pérennité du projet
""") """)
@@ -77,7 +79,7 @@ def _explainer():
"de cette non-publication." "de cette non-publication."
), ),
html.Li( html.Li(
"fédération des bonnes volontés souhaitant militer pour une " "militer aux côtés des bonnes volontés pour une "
"législation plus ambitieuse sur la transparence de la " "législation plus ambitieuse sur la transparence de la "
"commande publique." "commande publique."
), ),
@@ -94,6 +96,7 @@ def _subscribe_button(
authenticated: bool, has_active_subscription: bool, tous_abonnes: bool authenticated: bool, has_active_subscription: bool, tous_abonnes: bool
): ):
if tous_abonnes: if tous_abonnes:
href = "/compte/abonnement" if authenticated else "/inscription"
return html.Div( return html.Div(
[ [
dbc.Alert( dbc.Alert(
@@ -103,9 +106,9 @@ def _subscribe_button(
color="info", color="info",
), ),
html.A( html.A(
"Je m'abonne", "Je m'abonne gratuitement",
href="#", href=href,
className="btn btn-secondary disabled", className="btn btn-secondary",
), ),
], ],
className="text-center my-4", className="text-center my-4",
@@ -117,7 +120,7 @@ def _subscribe_button(
else: else:
label, href = "Je m'abonne", "/inscription" label, href = "Je m'abonne", "/inscription"
return html.Div( return html.Div(
html.A(label, href=href, className="btn btn-primary"), html.A(label, href=href, className="btn btn-secondary"),
className="text-center my-4 btn-lg", className="text-center my-4 btn-lg",
style={"width": "fit-content", "margin": "auto"}, style={"width": "fit-content", "margin": "auto"},
) )
@@ -128,8 +131,7 @@ subscription_terms = html.Div(
html.H2("Abonnement"), html.H2("Abonnement"),
dcc.Markdown( dcc.Markdown(
""" """
L'accès aux fonctionnalités de base de colibre est gratuit et sans inscription. Il est également possible de créer un compte gratuitement via le menu [Connexion](/connexion). Une fois le compte créé, L'accès aux fonctionnalités de base de colibre est gratuit et sans inscription. Il est possible de souscrire à un abonnement mensuel qui donne accès à des fonctionnalités supplémentaires. Cet abonnement s'adresse tant aux professionnel·les qu'aux particuliers.
il est possible de souscrire à un abonnement mensuel qui donne accès à des fonctionnalités supplémentaires. Cet abonnement s'adresse tant aux professionnel·les qu'aux particuliers.
""" """
), ),
html.H4("Tarifs"), html.H4("Tarifs"),
@@ -148,7 +150,7 @@ La TVA applicable en France est de 20 %.
""" """
Les cartes bancaires des réseaux Visa et Mastercard sont acceptées. Les cartes bancaires des réseaux Visa et Mastercard sont acceptées.
Il est également possible de payer par virement bancaire à condition de payer un an d'abonnement. Il est également possible de payer par virement bancaire à condition de payer un an d'abonnement. [Contactez-moi](/a-propos/contact) si vous êtes intéressé·e.
""" """
), ),
html.H4("Période d'essai"), html.H4("Période d'essai"),
@@ -196,6 +198,10 @@ au bon fonctionnement du site et de la facturation.
Ces données sont utilisées uniquement pour la gestion de votre abonnement et ne sont pas transmises à des tiers à des fins commerciales. Ces données sont utilisées uniquement pour la gestion de votre abonnement et ne sont pas transmises à des tiers à des fins commerciales.
Conformément au RGPD, vous pouvez demander l'accès, la rectification ou la suppression de vos données en [me contactant](/a-propos/contact). Conformément au RGPD, vous pouvez demander l'accès, la rectification ou la suppression de vos données en [me contactant](/a-propos/contact).
**Conversations en direct depuis le site Web 💬**
Le contenu des conversations est transmis au prestataire Chatwoot et au logiciel Slack.
""" """
), ),
html.H4("Contact"), html.H4("Contact"),
+1
View File
@@ -18,6 +18,7 @@ def layout(**_):
html.H2("Contact"), html.H2("Contact"),
dcc.Markdown( dcc.Markdown(
""" """
- Chat en direct (💬 en bas à droite de l'écran)
- Email : [colin@colmo.tech](mailto:colin@colmo.tech) - Email : [colin@colmo.tech](mailto:colin@colmo.tech)
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social) - Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m) - Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
+1 -1
View File
@@ -22,7 +22,7 @@ def layout(**_):
dcc.Markdown( dcc.Markdown(
""" """
Les données présentées sur ce site sont publiées par les acheteurs publics dans le cadre de la réglementation DECP (Données Essentielles de la Commande Publique). Les données présentées sur ce site sont publiées par les acheteurs publics dans le cadre de la réglementation DECP (Données Essentielles de la Commande Publique).
Ces données couvrent principalement l'étape d'attribution des marchés publics, qui suit l'étape d'appel d'offres. Ces données couvrent principalement l'étape d'attribution des marchés publics, qui suit l'étape de publicité (l'appel d'offres).
**Données publiées par étape de passsation du marché et par seuil selon la valeur du marché** : **Données publiées par étape de passsation du marché et par seuil selon la valeur du marché** :
+186 -321
View File
@@ -3,12 +3,10 @@ import datetime
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import polars as pl import polars as pl
from dash import ( from dash import (
ClientsideFunction,
Input, Input,
Output, Output,
State, State,
callback, callback,
clientside_callback,
dcc, dcc,
html, html,
register_page, register_page,
@@ -16,26 +14,22 @@ from dash import (
from src.db import aggregate_marches, count_marches, query_marches, schema from src.db import aggregate_marches, count_marches, query_marches, schema
from src.figures import ( from src.figures import (
DataTable,
get_distance_histogram, get_distance_histogram,
get_org_location_map, get_org_location_map,
get_top_org_table, get_top_org_ag_grid,
make_card, make_card,
make_column_picker, make_column_picker,
) )
from src.utils.data import DF_ACHETEURS, get_annuaire_data, get_departement_region from src.utils.data import DF_ACHETEURS, get_annuaire_data, get_departement_region
from src.utils.entity_grid import entity_scope, register_entity_grid_callbacks
from src.utils.frontend import DROPDOWN_LABELS_FR, get_button_properties from src.utils.frontend import DROPDOWN_LABELS_FR, get_button_properties
from src.utils.seo import META_CONTENT from src.utils.seo import META_CONTENT
from src.utils.table import ( from src.utils.table import (
COLUMNS, COLUMNS,
filter_table_data,
format_number, format_number,
get_default_hidden_columns, get_default_hidden_columns,
prepare_table_data,
sort_table_data,
write_styled_excel, write_styled_excel,
) )
from src.utils.tracking import track_search
def get_title(acheteur_id: str | None = None) -> str: def get_title(acheteur_id: str | None = None) -> str:
@@ -49,13 +43,7 @@ def get_title(acheteur_id: str | None = None) -> str:
def _acheteur_scope(pathname: str, ach_year: str | None) -> tuple[str, list]: def _acheteur_scope(pathname: str, ach_year: str | None) -> tuple[str, list]:
"""WHERE SQL scopant les requêtes à cet acheteur (et éventuellement une année).""" """WHERE SQL scopant les requêtes à cet acheteur (et éventuellement une année)."""
acheteur_siret = pathname.split("/")[-1] return entity_scope("acheteur", pathname.split("/")[-1], ach_year)
where_sql = "acheteur_id = ?"
params: list = [acheteur_siret]
if ach_year and ach_year != "Toutes les années":
where_sql += ' AND YEAR("dateNotification") = ?'
params.append(int(ach_year))
return where_sql, params
register_page( register_page(
@@ -68,197 +56,191 @@ register_page(
order=5, order=5,
) )
DATATABLE = html.Div(
className="marches_table",
children=DataTable(
dtid="acheteur_datatable",
persistence=True,
persistence_type="local",
persisted_props=["filter_query", "sort_by"],
page_action="custom",
filter_action="custom",
sort_action="custom",
page_size=10,
hidden_columns=[],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
layout = [ def layout(acheteur_id=None, **kwargs):
dcc.Store(id="acheteur-hidden-columns", storage_type="local"), return [
dcc.Store(id="filter-cleanup-trigger-acheteur"), dcc.Store(id="acheteur-hidden-columns", storage_type="local"),
dcc.Location(id="acheteur_url", refresh="callback-nav"), dcc.Store(id="acheteur-total"),
html.Div( dcc.Store(id="acheteur-total-unique"),
children=[ dcc.Store(id="entity-grid-columns-state", storage_type="local"),
html.Div( dcc.Location(id="acheteur_url", refresh="callback-nav"),
style={"marginBottom": "50px"}, html.Div(
children=[ children=[
dbc.Row( html.Div(
className="mb-2", style={"marginBottom": "50px"},
children=[ children=[
dbc.Col( dbc.Row(
html.H2( className="mb-2",
children=[
dbc.Col(
html.H2(
children=[
html.Span(id="acheteur_siret"),
" - ",
html.Span(id="acheteur_nom"),
],
),
width=8,
),
dbc.Col(
dcc.Dropdown(
id="acheteur_year",
options=["Toutes les années"]
+ [
str(year)
for year in range(
2018,
int(datetime.date.today().year) + 1,
)
],
placeholder="Année",
labels=DROPDOWN_LABELS_FR,
),
width=4,
),
],
),
dbc.Row(
className="mb-2",
children=[
dbc.Col(
className="org_infos",
children=[ children=[
html.Span(id="acheteur_siret"), # TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
" - ", html.P(
html.Span(id="acheteur_nom"), [
"Commune : ",
html.Strong(id="acheteur_commune"),
]
),
html.P(
[
"Département : ",
html.Strong(id="acheteur_departement"),
]
),
html.P(
[
"Région : ",
html.Strong(id="acheteur_region"),
]
),
html.A(
id="acheteur_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
),
], ],
width=4,
), ),
width=8, dbc.Col(
), children=[
dbc.Col( html.P(id="acheteur_titre_stats"),
dcc.Dropdown( html.P(id="acheteur_marches_attribues"),
id="acheteur_year", html.P(id="acheteur_titulaires_differents"),
options=["Toutes les années"] html.Button(
+ [ "Téléchargement au format Excel",
str(year) id="btn-download-data-acheteur",
for year in range( className="btn btn-secondary",
2018, int(datetime.date.today().year) + 1 ),
) dcc.Download(id="download-data-acheteur"),
], ],
placeholder="Année", width=4,
labels=DROPDOWN_LABELS_FR,
), ),
width=4, dbc.Col(
), id="acheteur_map",
], width=4,
), style={"minHeight": "300px"},
dbc.Row( ),
className="mb-2", ],
children=[ ),
dbc.Col( dbc.Row(
className="org_infos", children=[
children=[ dbc.Col(
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc. className="marches_table",
html.P( id="top10_titulaires",
[ width=8,
"Commune : ", style={"minHeight": "420px"},
html.Strong(id="acheteur_commune"), ),
] dbc.Col(
), id="acheteur-distance-histogram",
html.P( width=4,
[ style={"minHeight": "450px"},
"Département : ", ),
html.Strong(id="acheteur_departement"), ],
] ),
), ],
html.P( ),
["Région : ", html.Strong(id="acheteur_region")] # récupérer les données de l'acheteur sur l'api annuaire
), html.H3("Derniers marchés publics attribués"),
html.A( dcc.Loading(
id="acheteur_lien_annuaire", overlay_style={"visibility": "visible", "filter": "blur(2px)"},
children="Plus de détails sur l'Annuaire des entreprises", id="loading-home",
), type="default",
], children=[
width=4, html.Div(
), [
dbc.Col(
children=[
html.P(id="acheteur_titre_stats"),
html.P(id="acheteur_marches_attribues"),
html.P(id="acheteur_titulaires_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-data-acheteur",
className="btn btn-primary",
),
dcc.Download(id="download-data-acheteur"),
],
width=4,
),
dbc.Col(
id="acheteur_map",
width=4,
style={"minHeight": "300px"},
),
],
),
dbc.Row(
children=[
dbc.Col(
className="marches_table",
id="top10_titulaires",
width=8,
style={"minHeight": "420px"},
),
dbc.Col(
id="acheteur-distance-histogram",
width=4,
style={"minHeight": "450px"},
),
],
),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics attribués"),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
dbc.Button(
"Colonnes",
id="acheteur_columns_open",
color="secondary",
size="sm",
className="column_list",
),
dbc.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-filtered-data-acheteur",
color="secondary",
size="sm",
disabled=True,
),
dcc.Download(id="acheteur-download-filtered-data"),
dbc.Button(
"Réinitialiser",
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
id="btn-acheteur-reset",
color="danger",
outline=True,
size="sm",
),
],
className="table-toolbar",
),
html.Div(
html.Span(id="acheteur_nb_rows"),
className="table-meta",
),
dbc.Modal(
[
dbc.ModalHeader(
dbc.ModalTitle("Choix des colonnes à afficher")
),
dbc.ModalBody(
id="acheteur_columns_body",
children=make_column_picker("acheteur"),
),
dbc.ModalFooter(
dbc.Button( dbc.Button(
"Fermer", "Colonnes",
id="acheteur_columns_close", id="acheteur_columns_open",
className="ms-auto", color="secondary",
n_clicks=0, size="sm",
) className="column_list",
), ),
], dbc.Button(
id="acheteur_columns", "Téléchargement désactivé au-delà de 65 000 lignes",
is_open=False, id="btn-download-filtered-data-acheteur",
fullscreen="md-down", color="secondary",
scrollable=True, size="sm",
size="xl", disabled=True,
), ),
DATATABLE, dcc.Download(id="acheteur-download-filtered-data"),
], dbc.Button(
), "Réinitialiser",
], title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
), id="btn-acheteur-reset",
] color="danger",
outline=True,
size="sm",
),
],
className="table-toolbar",
),
html.Div(
html.Span(id="acheteur_nb_rows"),
className="table-meta",
),
dbc.Modal(
[
dbc.ModalHeader(
dbc.ModalTitle("Choix des colonnes à afficher")
),
dbc.ModalBody(
id="acheteur_columns_body",
children=make_column_picker("acheteur"),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="acheteur_columns_close",
className="ms-auto",
n_clicks=0,
)
),
],
id="acheteur_columns",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="xl",
),
html.Div(
id="acheteur-grid-container", className="marches_table"
),
],
),
],
),
]
@callback( @callback(
@@ -367,47 +349,6 @@ def update_download_button_acheteur(pathname, ach_year):
return get_button_properties(count_marches(where_sql, params)) return get_button_properties(count_marches(where_sql, params))
@callback(
Output("acheteur_datatable", "data"),
Output("acheteur_datatable", "columns"),
Output("acheteur_datatable", "tooltip_header"),
Output("acheteur_datatable", "data_timestamp"),
Output("acheteur_nb_rows", "children"),
Output("btn-download-filtered-data-acheteur", "disabled"),
Output("btn-download-filtered-data-acheteur", "children"),
Output("btn-download-filtered-data-acheteur", "title"),
Output("filter-cleanup-trigger-acheteur", "data"),
Input("acheteur_url", "pathname"),
Input("acheteur_year", "value"),
Input("acheteur_datatable", "page_current"),
Input("acheteur_datatable", "page_size"),
Input("acheteur_datatable", "filter_query"),
Input("acheteur_datatable", "sort_by"),
State("acheteur_datatable", "data_timestamp"),
)
def get_last_marches_data(
pathname,
ach_year,
page_current,
page_size,
filter_query,
sort_by,
data_timestamp,
) -> tuple:
where_sql, params = _acheteur_scope(pathname, ach_year)
return prepare_table_data(
None,
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"acheteur",
base_where_sql=where_sql,
base_params=params,
)
@callback( @callback(
Output(component_id="top10_titulaires", component_property="children"), Output(component_id="top10_titulaires", component_property="children"),
Input(component_id="acheteur_url", component_property="pathname"), Input(component_id="acheteur_url", component_property="pathname"),
@@ -415,7 +356,7 @@ def get_last_marches_data(
) )
def get_top_titulaires(pathname, ach_year): def get_top_titulaires(pathname, ach_year):
where_sql, params = _acheteur_scope(pathname, ach_year) where_sql, params = _acheteur_scope(pathname, ach_year)
table = get_top_org_table( table = get_top_org_ag_grid(
query_marches(where_sql, params).lazy(), "titulaire", ["titulaire_distance"] query_marches(where_sql, params).lazy(), "titulaire", ["titulaire_distance"]
) )
return make_card(fig=table, title="Top titulaires", lg=12, xl=12) return make_card(fig=table, title="Top titulaires", lg=12, xl=12)
@@ -451,62 +392,6 @@ def download_acheteur_data(
return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx") return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx")
@callback(
Output("acheteur-download-filtered-data", "data"),
Input("btn-download-filtered-data-acheteur", "n_clicks"),
State("acheteur_url", "pathname"),
State("acheteur_year", "value"),
State("acheteur_nom", "children"),
State("acheteur_datatable", "filter_query"),
State("acheteur_datatable", "sort_by"),
State("acheteur_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def download_filtered_acheteur_data(
n_clicks,
pathname,
ach_year,
acheteur_nom,
filter_query,
sort_by,
hidden_columns: list | None = None,
):
where_sql, params = _acheteur_scope(pathname, ach_year)
lff: pl.LazyFrame = query_marches(where_sql, params).lazy()
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
track_search(filter_query, "ach download")
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(
to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx"
)
# Pour nettoyer les icontains et i< des filtres
# voir aussi src/assets/dash_clientside.js
clientside_callback(
ClientsideFunction(
namespace="clientside",
function_name="clean_filters",
),
Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True),
Input("filter-cleanup-trigger-acheteur", "data"),
prevent_initial_call=True,
)
@callback( @callback(
Output("acheteur-hidden-columns", "data", allow_duplicate=True), Output("acheteur-hidden-columns", "data", allow_duplicate=True),
Input("acheteur_column_list", "selected_rows"), Input("acheteur_column_list", "selected_rows"),
@@ -521,22 +406,9 @@ def update_hidden_columns_from_checkboxes(selected_columns):
return [] return []
@callback(
Output("acheteur_datatable", "hidden_columns"),
Input(
"acheteur-hidden-columns",
"data",
),
)
def store_hidden_columns(hidden_columns):
if hidden_columns is None:
hidden_columns = get_default_hidden_columns("acheteur")
return hidden_columns
@callback( @callback(
Output("acheteur_column_list", "selected_rows"), Output("acheteur_column_list", "selected_rows"),
Input("acheteur_datatable", "hidden_columns"), Input("acheteur-hidden-columns", "data"),
State("acheteur_column_list", "selected_rows"), # pour éviter la boucle infinie State("acheteur_column_list", "selected_rows"), # pour éviter la boucle infinie
) )
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
@@ -559,16 +431,6 @@ def toggle_acheteur_columns(click_open, click_close, is_open):
return is_open return is_open
@callback(
Output("acheteur_datatable", "filter_query", allow_duplicate=True),
Output("acheteur_datatable", "sort_by"),
Input("btn-acheteur-reset", "n_clicks"),
prevent_initial_call=True,
)
def reset_view(n_clicks):
return "", []
@callback( @callback(
Output("acheteur-distance-histogram", "children"), Output("acheteur-distance-histogram", "children"),
Input("acheteur_url", "pathname"), Input("acheteur_url", "pathname"),
@@ -585,3 +447,6 @@ def update_acheteur_distance_histogram(pathname, ach_year):
lg=12, lg=12,
xl=12, xl=12,
) )
register_entity_grid_callbacks("acheteur")
+38 -9
View File
@@ -41,6 +41,39 @@ def _reabo_button():
) )
def _free_access_view():
from src.pages.a_propos.abonnement import abonnement_features
return html.Div(
[
html.H5(
"Vous avez temporairement accès à toutes les fonctionnalités",
className="mb-3",
),
html.P("Votre accès gratuit débloque :"),
abonnement_features,
dcc.Markdown(
"Pensez à copier les liens vers vos vues pour les conserver si vous "
"ne comptez pas souscrire à un abonnement payant.",
className="text-muted mt-3",
),
],
className="mb-4",
)
def _no_sub_view(tous_abonnes: bool, row):
if tous_abonnes:
return _free_access_view()
blocks = []
if row is not None and row["status"] == "expired":
blocks.append(
dbc.Alert("Votre abonnement a expiré.", color="warning", className="mb-4")
)
blocks.append(_reabo_button())
return html.Div(blocks)
def _active_view(row): def _active_view(row):
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]} meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
end = format_date_french(row["current_period_end"]) end = format_date_french(row["current_period_end"])
@@ -66,7 +99,7 @@ def _active_view(row):
html.Button( html.Button(
"Ajouter une méthode de paiement", "Ajouter une méthode de paiement",
type="submit", type="submit",
className="btn btn-primary mb-3", className="btn btn-secondary mb-3",
), ),
], ],
), ),
@@ -236,7 +269,7 @@ def _tous_abonnes_banner():
return dbc.Alert( return dbc.Alert(
"Les fonctionnalités normalement accessibles contre un abonnement de " "Les fonctionnalités normalement accessibles contre un abonnement de "
"20 € HT par mois sont accessibles à tous et toutes en attendant la " "20 € HT par mois sont accessibles à tous et toutes en attendant la "
"validation de mon dossier pour recevoir des paiements.", "validation du dossier pour recevoir des paiements.",
color="info", color="info",
) )
@@ -262,13 +295,9 @@ def layout(**query):
body.append(_active_view(row)) body.append(_active_view(row))
body.append(_resiliation_modal(row["current_period_end"])) body.append(_resiliation_modal(row["current_period_end"]))
else: else:
if row is not None and row["status"] == "expired": from src.utils import TOUS_ABONNES
body.append(
dbc.Alert( body.append(_no_sub_view(TOUS_ABONNES, row))
"Votre abonnement a expiré.", color="warning", className="mb-4"
)
)
body.append(_reabo_button())
body.append(_salaire_modal) body.append(_salaire_modal)
return account_shell("abonnement", html.Div(body)) return account_shell("abonnement", html.Div(body))
+22 -11
View File
@@ -49,7 +49,7 @@ def _submit_button(mode: str):
label, label,
id="inf-submit", id="inf-submit",
type="submit", type="submit",
className="btn btn-primary", className="btn btn-secondary",
disabled=(mode == "subscribe"), disabled=(mode == "subscribe"),
) )
@@ -147,16 +147,18 @@ def _consent_checklists(hidden: bool = False):
id="inf-cb-cgu", id="inf-cb-cgu",
options=[ options=[
{ {
"label": [ "label": html.Span(
"J'ai lu et accepte les ", [
html.A( "J'ai lu et accepte les ",
"conditions générales d'utilisation du service", html.A(
href="#", "conditions générales d'utilisation du service",
id="inf-cgu-link", href="#",
style={"cursor": "pointer"}, id="inf-cgu-link",
), style={"cursor": "pointer"},
".", ),
], ".",
]
),
"value": "ok", "value": "ok",
} }
], ],
@@ -189,6 +191,15 @@ def _cgu_modal():
def layout(**query): def layout(**query):
from src.utils import TOUS_ABONNES
# Sous TOUS_ABONNES, la souscription payante est désactivée : cette page
# carte bancaire n'a plus de sens, on renvoie vers la page abonnement.
if TOUS_ABONNES:
return dcc.Location(
href="/compte/abonnement", id="mes-infos-tous-abonnes-redirect"
)
guard = account_guard("/compte/abonnement/mes-infos", require_subscription=False) guard = account_guard("/compte/abonnement/mes-infos", require_subscription=False)
if guard is not None: if guard is not None:
return guard return guard
+4 -2
View File
@@ -52,7 +52,9 @@ def _email_section():
dbc.Input( dbc.Input(
type="email", name="email", required=True, className="mb-3" type="email", name="email", required=True, className="mb-3"
), ),
dbc.Button("Mettre à jour l'email", type="submit", color="primary"), dbc.Button(
"Mettre à jour l'email", type="submit", color="secondary"
),
], ],
), ),
] ]
@@ -92,7 +94,7 @@ def _password_section():
className="mb-3", className="mb-3",
), ),
dbc.Button( dbc.Button(
"Changer le mot de passe", type="submit", color="primary" "Changer le mot de passe", type="submit", color="secondary"
), ),
], ],
), ),
+313
View File
@@ -0,0 +1,313 @@
import os
import dash_bootstrap_components as dbc
from dash import dcc, html, register_page
from flask import session
from flask_login import current_user
from src.api import tokens_db
from src.pages._compte_shell import account_guard, account_shell
register_page(
__name__,
path="/compte/mcp",
title="Connecteur MCP | colibre",
name="Connecteur MCP",
description="Connectez votre agent IA aux données colibre via le protocole MCP.",
)
MCP_ENABLED = os.getenv("DASH_MCP_ENABLED", "").lower() == "true"
_TOKEN_PLACEHOLDER = "<VOTRE_JETON>"
def _mcp_url() -> str:
base = os.getenv("APP_BASE_URL", "").rstrip("/")
return f"{base}/_mcp" if base else "/_mcp"
def _csrf(index: str):
return dcc.Input(
type="hidden",
id={"type": "csrf-input", "index": index},
name="csrf_token",
)
def client_instructions(url: str, token: str):
"""Construit les blocs d'instructions par client (fonction pure, testable)."""
claude = f'claude mcp add colibre --scope user --transport http {url} --header "Authorization: Bearer {token}"'
gemini = f'gemini mcp add --scope user --transport http --header "Authorization: Bearer {token}" colibre {url}'
return dbc.Accordion(
start_collapsed=True,
always_open=False,
children=[
dbc.AccordionItem(
title="ChatGPT",
children=[
html.P(
"ChatGPT utilise aussi OAuth (aucun jeton à copier). Dans les "
"connecteurs, ajoutez un connecteur par URL :"
),
html.Ol(
[
html.Li(f"URL du serveur MCP : {url}"),
html.Li(
"Connectez-vous avec colibre, puis autorisez l'accès."
),
]
),
html.P(
"La disponibilité des connecteurs dépend de votre plan ChatGPT.",
className="text-muted",
),
],
),
dbc.AccordionItem(
title="Claude Code",
children=html.Pre(html.Code(claude)),
),
dbc.AccordionItem(
title="Claude.ai / Claude Desktop / mobile",
children=[
html.P("Aucun jeton à copier : ces applications utilisent OAuth."),
html.Ol(
[
html.Li(
"Icône utilisateur → Paramètres → Connecteurs → Ajouter → Ajouter un connecteur personnalisé."
),
html.Li("Nom : colibre"),
html.Li(f"URL du serveur MCP distant : {url}"),
html.Li(
'Laissez les champs "Client ID" et "Client secret" vides.'
),
html.Li(
'Connectez-vous avec votre compte colibre, puis "Autoriser".'
),
]
),
],
),
dbc.AccordionItem(
title="Gemini CLI",
children=[
html.Pre(html.Code(gemini)),
],
),
dbc.AccordionItem(
title="Mistral Vibe",
children=dcc.Markdown(f"""
1. Sur l'[interface Work](https://chat.mistral.ai/work), cliquez sur **Connectez vos applications...**
2. Cliquez sur **+ Ajouter un connecteur**
3. Cliquez sur **Connecteur personnalisé**
4. Titre : colibre, Description : Base de données ouverte des marchés publics français (par exemple)
5. Serveur : `{url}`
6. Méthode d'authentification : Authentification par token API
7. Valeur du header : Bearer <VOTRE_JETON>
8. Cliquez sur **Créer**
"""),
),
],
)
def prompt_tips():
"""Exemples de prompts pour guider l'utilisateur (fonction pure, testable)."""
exemples = [
"Depuis colibre, trouve le dernier marché public attribué par le "
"département du Morbihan.",
"Donne-moi les statistiques de l'acheteur Rennes Métropole : nombre de "
"marchés, montant total et principaux titulaires.",
"Quelles entreprises ont remporté le plus de marchés d'informatique "
"(CPV 72) en Ille-et-Vilaine depuis 2023 ?",
"Reprends ces marchés en n'affichant que l'objet, le montant et la date "
"de notification, avec le lien vers chaque fiche.",
]
return html.Div(
[
html.P(
"L'assistant découvre seul les outils disponibles : décrivez "
"votre besoin en langage naturel. Quelques exemples :"
),
html.Ul([html.Li(html.Em(f"« {p} »")) for p in exemples]),
html.P(
"Astuce colonnes : demandez à l'assistant de vous proposer les "
"colonnes disponibles pour choisir précisément ce qui s'affiche, si possible sous la forme d'une liste de cases à cocher "
"(les colonnes par défaut restent pré-sélectionnées). Chaque "
"marché renvoyé inclut un lien direct vers sa fiche sur colibre.",
className="text-muted",
),
]
)
def _copy_token_button(token: str):
"""Bouton « Copier le jeton » (même design que « Copier le lien » des vues)."""
return dcc.Clipboard(
content=token,
title="Copier le jeton",
className="btn btn-outline-secondary btn-sm d-inline-flex "
"align-items-center me-2",
children=[
html.Img(
src="/assets/copy.svg",
alt="",
style={
"height": "1em",
"verticalAlign": "-0.15em",
"marginRight": "0.35em",
},
),
"Copier le jeton",
],
copied_children="✓ Copié",
)
def _token_row(row: dict):
statut = "Révoqué" if row["revoked_at"] else "Actif"
actions = []
if not row["revoked_at"]:
token_plain = row.get("token_plain")
if token_plain:
actions.append(_copy_token_button(token_plain))
else:
actions.append(
html.Span(
"Régénérez pour copier",
className="text-muted small me-2",
)
)
actions.append(
html.Form(
method="POST",
action=f"/compte/mcp/revoquer/{row['id']}",
className="d-inline",
children=[
_csrf(f"revoke-{row['id']}"),
dbc.Button(
"Révoquer",
type="submit",
color="danger",
size="sm",
outline=True,
),
],
)
)
return html.Tr(
[
html.Td(row["label"]),
html.Td(row["created_at"]),
html.Td(row["last_used_at"] or ""),
html.Td(statut),
html.Td(actions),
]
)
def layout(**_):
guard = account_guard("/compte/mcp", require_subscription=True)
if guard is not None:
return guard
if not MCP_ENABLED:
contenu = html.Div(
[
html.H2("Connecteur MCP"),
dbc.Alert(
"Le connecteur MCP sera bientôt disponible. Revenez prochainement.",
color="info",
),
]
)
return account_shell("mcp", contenu)
url = _mcp_url()
new_token = session.pop("mcp_new_token", None)
alerts = []
if new_token:
alerts.append(
dbc.Alert(
[
html.Strong(
"Votre nouveau jeton (vous pourrez le copier à tout "
"moment via « Copier le jeton ») :"
),
html.Pre(html.Code(new_token)),
],
color="success",
)
)
db_path = os.environ["USERS_DB_PATH"]
tokens = tokens_db.list_user_tokens(db_path, current_user.id, "mcp")
token_rows = []
for r in tokens:
row = dict(r)
row["token_plain"] = tokens_db.get_token_plaintext_for_user(
db_path, row["id"], current_user.id
)
token_rows.append(_token_row(row))
table = (
dbc.Table(
[
html.Thead(
html.Tr(
[
html.Th("Nom"),
html.Th("Créé le"),
html.Th("Dernière utilisation"),
html.Th("Statut"),
html.Th(""),
]
)
),
html.Tbody(token_rows),
],
striped=True,
bordered=False,
hover=True,
)
if tokens
else html.P("Aucun jeton pour le moment.")
)
create_form = html.Form(
method="POST",
action="/compte/mcp/creer",
className="mb-4",
children=[
_csrf("mcp-create"),
dbc.Label("Nom du jeton (ex. « Claude sur mon portable »)"),
dbc.Input(type="text", name="label", required=True, className="mb-2"),
dbc.Button("Générer un jeton", type="submit", color="secondary"),
],
)
snippet_token = new_token or _TOKEN_PLACEHOLDER
contenu = html.Div(
[
html.H2("Connecteur MCP"),
html.P(
"Un connecteur MCP est une interface proposée par un outil (ici colibre) pour faciliter "
"son interrogation par un agent IA tel que Mistral, Claude ou ChatGPT. "
"Dans le cas de colibre, cela vous permet de poser des questions très précises aux données, "
"et d'obtenir une réponse rapide, précise et peu couteuse."
),
html.P(
"L'utilisation de cette fonctionnalité étant liée à un abonnement, vous devez d'abord vous authentifiez auprès "
"dans les options de votre agent IA. La méthode varie selon les outils."
),
html.H4("Connecter un client", className="mt-4"),
client_instructions(url, snippet_token),
*alerts,
html.H4("Générer un jeton", className="mt-3"),
create_form,
html.H4("Mes jetons", className="mt-4"),
table,
html.H4("Conseils de prompts", className="mt-4"),
prompt_tips(),
]
)
return account_shell("mcp", contenu)
+6 -1
View File
@@ -22,10 +22,15 @@ def layout(**_):
return guard return guard
balance = subs_db.credit_pending(current_user.id) balance = subs_db.credit_pending(current_user.id)
next_recharge = subs_db.next_recharge_at(current_user.id) next_recharge = subs_db.next_recharge_at(current_user.id)
sub = subs_db.get_current(current_user.id)
return account_shell( return account_shell(
"roadmap", "roadmap",
roadmap_ui.roadmap_content( roadmap_ui.roadmap_content(
editable=True, balance=balance, next_recharge=next_recharge editable=True,
balance=balance,
next_recharge=next_recharge,
sub_status=sub["status"] if sub else None,
trial_ends_at=sub["current_period_end"] if sub else None,
), ),
) )
+21 -8
View File
@@ -31,8 +31,14 @@ def _content():
[ [
html.H2("Mes vues"), html.H2("Mes vues"),
html.P( html.P(
"Les vues que vous enregistrez depuis le Tableau apparaissent ici. " [
"Cliquez sur « Ouvrir » pour appliquer une vue." "Les vues que vous enregistrez depuis le Tableau apparaissent "
"ici. Cliquez sur ",
html.B("Ouvrir"),
" pour appliquer une vue, ou sur ",
html.B("Copier le lien"),
" pour copier son adresse Web et la partager par mail ou par message, y compris avec une personne qui n'a pas de compte colibre.",
]
), ),
html.Div(saved_views_ui.views_table(views), id="vues-list"), html.Div(saved_views_ui.views_table(views), id="vues-list"),
dbc.Modal( dbc.Modal(
@@ -41,10 +47,12 @@ def _content():
children=[ children=[
dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")), dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")),
dbc.ModalBody( dbc.ModalBody(
dbc.Input(id="vue-rename-input", type="text"), dbc.Input(id="vue-rename-input", type="text", autofocus=True),
), ),
dbc.ModalFooter( dbc.ModalFooter(
dbc.Button("Renommer", id="vue-rename-confirm", color="primary") dbc.Button(
"Renommer", id="vue-rename-confirm", color="secondary"
)
), ),
], ],
), ),
@@ -76,25 +84,30 @@ def delete_view(n_clicks):
@callback( @callback(
Output("vue-rename-modal", "is_open"), Output("vue-rename-modal", "is_open"),
Output("vue-rename-id", "value"), Output("vue-rename-id", "value"),
Output("vue-rename-input", "value"),
Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"), Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"),
Input("vue-rename-confirm", "n_clicks"), Input("vue-rename-confirm", "n_clicks"),
Input("vue-rename-input", "n_submit"),
State("vue-rename-modal", "is_open"), State("vue-rename-modal", "is_open"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def toggle_rename_modal(_open, _confirm, is_open): def toggle_rename_modal(_open, _confirm, _submit, is_open):
if isinstance(ctx.triggered_id, dict) and any(_open): if isinstance(ctx.triggered_id, dict) and any(_open):
return True, str(ctx.triggered_id["index"]) view_id = ctx.triggered_id["index"]
return False, no_update view = saved_views_db.get(view_id, current_user.id)
return True, str(view_id), view["name"] if view else no_update
return False, no_update, no_update
@callback( @callback(
Output("vues-list", "children", allow_duplicate=True), Output("vues-list", "children", allow_duplicate=True),
Input("vue-rename-confirm", "n_clicks"), Input("vue-rename-confirm", "n_clicks"),
Input("vue-rename-input", "n_submit"),
State("vue-rename-id", "value"), State("vue-rename-id", "value"),
State("vue-rename-input", "value"), State("vue-rename-input", "value"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def rename_view(_n, view_id, new_name): def rename_view(_n, _submit, view_id, new_name):
clean = saved_views_ui.clean_view_name(new_name) clean = saved_views_ui.clean_view_name(new_name)
if not view_id or not clean: if not view_id or not clean:
return no_update return no_update
+2 -2
View File
@@ -81,7 +81,7 @@ def layout(error: str | None = None, email: str | None = None, **kwargs):
required=True, required=True,
className="mb-3", className="mb-3",
), ),
dbc.Button("Se connecter", type="submit", color="primary"), dbc.Button("Se connecter", type="submit", color="secondary"),
], ],
), ),
dcc.Link( dcc.Link(
@@ -95,7 +95,7 @@ def layout(error: str | None = None, email: str | None = None, **kwargs):
html.A( html.A(
"Pas encore de compte ? Voir les abonnements", "Pas encore de compte ? Voir les abonnements",
href="/a-propos/abonnement", href="/a-propos/abonnement",
className="btn btn-primary w-100", className="btn btn-secondary w-100",
), ),
], ],
) )
+9 -2
View File
@@ -23,10 +23,17 @@ ERROR_MESSAGES = {
def layout(error: str | None = None, email: str | None = None, **_): def layout(error: str | None = None, email: str | None = None, **_):
from src.utils import TOUS_ABONNES
alert = None alert = None
if error and error in ERROR_MESSAGES: if error and error in ERROR_MESSAGES:
alert = dbc.Alert(ERROR_MESSAGES[error], color="danger") alert = dbc.Alert(ERROR_MESSAGES[error], color="danger")
# Sous TOUS_ABONNES, on saute la page carte bancaire (mes-infos).
linkedin_next = (
"/compte/abonnement" if TOUS_ABONNES else "/compte/abonnement/mes-infos"
)
return dbc.Container( return dbc.Container(
className="py-4", className="py-4",
style={"maxWidth": "500px"}, style={"maxWidth": "500px"},
@@ -66,11 +73,11 @@ def layout(error: str | None = None, email: str | None = None, **_):
minLength=8, minLength=8,
className="mb-3", className="mb-3",
), ),
dbc.Button("Créer le compte", type="submit", color="primary"), dbc.Button("Créer le compte", type="submit", color="secondary"),
], ],
), ),
html.Div("ou", className="text-center text-muted my-2"), html.Div("ou", className="text-center text-muted my-2"),
linkedin_button("/compte/abonnement/mes-infos"), linkedin_button(linkedin_next),
html.Hr(), html.Hr(),
dcc.Link("Déjà un compte ? Se connecter", href="/connexion"), dcc.Link("Déjà un compte ? Se connecter", href="/connexion"),
], ],
+1 -1
View File
@@ -58,7 +58,7 @@ def layout(
value=email or "", value=email or "",
className="mb-3", className="mb-3",
), ),
dbc.Button("Envoyer le lien", type="submit", color="primary"), dbc.Button("Envoyer le lien", type="submit", color="secondary"),
], ],
), ),
html.Hr(), html.Hr(),
+4 -4
View File
@@ -426,9 +426,8 @@ Alors, on fait comment ?
dbc.Button( dbc.Button(
"Voir les données", "Voir les données",
id="btn-observatoire-preview", id="btn-observatoire-preview",
className="btn btn-primary mt-2", color="secondary",
color="primary", className="mt-2",
outline=True,
), ),
dcc.Input( dcc.Input(
id="observatoire-share-url", id="observatoire-share-url",
@@ -641,7 +640,8 @@ def sync_observatoire_share_url(*args):
dbc.Button( dbc.Button(
"Partager cette vue", "Partager cette vue",
id="btn-copy-observatoire", id="btn-copy-observatoire",
className="btn btn-primary mt-2", color="secondary",
className="mt-2",
title="Copier l'adresse de cette vue filtrée pour la partager.", title="Copier l'adresse de cette vue filtrée pour la partager.",
) )
], ],
+171 -62
View File
@@ -1,14 +1,109 @@
import math
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, dcc, html, register_page from dash import Input, Output, State, callback, dcc, html, register_page
from src.db import count_unique_marches
from src.figures import DataTable from src.figures import DataTable
from src.pages.a_propos.abonnement import abonnement_features
from src.utils.cache import cache
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES from src.utils.data import DF_ACHETEURS, DF_TITULAIRES
from src.utils.search import search_org from src.utils.search import search_org
from src.utils.seo import META_CONTENT from src.utils.seo import META_CONTENT
from src.utils.table import setup_table_columns from src.utils.table import format_number, setup_table_columns
NAME = "Recherche" NAME = "Recherche"
_STAT_STYLE = {"fontWeight": "bold", "color": "var(--primary-color-text)"}
@cache.memoize(timeout=12 * 60 * 60)
def _tagline_stats() -> tuple[str, str, str]:
nb_acheteurs = format_number(round(DF_ACHETEURS.height, -3))
nb_titulaires = format_number(round(DF_TITULAIRES.height, -3))
nb_marches_millions = math.floor(count_unique_marches() / 100_000) / 10
unite_marches = "million" if nb_marches_millions < 2 else "millions"
nb_marches = f"{nb_marches_millions:.1f}".replace(".", ",") + " " + unite_marches
return nb_acheteurs, nb_titulaires, nb_marches
def _tagline() -> html.P:
nb_acheteurs, nb_titulaires, nb_marches = _tagline_stats()
return html.P(
[
"Recherchez parmi ",
html.Span(nb_acheteurs, style=_STAT_STYLE),
" acheteurs et ",
html.Span(nb_titulaires, style=_STAT_STYLE),
" titulaires dans une base de données de plus de ",
html.Span(nb_marches, style=_STAT_STYLE),
" de marchés publics",
]
)
_features_gratuites = dcc.Markdown("""
- Recherche d'acheteurs et de titulaires
- [Tableau](/tableau) filtrable et personnalisable sur des dizaines de colonnes et exports Excel
- Fiches détaillées : acheteur, titulaire, marché
- Cartes, statistiques et [observatoire](/observatoire) national personnalisable
- 100 % open source et Open Data
""")
home_intro = html.Div(
id="home_intro",
className="container",
style={"maxWidth": "900px", "marginTop": "1rem"},
children=[
html.P(
[
html.Span(
"colibre",
style={
"fontWeight": 600,
"fontSize": "1.5rem",
"color": "var(--primary-color-text)",
},
),
" ouvre les données des marchés publics français à toutes et "
"tous : recherchez, filtrez, cartographiez, téléchargez.",
],
className="text-center",
style={
"maxWidth": "750px",
"fontSize": "1.5rem",
"margin": "6rem auto 1.5rem",
},
),
dbc.Row(
[
dbc.Col(
[html.H4("✅ Gratuit, sans compte"), _features_gratuites],
md=6,
style={
"borderRight": "1px solid var(--bs-border-color)",
"paddingRight": "2rem",
},
),
dbc.Col(
[
html.H4("⭐ Avec un abonnement"),
abonnement_features,
dcc.Link(
"En savoir plus et s'abonner",
href="/a-propos/abonnement",
style={"marginLeft": "20px"},
),
],
md=6,
style={"paddingLeft": "2rem"},
),
],
className="align-items-start",
),
],
)
register_page( register_page(
__name__, __name__,
path="/", path="/",
@@ -19,70 +114,84 @@ register_page(
order=0, order=0,
) )
layout = html.Div(
className="container", def layout(**_):
children=[ return html.Div(
html.Div( className="container",
className="tagline", children=[
children=html.P("Recherchez un acheteur ou un titulaire de marché public"), html.Div(
), className="tagline",
html.Div( children=_tagline(),
style={ ),
"display": "flex", html.Div(
"justifyContent": "center", style={
"marginTop": "30px", "display": "flex",
"marginBottom": "30px", "justifyContent": "center",
}, "marginTop": "30px",
children=[ "marginBottom": "30px",
dcc.Input( },
id="search", children=[
type="text", dcc.Input(
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département", id="search",
autoFocus=True, type="text",
style={ placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
"margin": "0", autoFocus=True,
"width": "500px", style={
"border": "1px solid #ccc", "margin": "0",
"borderRight": "none", "width": "500px",
"borderRadius": "3px 0 0 3px", "border": "1px solid #ccc",
"padding": "5px 10px", "borderRight": "none",
"outline": "none", "borderRadius": "3px 0 0 3px",
"height": "34px", "padding": "5px 10px",
}, "outline": "none",
), "height": "34px",
html.Button( },
"=>", ),
id="search-button", html.Button(
className="btn btn-primary", "=>",
style={ id="search-button",
"border": "1px solid #ccc", className="btn btn-secondary",
"borderRadius": "0 3px 3px 0", style={
"marginLeft": "0", "border": "1px solid #ccc",
"height": "auto", # Ensure it matches input height if necessary, often relying on padding/line-height "borderRadius": "0 3px 3px 0",
}, "marginLeft": "0",
), "height": "34px",
], # Le padding vertical (.375rem) et le line-height
), # (1.5) de .btn dépassent la hauteur fixe de 34px :
html.P( # le glyphe débordait vers le bas. On centre en flex
[ # et on neutralise le padding vertical.
"...ou bien filtrez les marchés publics dans la vue ", "display": "flex",
dcc.Link("Tableau", href="/tableau"), "alignItems": "center",
], "justifyContent": "center",
style={"textAlign": "center"}, "paddingTop": "0",
id="mention_tableau", "paddingBottom": "0",
), "lineHeight": "1",
# html.Div( },
# className="search_options", ),
# children=[dcc.RadioItems(options=["Acheteur(s)"])], ],
# ), ),
dbc.Row(id="search_results"), html.P(
], [
) "...ou bien filtrez les marchés publics dans la vue ",
dcc.Link("Tableau", href="/tableau"),
],
style={"textAlign": "center"},
id="mention_tableau",
),
home_intro,
# html.Div(
# className="search_options",
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
# ),
dbc.Row(id="search_results"),
],
)
@callback( @callback(
Output("search_results", "children"), Output("search_results", "children"),
Output("mention_tableau", "style"), Output("mention_tableau", "style"),
Output("home_intro", "style"),
Input("search", "n_submit"), Input("search", "n_submit"),
Input("search-button", "n_clicks"), Input("search-button", "n_clicks"),
State("search", "value"), State("search", "value"),
@@ -128,5 +237,5 @@ def update_search_results(n_submit, n_clicks, query):
cols.append(col) cols.append(col)
style = {"textAlign": "center", "display": "none"} style = {"textAlign": "center", "display": "none"}
return cols, style return cols, style, {"display": "none"}
return html.P(""), {"textAlign": "center"} return html.P(""), {"textAlign": "center"}, home_intro.style
+1 -1
View File
@@ -68,7 +68,7 @@ def layout(token: str | None = None, error: str | None = None, **_):
minLength=8, minLength=8,
className="mb-3", className="mb-3",
), ),
dbc.Button("Valider", type="submit", color="primary"), dbc.Button("Valider", type="submit", color="secondary"),
], ],
), ),
], ],
+264 -49
View File
@@ -1,6 +1,7 @@
import json import json
import os import os
from datetime import datetime from datetime import datetime
from urllib.parse import parse_qs
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
from dash import ( from dash import (
@@ -22,9 +23,10 @@ from src.db import schema
from src.figures import ag_grid, make_column_picker from src.figures import ag_grid, make_column_picker
from src.pages._compte_shell import current_user_has_subscription 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 db as saved_views_db
from src.saved_views import resolve as saved_views_resolve
from src.saved_views import ui as saved_views_ui from src.saved_views import ui as saved_views_ui
from src.utils import get_data_update_timestamp, logger from src.utils import get_data_update_timestamp, logger
from src.utils.grid import fetch_grid_page, grid_column_defs from src.utils.grid import apply_persisted_layout, fetch_grid_page, grid_column_defs
from src.utils.query_ast import ( from src.utils.query_ast import (
ast_from_dict, ast_from_dict,
ast_to_dict, ast_to_dict,
@@ -34,6 +36,7 @@ from src.utils.query_ast import (
from src.utils.seo import META_CONTENT from src.utils.seo import META_CONTENT
from src.utils.table import ( from src.utils.table import (
COLUMNS, COLUMNS,
format_number,
get_default_hidden_columns, get_default_hidden_columns,
write_styled_excel, write_styled_excel,
) )
@@ -62,10 +65,30 @@ register_page(
order=1, order=1,
) )
# Le bloc « URL directe » (share-url-box) reste affiché tant que la vue partagée
# n'a pas dérivé. On détecte la dérive par des écouteurs d'événements AG Grid
# côté client plutôt que par diff d'état : l'application programmatique d'une vue
# (source 'api'/'gridOptionsChanged') est ignorée, seules les actions utilisateur
# effacent `active-view` et masquent le bloc. Voir
# src/assets/dashAgGridFunctions.js. Cette approche évite la course
# d'ordonnancement d'un compteur d'échos et le bruit du columnState réémis.
#
# On écoute filtre et tri (signaux natifs fiables, source utilisateur distincte
# de 'api'). La visibilité des colonnes est pilotée par le sélecteur « Colonnes »
# maison (store tableau-hidden-columns → régénération des columnDefs → événement
# columnVisible de source 'gridOptionsChanged', identique pour l'utilisateur et
# l'application programmatique) : indistinguable via AG Grid, donc non écoutée.
_SHARE_DRIFT_LISTENERS = {
event: ["hideShareOnUserAction(params)"]
for event in ("filterChanged", "sortChanged")
}
DATATABLE = html.Div( DATATABLE = html.Div(
className="marches_table", className="marches_table",
children=ag_grid( children=ag_grid(
"tableau_grid", grid_column_defs(get_default_hidden_columns("tableau")) "tableau_grid",
grid_column_defs(get_default_hidden_columns("tableau")),
event_listeners=_SHARE_DRIFT_LISTENERS,
), ),
) )
@@ -95,7 +118,7 @@ def _help_button_legend():
"Supprimer tous les filtres et tris.", "Supprimer tous les filtres et tris.",
), ),
( (
dbc.Button("Mode d'emploi", color="secondary", outline=True, size="sm"), dbc.Button("Mode d'emploi", color="secondary", outline=True, size="sm"),
"Ouvrir cette aide.", "Ouvrir cette aide.",
), ),
] ]
@@ -126,6 +149,9 @@ layout = [
dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-hidden-columns", storage_type="local"),
dcc.Store(id="tableau-table"), dcc.Store(id="tableau-table"),
dcc.Store(id="tableau-total"), dcc.Store(id="tableau-total"),
dcc.Store(id="tableau-total-unique"),
dcc.Store(id="active-view"),
dcc.Store(id="vue-resolution"),
html.Script( html.Script(
type="application/ld+json", type="application/ld+json",
id="dataset_jsonld", id="dataset_jsonld",
@@ -195,6 +221,7 @@ layout = [
[], [],
id="header", id="header",
), ),
html.Div(id="vue-resolve-feedback"),
dcc.Loading( dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"}, overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home", id="loading-home",
@@ -222,19 +249,19 @@ layout = [
children=f""" children=f"""
##### Définition des colonnes ##### Définition des colonnes
Pour voir la définition d'une colonne, passez votre souris sur son en-tête. Pour voir la définition d'une colonne et ses valeurs possibles, passez votre souris sur son en-tête.
##### Vos réglages sont persistents ##### Vos réglages sont persistents
Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée. Les filtres, les tris , le choix de colonnes, le placement et la largeur des colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée.
##### Filtrer les colonnes ##### Filtrer les colonnes
Chaque colonne a son propre filtre : saisissez une valeur dans le champ situé juste sous son en-tête (le filtre « flottant »), ou cliquez sur l'icône entonnoir dans l'en-tête pour ouvrir le filtre complet. Chaque colonne a son propre filtre : saisissez une valeur dans le champ situé juste sous son en-tête, ou cliquez sur l'icône entonnoir dans l'en-tête pour ouvrir le filtre complet.
- Champs textuels : contient (par défaut), égal à, ne contient pas, commence par, se termine par... - Champs textuels : contient (par défaut), égal à, ne contient pas, commence par, se termine par...
- Champs numériques (Durée en mois, Montant, nombre d'offres...) : égal à, supérieur à, inférieur à, entre (plage)... - Champs numériques (durée en mois, montant, nombre d'offres...) : égal à, supérieur à, inférieur à, entre (plage)...
- Champs date (Date de notification...) : égal à, avant, après, entre (plage)... - Champs date : égal à, avant, après, entre (plage)...
Dans le filtre complet (icône entonnoir), vous pouvez combiner deux conditions sur la même colonne avec **ET** ou **OU**. Dans le filtre complet (icône entonnoir), vous pouvez combiner deux conditions sur la même colonne avec **ET** ou **OU**.
@@ -303,23 +330,39 @@ layout = [
), ),
html.Div( html.Div(
id="saved-views-bar", id="saved-views-bar",
style={"display": "none"},
className="d-inline-flex align-items-center gap-2", className="d-inline-flex align-items-center gap-2",
children=[ children=[
dbc.Button( # Boutons enveloppés dans un <span> : Bootstrap met
"Sauvegarder la vue", # `pointer-events: none` sur les boutons désactivés,
id="btn-save-view", # ce qui empêche le survol (et donc le `title`
color="secondary", # natif) de fonctionner directement dessus. Le
size="sm", # `title` est donc porté par le span englobant.
title="Enregistrer les filtres, tris et colonnes actuels sous un nom", html.Span(
), id="btn-save-view-wrapper",
dbc.DropdownMenu(
id="saved-views-menu",
label="Mes vues",
color="secondary",
size="sm",
children=[],
className="d-inline-block", className="d-inline-block",
children=dbc.Button(
"Sauvegarder la vue",
id="btn-save-view",
color="secondary",
size="sm",
# Grisé/désactivé pour les non-abonnés (le
# callback toggle_saved_views_controls
# affine au chargement).
disabled=True,
),
),
html.Span(
id="saved-views-menu-wrapper",
className="d-inline-block",
children=dbc.DropdownMenu(
id="saved-views-menu",
label="Mes vues",
color="secondary",
size="sm",
children=[],
disabled=True,
className="d-inline-block",
),
), ),
], ],
), ),
@@ -351,7 +394,7 @@ layout = [
dbc.Button( dbc.Button(
"Enregistrer", "Enregistrer",
id="btn-save-view-confirm", id="btn-save-view-confirm",
color="primary", color="secondary",
) )
), ),
], ],
@@ -376,6 +419,44 @@ layout = [
], ],
className="table-toolbar", className="table-toolbar",
), ),
html.Div(
id="share-url-box",
className="share-url-box d-none",
children=[
dbc.Label(
"Lien direct vers cette vue :",
className="mb-0",
style={"fontSize": "0.9em"},
),
# URL affichée comme texte sélectionnable : prend exactement
# sa largeur (pas de champ pleine largeur qui encombre) et
# passe à la ligne si le lien est long (pas de troncature).
html.Span(
id="share-url-text",
className="share-url-text",
style={"wordBreak": "break-all", "minWidth": 0},
),
dcc.Clipboard(
target_id="share-url-text",
title="Copier le lien vers cette vue",
className="btn btn-outline-secondary btn-sm "
"d-inline-flex align-items-center",
children=[
html.Img(
src="/assets/copy.svg",
alt="",
style={
"height": "1em",
"verticalAlign": "-0.15em",
"marginRight": "0.35em",
},
),
"Copier le lien",
],
copied_children="✓ Copié",
),
],
),
html.Div( html.Div(
className="table-meta", className="table-meta",
children=[ children=[
@@ -415,12 +496,13 @@ layout = [
@callback( @callback(
Output("tableau_grid", "getRowsResponse"), Output("tableau_grid", "getRowsResponse"),
Output("tableau-total", "data"), Output("tableau-total", "data"),
Output("tableau-total-unique", "data"),
Input("tableau_grid", "getRowsRequest"), Input("tableau_grid", "getRowsRequest"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def get_rows_tableau(request): def get_rows_tableau(request):
if request is None: if request is None:
return no_update, no_update return no_update, no_update, no_update
filter_model = request.get("filterModel") or None filter_model = request.get("filterModel") or None
sort_model = request.get("sortModel") or None sort_model = request.get("sortModel") or None
# AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de # AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de
@@ -429,13 +511,13 @@ def get_rows_tableau(request):
# pas une par bloc chargé au défilement. # pas une par bloc chargé au défilement.
if filter_model and request.get("startRow", 0) == 0: if filter_model and request.get("startRow", 0) == 0:
track_search(json.dumps(filter_model), "tableau") track_search(json.dumps(filter_model), "tableau")
rows, total = fetch_grid_page( rows, total, total_unique = fetch_grid_page(
filter_model, filter_model,
sort_model, sort_model,
request.get("startRow", 0), request.get("startRow", 0),
request.get("endRow", 100), request.get("endRow", 100),
) )
return {"rowData": rows, "rowCount": total}, total return {"rowData": rows, "rowCount": total}, total, total_unique
@callback( @callback(
@@ -443,16 +525,22 @@ def get_rows_tableau(request):
Output("btn-download-data", "disabled"), Output("btn-download-data", "disabled"),
Output("download-hint", "children"), Output("download-hint", "children"),
Input("tableau-total", "data"), Input("tableau-total", "data"),
Input("tableau-total-unique", "data"),
) )
def update_meta(total): def update_meta(total, total_unique):
total = total or 0 total = total or 0
total_unique = total_unique or 0
too_many = total > 65000 too_many = total > 65000
hint = ( hint = (
" · Filtrez sous 65 000 lignes pour activer le téléchargement" " · Filtrez sous 65 000 lignes pour activer le téléchargement"
if too_many if too_many
else "" else ""
) )
return f"{total} lignes", too_many, hint nb_rows = (
f"{format_number(total_unique) or 0} marchés "
f"({format_number(total) or 0} lignes)"
)
return nb_rows, too_many, hint
@callback( @callback(
@@ -499,22 +587,27 @@ def toggle_tableau_help(click_open, click_close, is_open):
prevent_initial_call=True, prevent_initial_call=True,
) )
def update_hidden_columns_from_checkboxes(selected_columns): def update_hidden_columns_from_checkboxes(selected_columns):
if selected_columns: # selected_columns == [] est un choix explicite (tout décoché), à ne pas
selected_columns = [COLUMNS[i] for i in selected_columns] # confondre avec « pas encore de préférence » : cf. apply_hidden_columns
hidden_columns = [col for col in COLUMNS if col not in selected_columns] # et update_checkboxes_from_hidden_columns, qui eux distinguent ce cas de
return hidden_columns # None. Sans ce traitement uniforme, décocher/cocher toutes les colonnes
else: # se faisait écraser par update_checkboxes_from_hidden_columns au tour
return [] # suivant (`hidden_cols or get_default_hidden_columns(...)`).
selected = [COLUMNS[i] for i in (selected_columns or [])]
hidden_columns = [col for col in COLUMNS if col not in selected]
return hidden_columns
@callback( @callback(
Output("tableau_grid", "columnDefs"), Output("tableau_grid", "columnDefs"),
Input("tableau-hidden-columns", "data"), Input("tableau-hidden-columns", "data"),
State("tableau_grid", "columnState"),
) )
def apply_hidden_columns(hidden_columns): def apply_hidden_columns(hidden_columns, column_state):
if hidden_columns is None: if hidden_columns is None:
hidden_columns = get_default_hidden_columns("tableau") hidden_columns = get_default_hidden_columns("tableau")
return grid_column_defs(hidden_columns) defs = grid_column_defs(hidden_columns)
return apply_persisted_layout(defs, column_state)
@callback( @callback(
@@ -523,7 +616,11 @@ def apply_hidden_columns(hidden_columns):
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
) )
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
hidden_cols = hidden_cols or get_default_hidden_columns("tableau") # None = pas encore de préférence enregistrée (première visite) ; []
# est un choix explicite (« ne rien masquer »/tout afficher) et doit
# être respecté tel quel, cf. update_hidden_columns_from_checkboxes.
if hidden_cols is None:
hidden_cols = get_default_hidden_columns("tableau")
# Show all columns that are NOT hidden # Show all columns that are NOT hidden
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols] visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
@@ -544,19 +641,118 @@ def toggle_tableau_columns(click_open, click_close, is_open):
@callback( @callback(
Output("tableau_grid", "filterModel", allow_duplicate=True), Output("tableau_grid", "filterModel", allow_duplicate=True),
Output("tableau_grid", "columnState", allow_duplicate=True),
Input("btn-tableau-reset", "n_clicks"), Input("btn-tableau-reset", "n_clicks"),
State("tableau_grid", "columnState"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def reset_view(n_clicks): def reset_view(n_clicks, column_state):
return {} # On ne touche qu'au tri (sort/sortIndex) : on réécrit le columnState tel
# quel pour préserver la largeur, l'épinglage et l'ordre des colonnes
# choisis par l'utilisateur (auparavant resetColumnState les effaçait
# aussi, cf. #47).
cleared_sort = [
{**col, "sort": None, "sortIndex": None} for col in (column_state or [])
]
return {}, cleared_sort
@callback( @callback(
Output("saved-views-bar", "style"), Output("btn-save-view", "disabled"),
Output("saved-views-menu", "disabled"),
Output("btn-save-view-wrapper", "title"),
Output("saved-views-menu-wrapper", "title"),
Input("tableau_url", "pathname"), Input("tableau_url", "pathname"),
) )
def toggle_saved_views_bar(_pathname): def toggle_saved_views_controls(_pathname):
return saved_views_ui.bar_style(current_user_has_subscription()) # La barre reste visible pour tous ; « Sauvegarder la vue » et « Mes vues »
# sont grisés et désactivés pour les non-abonnés (le gating serveur de
# save_view reste en place via prepare_view_to_save). L'infobulle est
# portée par les <span> englobants, cf. layout.
disabled = saved_views_ui.controls_disabled(current_user_has_subscription())
tooltip = "Fonctionnalité accessible en vous abonnant" if disabled else ""
return disabled, disabled, tooltip, tooltip
def resolve_vue_from_url(search: str) -> dict | None:
"""Extrait ?vue=... de la query string et le résout. Renvoie None s'il n'y a
pas de paramètre `vue` (chargement normal du tableau)."""
params = parse_qs((search or "").lstrip("?"))
values = params.get("vue")
if not values:
return None
return saved_views_resolve.resolve_vue_param(values[0], schema)
@callback(
Output("vue-resolution", "data"),
Input("tableau_url", "search"),
)
def store_vue_resolution(search):
resolution = resolve_vue_from_url(search)
return resolution if resolution is not None else no_update
def apply_vue_resolution(resolution):
"""Mappe le dict de résolution vers les sorties de la grille + le store
`active-view`. Séparé du callback pour être testable sans contexte Dash.
Sorties : (filterModel, columnState, hidden-columns, active-view, feedback).
Le masquage du bloc de partage n'est PAS géré par comparaison d'état ici :
c'est un écouteur d'événements AG Grid côté client (eventListeners
`dashAgGridFunctions.hideShareOnUserAction`) qui efface `active-view` sur
action utilisateur (filtre/tri/colonne), en ignorant l'application
programmatique (source == 'api'). Voir src/assets/dashAgGridFunctions.js.
"""
if resolution is None:
return (no_update,) * 5
if not resolution["found"]:
return (
no_update,
no_update,
no_update,
None, # active-view : masque le bloc de partage
html.Div(resolution["error"], className="alert alert-warning py-2"),
)
return (
resolution["filter_model"],
resolution["column_state"],
resolution["hidden_columns"],
{"token": resolution["token"], "url": resolution["url"]},
"",
)
@callback(
Output("tableau_grid", "filterModel", allow_duplicate=True),
Output("tableau_grid", "columnState", allow_duplicate=True),
Output("tableau-hidden-columns", "data", allow_duplicate=True),
Output("active-view", "data", allow_duplicate=True),
Output("vue-resolve-feedback", "children"),
Input("vue-resolution", "data"),
prevent_initial_call=True,
)
def apply_vue_resolution_cb(resolution):
return apply_vue_resolution(resolution)
# Classe Bootstrap de visibilité du bloc de partage. On bascule la CLASSE
# (d-flex ↔ d-none, toutes deux `!important`) et non le style inline : `.d-flex`
# est `display: flex !important` et l'emporterait sur un `style={"display":
# "none"}` inline, rendant le bloc impossible à masquer par le style.
_SHARE_BOX_SHOWN = "share-url-box d-flex align-items-center gap-2 my-2"
_SHARE_BOX_HIDDEN = "share-url-box d-none"
@callback(
Output("share-url-box", "className"),
Output("share-url-text", "children"),
Input("active-view", "data"),
)
def render_share_box(active_view):
if active_view and active_view.get("url"):
return _SHARE_BOX_SHOWN, active_view["url"]
return _SHARE_BOX_HIDDEN, ""
@callback( @callback(
@@ -572,6 +768,7 @@ def toggle_save_view_modal(_open):
Output("save-view-modal", "is_open", allow_duplicate=True), Output("save-view-modal", "is_open", allow_duplicate=True),
Output("save-view-feedback", "children"), Output("save-view-feedback", "children"),
Output("saved-views-refresh", "data"), Output("saved-views-refresh", "data"),
Output("active-view", "data", allow_duplicate=True),
Input("btn-save-view-confirm", "n_clicks"), Input("btn-save-view-confirm", "n_clicks"),
State("save-view-name", "value"), State("save-view-name", "value"),
State("tableau_grid", "filterModel"), State("tableau_grid", "filterModel"),
@@ -582,17 +779,24 @@ def save_view(_n, name, filter_model, column_state):
has_sub = current_user_has_subscription() has_sub = current_user_has_subscription()
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
if error: if error:
return True, html.Span(error, style={"color": "red"}), no_update return (
True,
html.Span(error, style={"color": "red"}),
no_update,
no_update,
)
# On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut # On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut
# d'AG Grid : cf. spec de conception, "l'AST (JSON) + columnState, # d'AG Grid : cf. spec de conception, "l'AST (JSON) + columnState,
# indépendant de l'UI". # indépendant de l'UI".
ast = filtermodel_to_ast(filter_model, schema) ast = filtermodel_to_ast(filter_model, schema)
query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []}) query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []})
saved_views_db.upsert(current_user.id, "tableau", clean_name, query) token = saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
active = {"token": token, "url": saved_views_ui.build_view_url(clean_name, token)}
return ( return (
False, False,
html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}), html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}),
clean_name, clean_name,
active,
) )
@@ -605,13 +809,20 @@ def populate_saved_views_menu(_pathname, _refresh):
if not current_user_has_subscription(): if not current_user_has_subscription():
return [] return []
views = saved_views_db.list_views(current_user.id, "tableau") views = saved_views_db.list_views(current_user.id, "tableau")
return saved_views_ui.saved_views_items(views) items = saved_views_ui.saved_views_items(views)
header = dbc.DropdownMenuItem(
"Gérer mes vues", href="/compte/vues", className="text-primary"
)
# Lien de gestion en tête, séparé de la liste des vues (séparateur seulement
# s'il y a des vues en dessous, pour éviter un séparateur orphelin).
return [header, *([dbc.DropdownMenuItem(divider=True)] if items else []), *items]
@callback( @callback(
Output("tableau_grid", "filterModel"), Output("tableau_grid", "filterModel"),
Output("tableau_grid", "columnState"), Output("tableau_grid", "columnState"),
Output("tableau-hidden-columns", "data", allow_duplicate=True), Output("tableau-hidden-columns", "data", allow_duplicate=True),
Output("active-view", "data", allow_duplicate=True),
Input({"type": "saved-view-item", "index": ALL}, "n_clicks"), Input({"type": "saved-view-item", "index": ALL}, "n_clicks"),
State({"type": "saved-view-item", "index": ALL}, "id"), State({"type": "saved-view-item", "index": ALL}, "id"),
prevent_initial_call=True, prevent_initial_call=True,
@@ -619,10 +830,10 @@ def populate_saved_views_menu(_pathname, _refresh):
def apply_saved_view(n_clicks, ids): def apply_saved_view(n_clicks, ids):
triggered = ctx.triggered_id triggered = ctx.triggered_id
if not triggered or not any(n_clicks): if not triggered or not any(n_clicks):
return no_update, no_update, no_update return no_update, no_update, no_update, no_update
row = saved_views_db.get(triggered["index"], current_user.id) row = saved_views_db.get(triggered["index"], current_user.id)
if not row: if not row:
return no_update, no_update, no_update return no_update, no_update, no_update, no_update
try: try:
view = json.loads(row["query"]) view = json.loads(row["query"])
# L'AST canonique est stocké (pas le filterModel brut d'AG Grid) : # L'AST canonique est stocké (pas le filterModel brut d'AG Grid) :
@@ -642,14 +853,18 @@ def apply_saved_view(n_clicks, ids):
"Vue sauvegardée au format pré-migration, impossible de l'appliquer : " "Vue sauvegardée au format pré-migration, impossible de l'appliquer : "
f"id={row['id']!r} name={row['name']!r}" f"id={row['id']!r} name={row['name']!r}"
) )
return no_update, no_update, no_update return no_update, no_update, no_update, no_update
# tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes # tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes
# (update_checkboxes_from_hidden_columns) et la régénération des # (update_checkboxes_from_hidden_columns) et la régénération des
# columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait # columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait
# désynchronisé du columnState rappelé (revue finale #41). Même extraction # désynchronisé du columnState rappelé (revue finale #41). Même extraction
# que download_data. # que download_data.
hidden_columns = [c["colId"] for c in column_state if c.get("hide")] hidden_columns = [c["colId"] for c in column_state if c.get("hide")]
return filter_model, column_state, hidden_columns active = {
"token": row["token"],
"url": saved_views_ui.build_view_url(row["name"], row["token"]),
}
return filter_model, column_state, hidden_columns, active
@callback( @callback(
+204 -339
View File
@@ -3,12 +3,10 @@ import datetime
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import polars as pl import polars as pl
from dash import ( from dash import (
ClientsideFunction,
Input, Input,
Output, Output,
State, State,
callback, callback,
clientside_callback,
dcc, dcc,
html, html,
register_page, register_page,
@@ -16,25 +14,21 @@ from dash import (
from src.db import aggregate_marches, count_marches, query_marches, schema from src.db import aggregate_marches, count_marches, query_marches, schema
from src.figures import ( from src.figures import (
DataTable,
get_distance_histogram, get_distance_histogram,
get_org_location_map, get_org_location_map,
get_top_org_table, get_top_org_ag_grid,
make_column_picker, make_column_picker,
) )
from src.utils.data import DF_TITULAIRES, get_annuaire_data, get_departement_region from src.utils.data import DF_TITULAIRES, get_annuaire_data, get_departement_region
from src.utils.entity_grid import entity_scope, register_entity_grid_callbacks
from src.utils.frontend import DROPDOWN_LABELS_FR, get_button_properties from src.utils.frontend import DROPDOWN_LABELS_FR, get_button_properties
from src.utils.seo import META_CONTENT from src.utils.seo import META_CONTENT
from src.utils.table import ( from src.utils.table import (
COLUMNS, COLUMNS,
filter_table_data,
format_number, format_number,
get_default_hidden_columns, get_default_hidden_columns,
prepare_table_data,
sort_table_data,
write_styled_excel, write_styled_excel,
) )
from src.utils.tracking import track_search
def get_title(titulaire_id: str = None) -> str: def get_title(titulaire_id: str = None) -> str:
@@ -48,13 +42,7 @@ def get_title(titulaire_id: str = None) -> str:
def _titulaire_scope(pathname: str, titulaire_year: str | None) -> tuple[str, list]: def _titulaire_scope(pathname: str, titulaire_year: str | None) -> tuple[str, list]:
"""WHERE SQL scopant les requêtes à ce titulaire (et éventuellement une année).""" """WHERE SQL scopant les requêtes à ce titulaire (et éventuellement une année)."""
titulaire_siret = pathname.split("/")[-1] return entity_scope("titulaire", pathname.split("/")[-1], titulaire_year)
where_sql = "titulaire_id = ? AND titulaire_typeIdentifiant = 'SIRET'"
params: list = [titulaire_siret]
if titulaire_year and titulaire_year != "Toutes les années":
where_sql += ' AND YEAR("dateNotification") = ?'
params.append(int(titulaire_year))
return where_sql, params
register_page( register_page(
@@ -67,213 +55,207 @@ register_page(
order=5, order=5,
) )
DATATABLE = html.Div(
className="marches_table",
children=DataTable(
dtid="titulaire_datatable",
persistence=True,
persistence_type="local",
persisted_props=["filter_query", "sort_by"],
page_action="custom",
filter_action="custom",
sort_action="custom",
page_size=10,
hidden_columns=[],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
layout = [ def layout(titulaire_id=None, **kwargs):
dcc.Store(id="titulaire-hidden-columns", storage_type="local"), return [
dcc.Store(id="filter-cleanup-trigger-titulaire"), dcc.Store(id="titulaire-hidden-columns", storage_type="local"),
dcc.Location(id="titulaire_url", refresh="callback-nav"), dcc.Store(id="titulaire-total"),
html.Div( dcc.Store(id="titulaire-total-unique"),
children=[ dcc.Store(id="entity-grid-columns-state", storage_type="local"),
html.Div( dcc.Location(id="titulaire_url", refresh="callback-nav"),
style={"marginBottom": "50px"}, html.Div(
children=[ children=[
dbc.Row( html.Div(
className="mb-2", style={"marginBottom": "50px"},
children=[ children=[
dbc.Col( dbc.Row(
[ className="mb-2",
html.H2( children=[
children=[ dbc.Col(
html.Span(id="titulaire_siret"), [
" - ", html.H2(
html.Span(id="titulaire_nom"), children=[
], html.Span(id="titulaire_siret"),
), " - ",
html.P( html.Span(id="titulaire_nom"),
id="titulaire_activite_libelle", ],
style={"color": "gray", "marginTop": "-10px"}, ),
), html.P(
], id="titulaire_activite_libelle",
width=8, style={
), "color": "gray",
dbc.Col( "marginTop": "-10px",
dcc.Dropdown( },
id="titulaire_year",
options=["Toutes les années"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
labels=DROPDOWN_LABELS_FR,
),
width=4,
),
],
),
dbc.Row(
className="mb-2",
children=[
dbc.Col(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(
[
"Commune : ",
html.Strong(id="titulaire_commune"),
]
),
html.P(
[
"Département : ",
html.Strong(id="titulaire_departement"),
]
),
html.P(
[
"Région : ",
html.Strong(id="titulaire_region"),
]
),
html.A(
id="titulaire_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
),
],
width=4,
),
dbc.Col(
children=[
html.P(id="titulaire_titre_stats"),
html.P(id="titulaire_marches_remportes"),
html.P(id="titulaire_acheteurs_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-data-titulaire",
className="btn btn-primary",
),
dcc.Download(id="download-data-titulaire"),
],
width=4,
),
dbc.Col(
id="titulaire_map",
width=4,
style={"minHeight": "300px"},
),
],
),
dbc.Row(
children=[
dbc.Col(
html.Div(
children=[
html.H3("Top acheteurs"),
html.Div(
className="marches_table",
id="top10_acheteurs",
style={"minHeight": "420px"},
), ),
], ],
width=8,
), ),
width=8, dbc.Col(
), dcc.Dropdown(
dbc.Col( id="titulaire_year",
id="titulaire-distance-histogram", options=["Toutes les années"]
width=4, + [
style={"minHeight": "450px"}, str(year)
), for year in range(
], 2018,
), int(datetime.date.today().year) + 1,
], )
), ],
# récupérer les données de l'acheteur sur l'api annuaire placeholder="Année",
html.H3("Derniers marchés publics remportés"), labels=DROPDOWN_LABELS_FR,
dcc.Loading( ),
overlay_style={"visibility": "visible", "filter": "blur(2px)"}, width=4,
id="loading-home", ),
type="default", ],
children=[ ),
html.Div( dbc.Row(
[ className="mb-2",
dbc.Button( children=[
"Colonnes", dbc.Col(
id="titulaire_columns_open", className="org_infos",
color="secondary", children=[
size="sm", # TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
className="column_list", html.P(
), [
dbc.Button( "Commune : ",
"Téléchargement désactivé au-delà de 65 000 lignes", html.Strong(id="titulaire_commune"),
id="btn-download-filtered-data-titulaire", ]
color="secondary", ),
size="sm", html.P(
disabled=True, [
), "Département : ",
dcc.Download(id="titulaire-download-filtered-data"), html.Strong(id="titulaire_departement"),
dbc.Button( ]
"Réinitialiser", ),
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.", html.P(
id="btn-titulaire-reset", [
color="danger", "Région : ",
outline=True, html.Strong(id="titulaire_region"),
size="sm", ]
), ),
], html.A(
className="table-toolbar", id="titulaire_lien_annuaire",
), children="Plus de détails sur l'Annuaire des entreprises",
html.Div( ),
html.Span(id="titulaire_nb_rows"), ],
className="table-meta", width=4,
), ),
dbc.Modal( dbc.Col(
[ children=[
dbc.ModalHeader( html.P(id="titulaire_titre_stats"),
dbc.ModalTitle("Choix des colonnes à afficher") html.P(id="titulaire_marches_remportes"),
), html.P(id="titulaire_acheteurs_differents"),
dbc.ModalBody( html.Button(
id="titulaire_columns_body", "Téléchargement au format Excel",
children=make_column_picker("titulaire"), id="btn-download-data-titulaire",
), className="btn btn-secondary",
dbc.ModalFooter( ),
dcc.Download(id="download-data-titulaire"),
],
width=4,
),
dbc.Col(
id="titulaire_map",
width=4,
style={"minHeight": "300px"},
),
],
),
dbc.Row(
children=[
dbc.Col(
html.Div(
children=[
html.H3("Top acheteurs"),
html.Div(
className="marches_table",
id="top10_acheteurs",
style={"minHeight": "420px"},
),
],
),
width=8,
),
dbc.Col(
id="titulaire-distance-histogram",
width=4,
style={"minHeight": "450px"},
),
],
),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics remportés"),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
dbc.Button( dbc.Button(
"Fermer", "Colonnes",
id="titulaire_columns_close", id="titulaire_columns_open",
className="ms-auto", color="secondary",
n_clicks=0, size="sm",
) className="column_list",
), ),
], dbc.Button(
id="titulaire_columns", "Téléchargement désactivé au-delà de 65 000 lignes",
is_open=False, id="btn-download-filtered-data-titulaire",
fullscreen="md-down", color="secondary",
scrollable=True, size="sm",
size="xl", disabled=True,
), ),
DATATABLE, dcc.Download(id="titulaire-download-filtered-data"),
], dbc.Button(
), "Réinitialiser",
], title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
), id="btn-titulaire-reset",
] color="danger",
outline=True,
size="sm",
),
],
className="table-toolbar",
),
html.Div(
html.Span(id="titulaire_nb_rows"),
className="table-meta",
),
dbc.Modal(
[
dbc.ModalHeader(
dbc.ModalTitle("Choix des colonnes à afficher")
),
dbc.ModalBody(
id="titulaire_columns_body",
children=make_column_picker("titulaire"),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="titulaire_columns_close",
className="ms-auto",
n_clicks=0,
)
),
],
id="titulaire_columns",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="xl",
),
html.Div(
id="titulaire-grid-container", className="marches_table"
),
],
),
],
),
]
@callback( @callback(
@@ -398,47 +380,6 @@ def update_download_button_titulaire(pathname, titulaire_year):
return get_button_properties(count_marches(where_sql, params)) return get_button_properties(count_marches(where_sql, params))
@callback(
Output("titulaire_datatable", "data"),
Output("titulaire_datatable", "columns"),
Output("titulaire_datatable", "tooltip_header"),
Output("titulaire_datatable", "data_timestamp"),
Output("titulaire_nb_rows", "children"),
Output("btn-download-filtered-data-titulaire", "disabled"),
Output("btn-download-filtered-data-titulaire", "children"),
Output("btn-download-filtered-data-titulaire", "title"),
Output("filter-cleanup-trigger-titulaire", "data"),
Input("titulaire_url", "pathname"),
Input("titulaire_year", "value"),
Input("titulaire_datatable", "page_current"),
Input("titulaire_datatable", "page_size"),
Input("titulaire_datatable", "filter_query"),
Input("titulaire_datatable", "sort_by"),
State("titulaire_datatable", "data_timestamp"),
)
def get_last_marches_data(
pathname,
titulaire_year,
page_current,
page_size,
filter_query,
sort_by,
data_timestamp,
) -> list[dict]:
where_sql, params = _titulaire_scope(pathname, titulaire_year)
return prepare_table_data(
None,
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"titulaire",
base_where_sql=where_sql,
base_params=params,
)
@callback( @callback(
Output(component_id="top10_acheteurs", component_property="children"), Output(component_id="top10_acheteurs", component_property="children"),
Input(component_id="titulaire_url", component_property="pathname"), Input(component_id="titulaire_url", component_property="pathname"),
@@ -446,7 +387,7 @@ def get_last_marches_data(
) )
def get_top_acheteurs(pathname, titulaire_year): def get_top_acheteurs(pathname, titulaire_year):
where_sql, params = _titulaire_scope(pathname, titulaire_year) where_sql, params = _titulaire_scope(pathname, titulaire_year)
return get_top_org_table( return get_top_org_ag_grid(
query_marches(where_sql, params).lazy(), "acheteur", ["titulaire_distance"] query_marches(where_sql, params).lazy(), "acheteur", ["titulaire_distance"]
) )
@@ -481,62 +422,6 @@ def download_titulaire_data(
return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx") return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx")
@callback(
Output("titulaire-download-filtered-data", "data"),
Input("btn-download-filtered-data-titulaire", "n_clicks"),
State("titulaire_url", "pathname"),
State("titulaire_year", "value"),
State("titulaire_nom", "children"),
State("titulaire_datatable", "filter_query"),
State("titulaire_datatable", "sort_by"),
State("titulaire_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def download_filtered_titulaire_data(
n_clicks,
pathname,
titulaire_year,
titulaire_nom,
filter_query,
sort_by,
hidden_columns: list | None = None,
):
where_sql, params = _titulaire_scope(pathname, titulaire_year)
lff: pl.LazyFrame = query_marches(where_sql, params).lazy()
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
track_search(filter_query, "titu download")
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(
to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx"
)
# Pour nettoyer les icontains et i< des filtres
# voir aussi src/assets/dash_clientside.js
clientside_callback(
ClientsideFunction(
namespace="clientside",
function_name="clean_filters",
),
Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True),
Input("filter-cleanup-trigger-titulaire", "data"),
prevent_initial_call=True,
)
@callback( @callback(
Output("titulaire-hidden-columns", "data", allow_duplicate=True), Output("titulaire-hidden-columns", "data", allow_duplicate=True),
Input("titulaire_column_list", "selected_rows"), Input("titulaire_column_list", "selected_rows"),
@@ -551,22 +436,9 @@ def update_hidden_columns_from_checkboxes(selected_columns):
return [] return []
@callback(
Output("titulaire_datatable", "hidden_columns"),
Input(
"titulaire-hidden-columns",
"data",
),
)
def store_hidden_columns(hidden_columns):
if hidden_columns is None:
hidden_columns = get_default_hidden_columns("titulaire")
return hidden_columns
@callback( @callback(
Output("titulaire_column_list", "selected_rows"), Output("titulaire_column_list", "selected_rows"),
Input("titulaire_datatable", "hidden_columns"), Input("titulaire-hidden-columns", "data"),
State("titulaire_column_list", "selected_rows"), # pour éviter la boucle infinie State("titulaire_column_list", "selected_rows"), # pour éviter la boucle infinie
) )
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
@@ -589,16 +461,6 @@ def toggle_titulaire_columns(click_open, click_close, is_open):
return is_open return is_open
@callback(
Output("titulaire_datatable", "filter_query", allow_duplicate=True),
Output("titulaire_datatable", "sort_by"),
Input("btn-titulaire-reset", "n_clicks"),
prevent_initial_call=True,
)
def reset_view(n_clicks):
return "", []
@callback( @callback(
Output("titulaire-distance-histogram", "children"), Output("titulaire-distance-histogram", "children"),
Input("titulaire_url", "pathname"), Input("titulaire_url", "pathname"),
@@ -617,3 +479,6 @@ def update_titulaire_distance_histogram(pathname, titulaire_year):
html.H6("par nombre de marchés", className="card-subtitle mb-2 text-muted"), html.H6("par nombre de marchés", className="card-subtitle mb-2 text-muted"),
fig, fig,
] ]
register_entity_grid_callbacks("titulaire")
+22 -1
View File
@@ -106,8 +106,26 @@ def _en_cours_items(en_cours: list[dict]) -> list:
] ]
def _trial_hint(sub_status: str | None, trial_ends_at: str | None):
if sub_status != "trial" or not trial_ends_at:
return None
try:
end_date = datetime.fromisoformat(trial_ends_at).strftime("%d/%m/%Y")
except (ValueError, TypeError):
return None
return dbc.Alert(
f"Vous pourrez voter à la fin de votre période d'essai, le {end_date}.",
color="info",
className="mb-3",
)
def roadmap_content( def roadmap_content(
editable: bool, balance: int | None = None, next_recharge: datetime | None = None editable: bool,
balance: int | None = None,
next_recharge: datetime | None = None,
sub_status: str | None = None,
trial_ends_at: str | None = None,
) -> html.Div: ) -> html.Div:
try: try:
issues = github.fetch_roadmap_issues() issues = github.fetch_roadmap_issues()
@@ -126,6 +144,9 @@ def roadmap_content(
"Les abonné·es votent pour les fonctionnalités à développer en priorité." "Les abonné·es votent pour les fonctionnalités à développer en priorité."
) )
) )
trial_hint = _trial_hint(sub_status, trial_ends_at) if editable else None
if trial_hint is not None:
body.append(trial_hint)
body.append( body.append(
html.Div( html.Div(
dbc.ListGroup( dbc.ListGroup(
+56 -6
View File
@@ -1,4 +1,6 @@
import secrets
import sqlite3 import sqlite3
import string
from datetime import datetime, timezone from datetime import datetime, timezone
from src.auth.db import get_conn from src.auth.db import get_conn
@@ -10,6 +12,7 @@ CREATE TABLE IF NOT EXISTS saved_views (
table_name TEXT NOT NULL DEFAULT 'tableau', table_name TEXT NOT NULL DEFAULT 'tableau',
name TEXT NOT NULL, name TEXT NOT NULL,
query TEXT NOT NULL, query TEXT NOT NULL,
token TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -17,15 +20,45 @@ CREATE TABLE IF NOT EXISTS saved_views (
); );
CREATE INDEX IF NOT EXISTS idx_saved_views_user CREATE INDEX IF NOT EXISTS idx_saved_views_user
ON saved_views(user_id, table_name); ON saved_views(user_id, table_name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_views_token
ON saved_views(token);
""" """
_TOKEN_ALPHABET = string.ascii_lowercase + string.digits # base36 (a-z 0-9)
def generate_token(length: int = 6) -> str:
return "".join(secrets.choice(_TOKEN_ALPHABET) for _ in range(length))
def _unique_token(conn) -> str:
while True:
token = generate_token()
exists = conn.execute(
"SELECT 1 FROM saved_views WHERE token = ?", (token,)
).fetchone()
if exists is None:
return token
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def init_schema() -> None: def init_schema() -> None:
get_conn().executescript(SCHEMA) conn = get_conn()
conn.executescript(SCHEMA)
# Backfill des lignes pré-migration (token NULL). L'index unique tolère
# plusieurs NULL transitoires ; on attribue un jeton à chacune. Idempotent :
# sans effet une fois toutes les lignes pourvues.
null_rows = conn.execute(
"SELECT id FROM saved_views WHERE token IS NULL"
).fetchall()
for row in null_rows:
conn.execute(
"UPDATE saved_views SET token = ? WHERE id = ?",
(_unique_token(conn), row["id"]),
)
def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]: def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]:
@@ -51,16 +84,33 @@ def get(view_id: int, user_id: int) -> sqlite3.Row | None:
) )
def upsert(user_id: int, table_name: str, name: str, query: str) -> None: def get_by_token(token: str) -> sqlite3.Row | None:
return (
get_conn()
.execute("SELECT * FROM saved_views WHERE token = ?", (token,))
.fetchone()
)
def upsert(user_id: int, table_name: str, name: str, query: str) -> str:
now = _now() now = _now()
get_conn().execute( conn = get_conn()
# Jeton candidat, utilisé uniquement en cas d'INSERT réel ; à l'écrasement
# (ON CONFLICT ... DO UPDATE), `token` n'est pas dans le SET → conservé.
conn.execute(
"INSERT INTO saved_views " "INSERT INTO saved_views "
"(user_id, table_name, name, query, created_at, updated_at) " "(user_id, table_name, name, query, token, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(user_id, table_name, name) DO UPDATE SET " "ON CONFLICT(user_id, table_name, name) DO UPDATE SET "
"query = excluded.query, updated_at = excluded.updated_at", "query = excluded.query, updated_at = excluded.updated_at",
(user_id, table_name, name, query, now, now), (user_id, table_name, name, query, _unique_token(conn), now, now),
) )
row = conn.execute(
"SELECT token FROM saved_views "
"WHERE user_id = ? AND table_name = ? AND name = ?",
(user_id, table_name, name),
).fetchone()
return row["token"]
def rename(view_id: int, user_id: int, new_name: str) -> None: def rename(view_id: int, user_id: int, new_name: str) -> None:
+59
View File
@@ -0,0 +1,59 @@
"""Résolution publique d'une vue depuis le paramètre d'URL ?vue=<slug>_<token>.
Fonction pure (aucune dépendance à Dash), testable directement : elle prend le
paramètre brut et le schéma, renvoie un dict décrivant la vue à appliquer ou une
erreur. Le slug est ignoré ; seul le jeton fait foi.
"""
import json
from src.saved_views import db, ui
from src.utils import logger
from src.utils.query_ast import ast_from_dict, ast_to_filtermodel
NOT_FOUND_MESSAGE = "Cette vue est introuvable ou a été supprimée."
def _error() -> dict:
return {
"found": False,
"filter_model": None,
"column_state": None,
"hidden_columns": None,
"token": None,
"url": None,
"error": NOT_FOUND_MESSAGE,
}
def resolve_vue_param(vue_param: str | None, schema) -> dict:
token = ui.token_from_vue_param(vue_param)
if not token:
return _error()
row = db.get_by_token(token)
if row is None:
return _error()
try:
view = json.loads(row["query"])
# AST canonique stocké par save_view (cf. spec vues sauvegardées).
ast = ast_from_dict(view.get("ast"))
filter_model = ast_to_filtermodel(ast, schema)
column_state = view.get("columnState") or []
except (json.JSONDecodeError, TypeError, AttributeError):
# Vue pré-migration (query string, pas du JSON) : repli propre, même
# message que pour un jeton inconnu (anti-énumération).
logger.warning(
"Vue partagée au format pré-migration, non applicable : "
f"token={token!r} name={row['name']!r}"
)
return _error()
hidden_columns = [c["colId"] for c in column_state if c.get("hide")]
return {
"found": True,
"filter_model": filter_model,
"column_state": column_state,
"hidden_columns": hidden_columns,
"token": row["token"],
"url": ui.build_view_url(row["name"], row["token"]),
"error": None,
}
+57 -9
View File
@@ -1,15 +1,46 @@
import re
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
from dash import html from dash import dcc, html
from unidecode import unidecode
from src.utils import DOMAIN_NAME
def bar_style(has_subscription: bool) -> dict: def controls_disabled(has_subscription: bool) -> bool:
return {} if has_subscription else {"display": "none"} """Boutons « Sauvegarder la vue » / « Mes vues » : actifs pour les abonnés,
grisés et désactivés sinon. La barre elle-même reste visible pour tous."""
return not has_subscription
def clean_view_name(name: str | None) -> str: def clean_view_name(name: str | None) -> str:
return (name or "").strip() return (name or "").strip()
def slugify(name: str | None) -> str:
"""Slug décoratif en tirets (convention web) : minuscule, translittéré ASCII,
non-alphanumériques '-', collapse/trim. Ne contient jamais d'underscore
(qui sert de séparateur slugjeton dans l'URL)."""
ascii_name = unidecode(name or "").lower()
return re.sub(r"[^a-z0-9]+", "-", ascii_name).strip("-")
def build_view_url(name: str, token: str) -> str:
slug = slugify(name)
prefix = f"{slug}_" if slug else ""
return f"https://{DOMAIN_NAME}/tableau?vue={prefix}{token}"
def token_from_vue_param(value: str | None) -> str | None:
"""Extrait le jeton du paramètre ?vue=. Le jeton base62 ne contient pas d'`_`
et le slug est en tirets, donc le segment après le dernier `_` est le jeton
(`?vue=abc123` sans slug fonctionne aussi)."""
if not value:
return None
token = value.rsplit("_", 1)[-1]
return token or None
def prepare_view_to_save( def prepare_view_to_save(
has_subscription: bool, name: str | None has_subscription: bool, name: str | None
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
@@ -34,19 +65,36 @@ def saved_views_items(views) -> list:
def _view_row(view) -> html.Div: def _view_row(view) -> html.Div:
view_id = view["id"] view_id = view["id"]
share_url = build_view_url(view["name"], view["token"])
return html.Div( return html.Div(
className="saved-view-row d-flex align-items-center gap-2 mb-2", className="saved-view-row d-flex align-items-center gap-2 mb-2",
children=[ children=[
html.Span(view["name"], className="flex-grow-1"), html.Span(view["name"], className="flex-grow-1"),
dbc.Button( dbc.Button(
# Limitation connue : la vue n'est pas appliquée automatiquement
# en arrivant sur /tableau depuis cette page. Le mécanisme de
# rappel multi-page reste à faire (voir tableau.py `apply_saved_view`).
"Ouvrir", "Ouvrir",
href="/tableau", href=share_url,
color="link", color="link",
size="sm", size="sm",
), ),
dcc.Clipboard(
content=share_url,
title="Copier le lien vers cette vue",
className="btn btn-outline-secondary btn-sm "
"d-inline-flex align-items-center",
children=[
html.Img(
src="/assets/copy.svg",
alt="",
style={
"height": "1em",
"verticalAlign": "-0.15em",
"marginRight": "0.35em",
},
),
"Copier le lien",
],
copied_children="✓ Copié",
),
dbc.Button( dbc.Button(
"Renommer", "Renommer",
id={"type": "vue-rename-open", "index": view_id}, id={"type": "vue-rename-open", "index": view_id},
@@ -68,9 +116,9 @@ def _view_row(view) -> html.Div:
def views_table(views) -> html.Div: def views_table(views) -> html.Div:
if not views: if not views:
return html.Div( return html.Div(
html.P( dcc.Markdown(
"Vous n'avez pas encore de vue enregistrée. " "Vous n'avez pas encore de vue enregistrée. "
"Créez-en une depuis le Tableau, bouton « Sauvegarder la vue »." "Créez-en une depuis le Tableau, bouton **Sauvegarder la vue**."
) )
) )
return html.Div([_view_row(v) for v in views]) return html.Div([_view_row(v) for v in views])
+23
View File
@@ -0,0 +1,23 @@
def build_widget_script(website_token: str | None) -> str:
"""Bloc <script> d'intégration du widget de chat Chatwoot (offre managée).
Renvoie une chaîne vide si aucun token n'est fourni, ce qui désactive le
widget (utilisé comme interrupteur pendant l'essai, voir issue #120).
"""
if not website_token:
return ""
return f"""<script>
(function(d,t) {{
var BASE_URL="https://app.chatwoot.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){{
window.chatwootSDK.run({{
websiteToken: '{website_token}',
baseUrl: BASE_URL
}})
}}
}})(document,"script");
</script>"""
+9 -1
View File
@@ -124,7 +124,15 @@ def get_data_schema() -> dict:
raw = _load_schema_file(cache_path) raw = _load_schema_file(cache_path)
if raw is None: if raw is None:
raise RuntimeError("Aucun schéma disponible (ni distant ni cache).") raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
return OrderedDict((c["name"], c) for c in raw["fields"]) schema = OrderedDict((c["name"], c) for c in raw["fields"])
for col in schema.keys():
new_obj = schema[col]
if "enum" in new_obj:
enums = ", ".join(new_obj["enum"])
new_obj["description"] = (
f"{new_obj['description']} Valeurs possibles : {enums}"
)
return schema
def prepare_dashboard_data(**filter_params) -> pl.DataFrame: def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
+285
View File
@@ -0,0 +1,285 @@
"""Logique de grille AG Grid scopée à une entité (acheteur/titulaire).
Factorise ce qui est commun aux pages acheteur.py et titulaire.py :
- scope SQL (WHERE acheteur_id/titulaire_id + année),
- datasource server-side (réutilise fetch_grid_page du Lot 1),
- export Excel filtré (réutilise export_dataframe),
- columnDefs + réapplication de la disposition persistée,
- fabrique de grille à id pattern-matching (persistance filterModel par fiche).
Les callbacks Dash sont enregistrés par register_entity_grid_callbacks()
(cf. module séparé de wiring) ; ici, seules des fonctions pures testables.
"""
from dash import (
ALL,
MATCH,
Input,
Output,
State,
callback,
ctx,
dcc,
no_update,
set_props,
)
from src.figures import (
AG_GRID_LOCALE_FR, # noqa: F401 (réexport pratique)
ag_grid,
)
from src.utils.grid import (
apply_persisted_layout,
export_dataframe,
fetch_grid_page,
grid_column_defs,
)
_TOUTES = "Toutes les années"
def grid_type(org_type: str) -> str:
"""Valeur de la clé `type` de l'id pattern-matching de la grille."""
return f"{org_type}-grid"
def entity_scope(org_type: str, entity_id: str, year) -> tuple[str, list]:
"""WHERE SQL + params liés scopant les requêtes à cette entité (+ année).
Reproduit _acheteur_scope/_titulaire_scope des pages, unifié par org_type.
"""
if org_type == "titulaire":
where_sql = "titulaire_id = ? AND titulaire_typeIdentifiant = 'SIRET'"
else:
where_sql = "acheteur_id = ?"
params: list = [entity_id]
if year and year != _TOUTES:
where_sql += ' AND YEAR("dateNotification") = ?'
params.append(int(year))
return where_sql, params
def _sort_model_from_column_state(column_state) -> list:
"""Extrait le sortModel AG Grid d'un columnState (comme tableau.download_data)."""
return [
{"colId": c["colId"], "sort": c["sort"]}
for c in (column_state or [])
if c.get("sort")
]
def entity_grid_column_defs(hidden_columns, column_state) -> list[dict]:
"""columnDefs du schéma DECP + disposition (largeur/ordre) persistée."""
defs = grid_column_defs(hidden_columns)
return apply_persisted_layout(defs, column_state)
def fetch_entity_page(org_type, entity_id, year, request) -> tuple:
"""Datasource server-side scopé pour la grille entité.
Renvoie ({"rowData": ..., "rowCount": total}, total, total_unique).
"""
if request is None:
return no_update, no_update, no_update
base_where_sql, base_params = entity_scope(org_type, entity_id, year)
rows, total, total_unique = fetch_grid_page(
request.get("filterModel") or None,
request.get("sortModel") or None,
request.get("startRow", 0),
request.get("endRow", 100),
base_where_sql=base_where_sql,
base_params=tuple(base_params),
)
return {"rowData": rows, "rowCount": total}, total, total_unique
def export_entity_dataframe(org_type, entity_id, year, filter_model, column_state):
"""DataFrame filtré/trié (état courant de la grille) pour l'export Excel."""
base_where_sql, base_params = entity_scope(org_type, entity_id, year)
sort_model = _sort_model_from_column_state(column_state)
hidden_columns = [c["colId"] for c in (column_state or []) if c.get("hide")]
return export_dataframe(
filter_model,
sort_model,
hidden_columns,
base_where_sql=base_where_sql,
base_params=tuple(base_params),
)
def clear_sort(column_state) -> list[dict]:
"""columnState avec le tri effacé (sort/sortIndex à None), largeur/ordre/
épinglage préservés. Approche #47-safe de tableau.reset_view."""
return [{**col, "sort": None, "sortIndex": None} for col in (column_state or [])]
def build_entity_grid(org_type, entity_id, year, hidden_columns, column_state):
"""Grille AG Grid à id pattern-matching, scopée à (entité, année).
L'id inclut entity_id + year → une entrée localStorage de filterModel par
fiche/année (persistance native scopée). columnState n'est PAS persisté
nativement (géré par un store global partagé) : persisted_props=["filterModel"].
"""
grid_id = {
"type": grid_type(org_type),
"entity_id": entity_id,
"year": year or _TOUTES,
}
defs = entity_grid_column_defs(hidden_columns, column_state)
return ag_grid(grid_id, defs, persisted_props=["filterModel"])
def register_entity_grid_callbacks(org_type: str) -> None:
"""Enregistre les callbacks de grille pour la page org_type (acheteur/titulaire).
Appelée une fois par page. Utilise des closures sur org_type ; toute la
logique délègue aux fonctions pures ci-dessus (testées en Task 4).
"""
gtype = grid_type(org_type)
# 1) Datasource server-side. Une seule sortie MATCH (getRowsResponse de la
# grille pattern-matching) : Dash INTERDIT de mélanger une sortie MATCH
# avec des sorties fixes (« MATCH wildcards must be on the same keys for
# all Outputs », erreur du dev-renderer en mode debug). Les stores totaux
# (id fixes) sont donc alimentés impérativement via dash.set_props.
@callback(
Output({"type": gtype, "entity_id": MATCH, "year": MATCH}, "getRowsResponse"),
Input({"type": gtype, "entity_id": MATCH, "year": MATCH}, "getRowsRequest"),
prevent_initial_call=True,
)
def _get_rows(request):
gid = ctx.triggered_id # {"type", "entity_id", "year"}
if request is None or gid is None:
return no_update
if request.get("filterModel") and request.get("startRow", 0) == 0:
import json
from src.utils.tracking import track_search
track_search(json.dumps(request["filterModel"]), org_type)
response, total, total_unique = fetch_entity_page(
org_type, gid["entity_id"], gid["year"], request
)
set_props(f"{org_type}-total", {"data": total})
set_props(f"{org_type}-total-unique", {"data": total_unique})
return response
# 2) (Re)construit la grille au changement de fiche (URL), d'année, ou de
# colonnes masquées. Le remontage réinitialise le filterModel (accepté
# au changement de fiche/année) ; pour les colonnes masquées voir §3bis.
@callback(
Output(f"{org_type}-grid-container", "children"),
Input(f"{org_type}_url", "pathname"),
Input(f"{org_type}_year", "value"),
State(f"{org_type}-hidden-columns", "data"),
State("entity-grid-columns-state", "data"),
)
def _build_grid(pathname, year, hidden_columns, column_state):
from src.utils.table import get_default_hidden_columns
entity_id = (pathname or "").split("/")[-1]
if hidden_columns is None:
hidden_columns = get_default_hidden_columns(org_type)
return build_entity_grid(
org_type, entity_id, year, hidden_columns, column_state
)
# 3) Persiste columnState (largeur/ordre/tri/visibilité) dans le store global
# partagé. Input MATCH (grille) → Output fixe (store). Autorisé Dash 4.4.
@callback(
Output("entity-grid-columns-state", "data", allow_duplicate=True),
Input({"type": gtype, "entity_id": MATCH, "year": MATCH}, "columnState"),
prevent_initial_call=True,
)
def _persist_column_state(column_state):
return column_state or no_update
# 3bis) Colonnes masquées → columnDefs in-place (préserve le filtre courant,
# contrairement au remontage). Input fixe → Output ALL (grille unique).
@callback(
Output({"type": gtype, "entity_id": ALL, "year": ALL}, "columnDefs"),
Input(f"{org_type}-hidden-columns", "data"),
State({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
prevent_initial_call=True,
)
def _apply_hidden_columns(hidden_columns, column_states):
from src.utils.table import get_default_hidden_columns
if hidden_columns is None:
hidden_columns = get_default_hidden_columns(org_type)
# ALL → listes (une entrée par grille présente, ici 0 ou 1).
return [
entity_grid_column_defs(hidden_columns, cs) for cs in (column_states or [])
]
# 4) Reset : efface filtres ET tris (columnState avec sort=None). Bouton fixe
# → grille ALL.
@callback(
Output({"type": gtype, "entity_id": ALL, "year": ALL}, "filterModel"),
Output({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
Input(f"btn-{org_type}-reset", "n_clicks"),
State({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
prevent_initial_call=True,
)
def _reset(_n, column_states):
cleared = [clear_sort(cs) for cs in (column_states or [])]
return [{} for _ in cleared], cleared
# 5) Export filtré (état courant de la grille). Bouton fixe → Download fixe,
# lit filterModel/columnState de la grille via ALL (state).
@callback(
Output(f"{org_type}-download-filtered-data", "data"),
Input(f"btn-download-filtered-data-{org_type}", "n_clicks"),
State(f"{org_type}_url", "pathname"),
State(f"{org_type}_year", "value"),
State(f"{org_type}_nom", "children"),
State({"type": gtype, "entity_id": ALL, "year": ALL}, "filterModel"),
State({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
prevent_initial_call=True,
)
def _download_filtered(_n, pathname, year, nom, filter_models, column_states):
import datetime as _dt
from src.utils.table import write_styled_excel
entity_id = (pathname or "").split("/")[-1]
filter_model = (filter_models or [None])[0]
column_state = (column_states or [None])[0]
if filter_model:
import json
from src.utils.tracking import track_search
track_search(json.dumps(filter_model), f"{org_type} download")
df = export_entity_dataframe(
org_type, entity_id, year, filter_model, column_state
)
def to_bytes(buffer):
write_styled_excel(df, buffer)
date = _dt.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
label = nom if isinstance(nom, str) else org_type
return dcc.send_bytes(to_bytes, filename=f"decp_filtrées_{label}_{date}.xlsx")
# 6) Meta : "X marchés (Y lignes)" + état du bouton d'export filtré (seuil 65k).
@callback(
Output(f"{org_type}_nb_rows", "children"),
Output(f"btn-download-filtered-data-{org_type}", "disabled"),
Output(f"btn-download-filtered-data-{org_type}", "children"),
Output(f"btn-download-filtered-data-{org_type}", "title"),
Input(f"{org_type}-total", "data"),
Input(f"{org_type}-total-unique", "data"),
)
def _meta(total, total_unique):
from src.utils.frontend import get_button_properties
from src.utils.table import format_number
total = total or 0
total_unique = total_unique or 0
nb_rows = (
f"{format_number(total_unique) or 0} marchés "
f"({format_number(total) or 0} lignes)"
)
disabled, children, title = get_button_properties(total)
return nb_rows, disabled, children, title
+144 -11
View File
@@ -2,7 +2,7 @@
import polars as pl import polars as pl
from src.db import count_marches, query_marches, schema from src.db import count_marches, count_unique_marches, query_marches, schema
from src.figures import DATA_SCHEMA from src.figures import DATA_SCHEMA
from src.utils.cache import cache from src.utils.cache import cache
from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql
@@ -21,6 +21,13 @@ def _cached_count(where_sql: str, params: tuple) -> int:
return count_marches(where_sql, params) return count_marches(where_sql, params)
@cache.memoize()
def _cached_unique_count(where_sql: str, params: tuple) -> int:
"""Cache le COUNT(DISTINCT uid) sur (where_sql, params), même raison que
`_cached_count`."""
return count_unique_marches(where_sql, params)
def fetch_grid_page( def fetch_grid_page(
filter_model, filter_model,
sort_model, sort_model,
@@ -28,8 +35,8 @@ def fetch_grid_page(
end_row: int, end_row: int,
base_where_sql: str = "TRUE", base_where_sql: str = "TRUE",
base_params: tuple = (), base_params: tuple = (),
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int, int]:
"""Renvoie (row_data, total_count) pour un bloc [start_row, end_row).""" """Renvoie (row_data, total_count, total_unique_count) pour un bloc [start_row, end_row)."""
ast = filtermodel_to_ast(filter_model, schema) ast = filtermodel_to_ast(filter_model, schema)
filter_sql, filter_params = ast_to_sql(ast, schema) filter_sql, filter_params = ast_to_sql(ast, schema)
where_sql = f"({base_where_sql}) AND ({filter_sql})" where_sql = f"({base_where_sql}) AND ({filter_sql})"
@@ -37,6 +44,7 @@ def fetch_grid_page(
order_by = sort_model_to_sql(sort_model, schema) or None order_by = sort_model_to_sql(sort_model, schema) or None
total = _cached_count(where_sql, tuple(params)) total = _cached_count(where_sql, tuple(params))
total_unique = _cached_unique_count(where_sql, tuple(params))
limit = max(0, end_row - start_row) limit = max(0, end_row - start_row)
page = query_marches( page = query_marches(
@@ -47,20 +55,41 @@ def fetch_grid_page(
offset=start_row, offset=start_row,
) )
page = postprocess_page(page) page = postprocess_page(page)
return page.to_dicts(), total rows = page.to_dicts()
# Numéro de ligne absolu (position dans les résultats filtrés/triés),
# ajouté sous le lien loupe : repère visuel pendant le défilement infini
# de la grille. Calculé ici (pas dans postprocess_page, partagé avec les
# dash_table paginées d'acheteur.py/titulaire.py) car il dépend de
# `start_row`, propre au chargement par bloc de l'AG Grid.
for i, row in enumerate(rows):
row["marche"] = (
f'{row["marche"]}<div class="marche-row-number">{start_row + i + 1}</div>'
)
return rows, total, total_unique
def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame: def export_dataframe(
filter_model,
sort_model,
hidden_columns,
base_where_sql: str = "TRUE",
base_params: tuple = (),
) -> pl.DataFrame:
"""Renvoie les lignes filtrées/triées pour l'export Excel. """Renvoie les lignes filtrées/triées pour l'export Excel.
Colonnes masquées exclues, valeurs brutes (non post-traitées HTML). Colonnes masquées exclues, valeurs brutes (non post-traitées HTML).
`base_where_sql`/`base_params` scopent l'export à un sous-ensemble (ex. un
acheteur/titulaire) combinés en `(base) AND (filtre)`, comme
`fetch_grid_page`. Par défaut `TRUE` comportement inchangé pour tableau.py.
""" """
ast = filtermodel_to_ast(filter_model, schema) ast = filtermodel_to_ast(filter_model, schema)
filter_sql, params = ast_to_sql(ast, schema) filter_sql, filter_params = ast_to_sql(ast, schema)
where_sql = f"({base_where_sql}) AND ({filter_sql})"
params = [*base_params, *filter_params]
order_by = sort_model_to_sql(sort_model, schema) or None order_by = sort_model_to_sql(sort_model, schema) or None
visible = [c for c in schema.names() if c not in set(hidden_columns or [])] visible = [c for c in schema.names() if c not in set(hidden_columns or [])]
return query_marches( return query_marches(
where_sql=filter_sql, where_sql=where_sql,
params=params, params=params,
columns=visible, columns=visible,
order_by=order_by, order_by=order_by,
@@ -74,7 +103,42 @@ _LINK_COLUMNS = {
"acheteur_nom", "acheteur_nom",
"titulaire_id", "titulaire_id",
"titulaire_nom", "titulaire_nom",
"sourceFile", "sourceDataset",
}
# Colonnes oui/non (cf. `booleans_to_strings` dans src.db) : valeur très
# courte, pas besoin de place.
_BOOLEAN_LIKE_COLUMNS = {
"attributionAvance",
"marcheInnovant",
"sousTraitanceDeclaree",
"considerationsSociales",
"considerationsEnvironnementales",
}
# Codes/identifiants qui ne suivent pas le pattern de suffixe `_id`/`_code`
# (ex. codeCPV, idAccordCadre) mais restent des valeurs courtes.
_SHORT_CODE_COLUMNS = {
"codeCPV",
"lieuExecution_typeCode",
}
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
_WIDE_LABEL_COLUMNS = {"objet", "uid"}
# Colonnes avec infobulle systématique sur les cellules (valeur complète au
# survol, pas seulement quand le texte est tronqué).
_CELL_TOOLTIP_COLUMNS = {
"procedure",
"considerationsSociales",
"considerationsEnvironnementales",
"objet",
"ccag",
"modalitesExecution",
"acheteur_nom",
"titulaire_nom",
"acheteur_id",
"titulaire_id",
} }
@@ -86,6 +150,29 @@ def _filter_for(col_type) -> str:
return "agTextColumnFilter" return "agTextColumnFilter"
def _column_width(col: str, col_type) -> dict:
"""Largeur par défaut selon la nature de la colonne.
Sert de base à l'affichage initial (pas de columnSize="responsiveSizeToFit",
cf. ag_grid() : les colonnes ne s'étirent/compressent pas automatiquement).
Pas de maxWidth ici : resizable=True (defaultColDef) doit pouvoir élargir
librement une colonne à la souris, sans plafond artificiel.
"""
if col == "montant":
return {"width": 150}
if col_type == pl.Date:
return {"width": 220}
if col_type.is_numeric():
return {"width": 150}
if col in _BOOLEAN_LIKE_COLUMNS:
return {"width": 110}
if col in _SHORT_CODE_COLUMNS or col.endswith(("_code")):
return {"width": 180}
if col in _WIDE_LABEL_COLUMNS or col.endswith("_nom"):
return {"width": 350}
return {"width": 190}
def grid_column_defs(hidden_columns=None): def grid_column_defs(hidden_columns=None):
"""columnDefs dérivés du schéma DuckDB. """columnDefs dérivés du schéma DuckDB.
@@ -115,10 +202,56 @@ def grid_column_defs(hidden_columns=None):
"hide": col in hidden, "hide": col in hidden,
} }
if meta.get("description"): if meta.get("description"):
col_def["headerTooltip"] = ( # Texte brut (pas de markdown) : le renderer "markdown" côté
f"{meta.get('title', col)} ({col}) — {meta['description']}" # dash-ag-grid n'est enregistré que comme cellRenderer, pas
) # comme tooltipComponent — posé sur un en-tête, il retombe sur
# le rendu natif d'AG Grid, qui affiche la chaîne telle quelle
# (donc "**gras**" apparaissait littéralement avec les astérisques).
col_def["headerTooltip"] = f"""{meta.get("title", col)} ({col})
{meta["description"]}"""
if col in _LINK_COLUMNS: if col in _LINK_COLUMNS:
col_def["cellRenderer"] = "markdown" col_def["cellRenderer"] = "markdown"
if col in _CELL_TOOLTIP_COLUMNS:
# Valeur complète en infobulle. Pour les colonnes-liens
# (_LINK_COLUMNS), la cellule contient du HTML (<a href=...>) :
# on pointe vers la copie texte brut posée par add_links() dans
# src.utils.table plutôt que d'afficher ce balisage tel quel.
col_def["tooltipField"] = f"{col}_tooltip" if col in _LINK_COLUMNS else col
if col in ["objet", "acheteur_nom", "titulaire_nom"]:
# autoHeight n'est pas supporté avec rowModelType="infinite" (la
# grille doit pouvoir calculer la position des lignes non
# chargées, donc une hauteur de ligne fixe) : cf. ag_grid(),
# rowHeight fixe côté dashGridOptions plutôt qu'autoHeight ici.
col_def["wrapText"] = True
col_def.update(_column_width(col, col_type))
defs.append(col_def) defs.append(col_def)
return defs return defs
def apply_persisted_layout(
defs: list[dict], column_state: list[dict] | None
) -> list[dict]:
"""Réapplique la largeur et l'ordre des colonnes personnalisés par
l'utilisateur (columnState restauré via la persistance AG Grid) par-dessus
des columnDefs fraîchement régénérés par grid_column_defs(), qui eux
ignorent tout état précédent et ne portent que la largeur par défaut et
l'ordre du schéma.
Sans ça, tout changement de columnDefs (à chaque chargement de page ou
changement de colonnes affichées) écrase silencieusement le
redimensionnement/réordonnancement fait par l'utilisateur, cf. #47.
"""
if not column_state:
return defs
order = {col["colId"]: i for i, col in enumerate(column_state)}
widths = {col["colId"]: col["width"] for col in column_state if col.get("width")}
for col_def in defs:
field = col_def["field"]
if field in widths:
col_def["width"] = widths[field]
# Colonnes déjà connues dans leur ordre persisté, colonnes nouvelles
# (ex. ajout au schéma, jamais vues dans columnState) à la fin dans
# leur ordre par défaut.
defs.sort(key=lambda d: order.get(d["field"], len(order)))
return defs
+42 -25
View File
@@ -191,7 +191,14 @@ def _leaf(column: str, spec: dict):
def filtermodel_to_ast(filter_model, schema): def filtermodel_to_ast(filter_model, schema):
"""Traduit un filterModel AG Grid en AST. Colonnes combinées en And.""" """Traduit un filterModel AG Grid en AST. Colonnes combinées en And.
AG Grid >=29.2 (pinné à 35.2.0 ici) encode un filtre à conditions
multiples via une liste `conditions` (N éléments, cf. `maxNumConditions`),
et non plus via `condition1`/`condition2` (dépréciés depuis 29.2, mais
l'API dit "still accept" ces clés en entrée — on les supporte donc aussi
en repli, sans qu'AG Grid ne les envoie normalement).
"""
if not filter_model: if not filter_model:
return None return None
children = [] children = []
@@ -199,7 +206,12 @@ def filtermodel_to_ast(filter_model, schema):
if column not in schema.names(): if column not in schema.names():
logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}") logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}")
continue continue
if "operator" in spec: # deux conditions if "conditions" in spec: # forme courante AG Grid >=29.2 (N conditions)
parts = [c for c in (_leaf(column, s) for s in spec["conditions"]) if c]
if not parts:
continue
node = And(parts) if spec.get("operator") == "AND" else Or(parts)
elif "operator" in spec: # forme dépréciée condition1/condition2
c1 = _leaf(column, spec.get("condition1", {})) c1 = _leaf(column, spec.get("condition1", {}))
c2 = _leaf(column, spec.get("condition2", {})) c2 = _leaf(column, spec.get("condition2", {}))
parts = [c for c in (c1, c2) if c is not None] parts = [c for c in (c1, c2) if c is not None]
@@ -289,11 +301,12 @@ def _child_to_filterspec(child, schema: pl.Schema):
"""Convertit un enfant du And de haut niveau en (colonne, spec filterModel). """Convertit un enfant du And de haut niveau en (colonne, spec filterModel).
Ne sait inverser que les deux formes produites par `filtermodel_to_ast` : Ne sait inverser que les deux formes produites par `filtermodel_to_ast` :
une Condition seule, ou un And/Or à 2 enfants portant sur la MÊME colonne une Condition seule, ou un And/Or à N enfants (N = maxNumConditions)
(filtre AG Grid natif à deux conditions). Toute autre forme (Not, And/Or portant tous sur la MÊME colonne (filtre AG Grid natif multi-conditions,
imbriqué plus profondément, colonnes différentes, plus de 2 enfants) est forme `conditions` cf. AG Grid >=29.2). Toute autre forme (Not, And/Or
ignorée avec un warning : un filterModel est par nature par-colonne et ne imbriqué plus profondément, colonnes différentes) est ignorée avec un
peut représenter une expression booléenne arbitraire (cf. #97). warning : un filterModel est par nature par-colonne et ne peut représenter
une expression booléenne arbitraire (cf. #97).
""" """
if isinstance(child, Condition): if isinstance(child, Condition):
spec = _condition_to_filterspec(child, schema) spec = _condition_to_filterspec(child, schema)
@@ -301,24 +314,28 @@ def _child_to_filterspec(child, schema: pl.Schema):
return None return None
return child.column, spec return child.column, spec
if isinstance(child, (And, Or)) and len(child.children) == 2: if isinstance(child, (And, Or)) and len(child.children) >= 2:
c1, c2 = child.children conditions = child.children
if ( if not all(isinstance(c, Condition) for c in conditions):
isinstance(c1, Condition) logger.warning(
and isinstance(c2, Condition) f"Nœud AST non représentable en filterModel, ignoré : {child!r}"
and c1.column == c2.column )
): return None
spec1 = _condition_to_filterspec(c1, schema) columns = {c.column for c in conditions}
spec2 = _condition_to_filterspec(c2, schema) if len(columns) != 1:
if spec1 is None or spec2 is None: logger.warning(
return None f"Nœud AST non représentable en filterModel, ignoré : {child!r}"
operator = "AND" if isinstance(child, And) else "OR" )
return c1.column, { return None
"filterType": spec1["filterType"], specs = [_condition_to_filterspec(c, schema) for c in conditions]
"operator": operator, if any(s is None for s in specs):
"condition1": spec1, return None
"condition2": spec2, operator = "AND" if isinstance(child, And) else "OR"
} return conditions[0].column, {
"filterType": specs[0]["filterType"],
"operator": operator,
"conditions": specs,
}
logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}") logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}")
return None return None
+19
View File
@@ -50,7 +50,26 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
return dff return dff
_TOOLTIP_SHADOW_COLUMNS = [
"acheteur_nom",
"titulaire_nom",
"acheteur_id",
"titulaire_id",
]
def add_links(dff: pl.DataFrame): def add_links(dff: pl.DataFrame):
# Copie du texte brut avant réécriture en HTML ci-dessous, pour que la
# grille AG Grid (cf. grid_column_defs dans src.utils.grid) puisse
# afficher une infobulle lisible sur ces colonnes plutôt que le balisage
# <a href=...> brut.
dff = dff.with_columns(
[
pl.col(c).alias(f"{c}_tooltip")
for c in _TOOLTIP_SHADOW_COLUMNS
if c in dff.columns
]
)
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]: for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
if col in dff.columns: if col in dff.columns:
if col.startswith("titulaire_"): if col.startswith("titulaire_"):
+19
View File
@@ -57,3 +57,22 @@ def test_valid_token_sets_g_and_calls_view(temp_db):
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.get_json()["token_id"] == token_id assert resp.get_json()["token_id"] == token_id
def test_mcp_kind_token_rejected_by_rest_api(temp_db):
token, _ = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
app = _make_app()
resp = app.test_client().get(
"/protected", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 401
assert resp.get_json()["message"] == "invalid_token"
def test_api_kind_token_accepted_by_rest_api(temp_db):
token, _ = tokens_db.create_token(temp_db, "x", kind="api")
app = _make_app()
resp = app.test_client().get(
"/protected", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
+8 -2
View File
@@ -29,16 +29,22 @@ def test_exact_filter():
def test_contains_filter_uses_like_wildcards(): def test_contains_filter_uses_like_wildcards():
where, params, _ = build_where([("objet__contains", "informatique")], SCHEMA) where, params, _ = build_where([("objet__contains", "informatique")], SCHEMA)
assert where == '"objet" LIKE ?' assert where == '"objet" ILIKE ?'
assert params == ["%informatique%"] assert params == ["%informatique%"]
def test_notcontains_filter(): def test_notcontains_filter():
where, params, _ = build_where([("objet__notcontains", "x")], SCHEMA) where, params, _ = build_where([("objet__notcontains", "x")], SCHEMA)
assert where == '"objet" NOT LIKE ?' assert where == '"objet" NOT ILIKE ?'
assert params == ["%x%"] assert params == ["%x%"]
def test_startswith_filter_uses_prefix_wildcard():
where, params, _ = build_where([("objet__startswith", "72")], SCHEMA)
assert where == '"objet" ILIKE ?'
assert params == ["72%"]
def test_comparison_operators_on_int(): def test_comparison_operators_on_int():
where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA) where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA)
assert where == '"annee" > ?' assert where == '"annee" > ?'
+21
View File
@@ -0,0 +1,21 @@
import sqlite3
def test_init_api_creates_api_tokens_table(monkeypatch, tmp_path):
from flask import Flask
from src.api import init_api, tracking
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
server = Flask(__name__)
init_api(server)
try:
with sqlite3.connect(str(db_path)) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='api_tokens'"
).fetchall()
finally:
tracking.stop_worker()
assert rows == [("api_tokens",)]
+44
View File
@@ -0,0 +1,44 @@
def test_migration_0007_adds_kind_to_legacy_api_tokens(monkeypatch, tmp_path):
from src import migrations
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
conn = auth_db.get_conn()
conn.execute("DROP TABLE IF EXISTS api_tokens")
# ancienne table SANS colonne kind
conn.execute(
"CREATE TABLE api_tokens ("
"id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, "
"label TEXT NOT NULL, user_id INTEGER, created_at TEXT NOT NULL, "
"last_used_at TEXT, count_total INTEGER NOT NULL DEFAULT 0, revoked_at TEXT)"
)
conn.commit()
migrations.apply_pending()
cols = [r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()]
assert "kind" in cols
# idempotent : un second passage ne lève pas
migrations.apply_pending()
auth_db.reset_conn_for_tests()
def test_apply_pending_tolerates_missing_api_tokens_table(monkeypatch, tmp_path):
from src import migrations
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
# api_tokens volontairement NON créée (chemin init_subscriptions sans init_api)
migrations.apply_pending() # ne doit pas lever
auth_db.reset_conn_for_tests()
+70
View File
@@ -62,3 +62,73 @@ def test_list_tokens_returns_all(temp_db):
tokens_db.create_token(temp_db, "b") tokens_db.create_token(temp_db, "b")
rows = tokens_db.list_tokens(temp_db) rows = tokens_db.list_tokens(temp_db)
assert [r["label"] for r in rows] == ["a", "b"] assert [r["label"] for r in rows] == ["a", "b"]
def test_create_token_defaults_to_api_kind(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x")
row = tokens_db.get_token_by_plaintext(temp_db, token)
assert row["kind"] == "api"
def test_create_token_with_mcp_kind(temp_db):
token, _ = tokens_db.create_token(temp_db, "x", user_id=7, kind="mcp")
row = tokens_db.get_token_by_plaintext(temp_db, token)
assert row["kind"] == "mcp"
assert row["user_id"] == 7
def test_list_user_tokens_filters_by_user_and_kind(temp_db):
tokens_db.create_token(temp_db, "mcp-u1", user_id=1, kind="mcp")
tokens_db.create_token(temp_db, "api-u1", user_id=1, kind="api")
tokens_db.create_token(temp_db, "mcp-u2", user_id=2, kind="mcp")
rows = tokens_db.list_user_tokens(temp_db, 1, "mcp")
assert [r["label"] for r in rows] == ["mcp-u1"]
def test_revoke_user_token_revokes_own(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True
assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is not None
def test_revoke_user_token_refuses_other_owner(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.revoke_user_token(temp_db, token_id, 999) is False
assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is None
def test_revoke_user_token_already_revoked_returns_false(temp_db):
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is False
def test_create_token_stores_recoverable_encrypted_token(temp_db, monkeypatch):
monkeypatch.setenv("SECRET_KEY", "s3cr3t-test-key")
token, token_id = tokens_db.create_token(temp_db, "x", user_id=7, kind="mcp")
with sqlite3.connect(str(temp_db)) as conn:
enc = conn.execute(
"SELECT token_enc FROM api_tokens WHERE id = ?", (token_id,)
).fetchone()[0]
assert enc is not None
assert token not in enc # chiffré, jamais en clair dans la base
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 7) == token
def test_get_token_plaintext_scoped_to_owner(temp_db, monkeypatch):
monkeypatch.setenv("SECRET_KEY", "s3cr3t-test-key")
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 999) is None
def test_get_token_plaintext_none_when_no_key(temp_db, monkeypatch):
monkeypatch.delenv("SECRET_KEY", raising=False)
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 1) is None
def test_get_token_plaintext_none_after_key_rotation(temp_db, monkeypatch):
monkeypatch.setenv("SECRET_KEY", "key-A")
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
monkeypatch.setenv("SECRET_KEY", "key-B") # rotation → indéchiffrable
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 1) is None
+15
View File
@@ -16,6 +16,21 @@ def test_verify_email_valid_token_logs_in_and_redirects_to_mes_infos(
assert sess.get("_user_id") == str(uid) assert sess.get("_user_id") == str(uid)
def test_verify_email_tous_abonnes_redirects_to_abonnement(
client, users_db_path, monkeypatch
):
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
db.init_schema()
uid = db.create_user("t@b.c", "hash")
token = tokens.create_verification_token(uid)
resp = client.get(f"/auth/verify-email?token={token}")
assert resp.status_code == 302
loc = resp.headers["Location"]
assert loc.endswith("/compte/abonnement")
assert "mes-infos" not in loc
def test_verify_email_invalid_token(client): def test_verify_email_invalid_token(client):
resp = client.get("/auth/verify-email?token=invalide") resp = client.get("/auth/verify-email?token=invalide")
assert "error=invalid_token" in resp.headers["Location"] assert "error=invalid_token" in resp.headers["Location"]
+10
View File
@@ -60,6 +60,16 @@ shutil.copyfile(_SCHEMA_FIXTURE, _SCHEMA_CACHE)
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_CACHE) os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_CACHE)
os.environ.pop("DATA_SCHEMA_PATH", None) os.environ.pop("DATA_SCHEMA_PATH", None)
# Base users partagée par les tests qui démarrent l'app complète (Selenium) :
# on COPIE le fixture committé vers une base jetable (users.runtime.sqlite,
# gitignorée) et on pointe USERS_DB_PATH dessus. Ainsi, toute écriture faite
# pendant les tests (comptes créés, vues sauvegardées, etc.) ne mute pas
# tests/users.test.sqlite (versionné, partagé pour toute la suite).
_USERS_DB_FIXTURE = Path(os.path.abspath("tests/users.test.sqlite"))
_USERS_DB_RUNTIME = Path(os.path.abspath("tests/users.runtime.sqlite"))
shutil.copyfile(_USERS_DB_FIXTURE, _USERS_DB_RUNTIME)
os.environ["USERS_DB_PATH"] = str(_USERS_DB_RUNTIME)
def _cleanup_db_artifacts() -> None: def _cleanup_db_artifacts() -> None:
for artifact in ( for artifact in (
+102
View File
@@ -0,0 +1,102 @@
def test_section_connecteur_mcp_present():
from src.pages._compte_shell import SECTIONS
keys = {s["key"]: s for s in SECTIONS}
assert "mcp" in keys
assert keys["mcp"]["label"] == "Connecteur MCP"
assert keys["mcp"]["href"] == "/compte/mcp"
assert keys["mcp"]["require_subscription"] is True
def test_page_module_registers_and_builds_client_instructions():
# importe l'app pour la découverte use_pages en contexte propre
from src.app import app # noqa: F401
from src.pages.compte import mcp as mcp_page
# helper pur : construit les 4 blocs d'instructions clients
blocks = mcp_page.client_instructions(
"https://colibre.fr/_mcp", "colibre_TESTTOKEN"
)
text = str(blocks)
assert "colibre_TESTTOKEN" in text
assert "https://colibre.fr/_mcp" in text
for client_name in ("Claude", "Gemini", "Mistral", "ChatGPT"):
assert client_name in text
def test_client_instructions_include_oauth_apps():
from src.app import app # noqa: F401
from src.pages.compte.mcp import client_instructions
comp = client_instructions("https://colibre.fr/_mcp", "<VOTRE_JETON>")
titles = _collect_titles(comp)
assert any("Claude.ai" in t for t in titles)
assert any("ChatGPT" in t for t in titles)
def test_prompt_tips_mentions_columns_and_examples():
from src.app import app # noqa: F401
from src.pages.compte.mcp import prompt_tips
text = str(prompt_tips())
assert "Morbihan" in text # un exemple de prompt concret
assert "colonnes" in text # l'astuce sur le choix des colonnes
assert "lien" in text # mention du lien vers la fiche marché
def test_token_row_has_copy_button_when_token_available():
from src.app import app # noqa: F401
from src.pages.compte.mcp import _token_row
text = str(
_token_row(
{
"id": 42,
"label": "l",
"created_at": "c",
"last_used_at": None,
"revoked_at": None,
"token_plain": "colibre_abc123",
}
)
)
assert "Copier le jeton" in text
assert "colibre_abc123" in text # contenu du presse-papier (dcc.Clipboard)
def test_token_row_no_copy_button_when_token_unavailable():
from src.app import app # noqa: F401
from src.pages.compte.mcp import _token_row
text = str(
_token_row(
{
"id": 42,
"label": "l",
"created_at": "c",
"last_used_at": None,
"revoked_at": None,
"token_plain": None, # ancien jeton haché, non récupérable
}
)
)
assert "Copier le jeton" not in text
def _collect_titles(component):
# Parcourt récursivement les AccordionItem pour collecter leurs `title`.
found = []
def walk(node):
title = getattr(node, "title", None)
if isinstance(title, str):
found.append(title)
children = getattr(node, "children", None)
if isinstance(children, (list, tuple)):
for c in children:
walk(c)
elif children is not None:
walk(children)
walk(component)
return found
+114
View File
@@ -0,0 +1,114 @@
import pytest
from flask import Flask
@pytest.fixture
def account_client(monkeypatch, tmp_path):
from src.api import tokens_db
from src.auth import db as auth_db
from src.auth.setup import init_auth
from src.mcp.account import mcp_account_bp
from src.subscriptions import db as sub_db
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite"))
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
auth_db.reset_conn_for_tests()
app = Flask(__name__)
app.config["WTF_CSRF_ENABLED"] = False
init_auth(app) # login manager + CSRF (désactivé) + schéma auth
sub_db.init_schema()
db_path = tmp_path / "users.test.sqlite"
tokens_db.init_schema(db_path)
app.register_blueprint(mcp_account_bp)
yield app, db_path
auth_db.reset_conn_for_tests()
def _login(app, uid):
client = app.test_client()
with client.session_transaction() as sess:
sess["_user_id"] = str(uid)
sess["_fresh"] = True
return client
def _subscribed_uid():
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
uid = auth_db.create_user("sub@ex.fr", "hash")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
return uid
def test_creer_generates_mcp_token_for_subscriber(account_client):
from src.api import tokens_db
app, db_path = account_client
uid = _subscribed_uid()
client = _login(app, uid)
resp = client.post("/compte/mcp/creer", data={"label": "Claude portable"})
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/compte/mcp")
with client.session_transaction() as sess:
assert sess.get("mcp_new_token", "").startswith("colibre_")
rows = tokens_db.list_user_tokens(db_path, uid, "mcp")
assert [r["label"] for r in rows] == ["Claude portable"]
assert rows[0]["kind"] == "mcp"
assert rows[0]["user_id"] == uid
def test_revoquer_own_token(account_client):
from src.api import tokens_db
app, db_path = account_client
uid = _subscribed_uid()
client = _login(app, uid)
token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
resp = client.post(f"/compte/mcp/revoquer/{tid}")
assert resp.status_code == 302
assert tokens_db.get_token_by_plaintext(db_path, token)["revoked_at"] is not None
def test_revoquer_other_users_token_is_noop(account_client):
from src.api import tokens_db
app, db_path = account_client
uid = _subscribed_uid()
client = _login(app, uid)
other_token, other_id = tokens_db.create_token(
db_path, "y", user_id=99999, kind="mcp"
)
resp = client.post(f"/compte/mcp/revoquer/{other_id}")
assert resp.status_code == 302
assert tokens_db.get_token_by_plaintext(db_path, other_token)["revoked_at"] is None
def test_creer_blocked_without_subscription(account_client):
from src.api import tokens_db
from src.auth import db as auth_db
app, db_path = account_client
uid = auth_db.create_user("nosub@ex.fr", "hash") # pas d'abonnement
client = _login(app, uid)
resp = client.post("/compte/mcp/creer", data={"label": "x"})
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/compte/abonnement")
assert tokens_db.list_user_tokens(db_path, uid, "mcp") == []
def test_creer_blocked_when_anonymous(account_client):
app, db_path = account_client
resp = app.test_client().post("/compte/mcp/creer", data={"label": "x"})
assert resp.status_code == 302
assert "/connexion" in resp.headers["Location"]
+89
View File
@@ -0,0 +1,89 @@
import importlib
import sys
def test_mcp_endpoint_guarded_and_csrf_exempt(monkeypatch, tmp_path):
# DB éphémère + secrets requis par init_auth/init_subscriptions.
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite"))
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
monkeypatch.setenv("DASH_MCP_ENABLED", "true")
from src.auth import db as auth_db
auth_db.reset_conn_for_tests()
# Recharger src.app avec le flag activé ré-exécute la découverte use_pages,
# qui REMPLACE les objets-modules src.pages.* dans sys.modules (Dash les
# ré-exécute via exec_module). Sans restauration, un test ultérieur ayant
# importé une fonction de page (ex. tests/test_grid.py: get_rows_tableau)
# verrait son patch("src.pages.tableau.<x>") cibler un objet-module différent
# de celui d'où provient la fonction importée → pollution inter-tests. On
# snapshot les modules concernés puis on les restaure en fin de test.
snapshot = {
name: mod
for name, mod in sys.modules.items()
if name == "src.app" or name.startswith("src.pages")
}
try:
import src.app as app_module
app_module = importlib.reload(app_module)
client = app_module.app.server.test_client()
# Pas de jeton : le garde renvoie 401 (et NON une erreur CSRF 400/403),
# ce qui prouve exemption CSRF + garde câblés sur /_mcp.
resp = client.post("/_mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert resp.status_code == 401
assert resp.headers.get("WWW-Authenticate", "").startswith(
'Bearer realm="colibre-mcp"'
)
finally:
# Restaurer les objets-modules d'origine et purger ceux créés par le
# reload, pour ne pas polluer les tests suivants.
for name in [
n for n in sys.modules if n == "src.app" or n.startswith("src.pages")
]:
if name in snapshot:
sys.modules[name] = snapshot[name]
else:
del sys.modules[name]
auth_db.reset_conn_for_tests()
def test_oauth_wellknown_served_when_mcp_enabled(monkeypatch, tmp_path):
# DB éphémère + secrets requis par init_auth/init_subscriptions.
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite"))
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
monkeypatch.setenv("DASH_MCP_ENABLED", "true")
from src.auth import db as auth_db
auth_db.reset_conn_for_tests()
# Voir commentaire ci-dessus : reload de src.app pollue sys.modules pour
# src.pages.* ; on snapshot/restaure de la même façon.
snapshot = {
name: mod
for name, mod in sys.modules.items()
if name == "src.app" or name.startswith("src.pages")
}
try:
import src.app as app_module
app_module = importlib.reload(app_module)
client = app_module.app.server.test_client()
resp = client.get("/.well-known/oauth-protected-resource")
assert resp.status_code == 200
assert resp.get_json()["resource"] == "https://colibre.fr/_mcp"
finally:
for name in [
n for n in sys.modules if n == "src.app" or n.startswith("src.pages")
]:
if name in snapshot:
sys.modules[name] = snapshot[name]
else:
del sys.modules[name]
auth_db.reset_conn_for_tests()
+212
View File
@@ -0,0 +1,212 @@
import pytest
from flask import Flask
@pytest.fixture
def mcp_app(monkeypatch, tmp_path):
from src.api import tokens_db
from src.auth import db as auth_db
from src.mcp.auth import init_mcp_auth
from src.subscriptions import db as sub_db
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
from src.mcp import usage
from src.mcp.oauth import store as oauth_store
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
tokens_db.init_schema(db_path)
oauth_store.init_schema(db_path)
usage.init_schema(db_path)
app = Flask(__name__)
@app.route("/_mcp", methods=["GET", "POST"])
def _mcp():
return "ok", 200
init_mcp_auth(app)
yield app, db_path
auth_db.reset_conn_for_tests()
def _subscribed_uid():
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
uid = auth_db.create_user("mcp@ex.fr", "hash")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
return uid
def test_missing_header_401_with_challenge(mcp_app):
app, _ = mcp_app
resp = app.test_client().post("/_mcp")
assert resp.status_code == 401
assert resp.headers["WWW-Authenticate"].startswith('Bearer realm="colibre-mcp"')
def test_empty_bearer_401(mcp_app):
app, _ = mcp_app
resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer "})
assert resp.status_code == 401
def test_unknown_token_401(mcp_app):
app, _ = mcp_app
resp = app.test_client().post(
"/_mcp", headers={"Authorization": "Bearer colibre_unknown"}
)
assert resp.status_code == 401
def test_revoked_mcp_token_401(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
uid = _subscribed_uid()
token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
tokens_db.revoke_user_token(db_path, tid, uid)
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
def test_api_kind_token_rejected_401(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
uid = _subscribed_uid()
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="api")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
def test_mcp_token_without_user_403(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
token, _ = tokens_db.create_token(db_path, "x", user_id=None, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
def test_mcp_token_no_active_subscription_403(mcp_app):
from src.api import tokens_db
from src.auth import db as auth_db
app, db_path = mcp_app
uid = auth_db.create_user("nosub@ex.fr", "hash") # aucun abonnement
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
def test_mcp_token_active_subscription_passes_and_increments(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
uid = _subscribed_uid()
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert tokens_db.get_token_by_plaintext(db_path, token)["count_total"] == 1
def test_tous_abonnes_bypasses_subscription(mcp_app, monkeypatch):
from src.api import tokens_db
from src.auth import db as auth_db
monkeypatch.setattr("src.mcp.auth.TOUS_ABONNES", True)
app, db_path = mcp_app
uid = auth_db.create_user("nosub2@ex.fr", "hash")
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
def test_oauth_token_active_subscription_passes(mcp_app, monkeypatch):
import time
from src.mcp.oauth import server, store
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
app, db_path = mcp_app
store.init_schema(db_path)
uid = _subscribed_uid()
store.create_client(db_path, "cid", {"redirect_uris": []})
store.save_token(
db_path,
access_token="opaque123",
refresh_token=None,
client_id="cid",
user_id=uid,
scope="mcp",
resource="https://colibre.fr/_mcp",
access_expires_at=server._iso(time.time() + 3600),
refresh_expires_at=None,
)
resp = app.test_client().post(
"/_mcp", headers={"Authorization": "Bearer opaque123"}
)
assert resp.status_code == 200
def test_oauth_token_wrong_audience_401(mcp_app, monkeypatch):
import time
from src.mcp.oauth import server, store
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
app, db_path = mcp_app
store.init_schema(db_path)
uid = _subscribed_uid()
store.save_token(
db_path,
access_token="badaud",
refresh_token=None,
client_id="cid",
user_id=uid,
scope="mcp",
resource="https://evil.example/_mcp",
access_expires_at=server._iso(time.time() + 3600),
refresh_expires_at=None,
)
resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer badaud"})
assert resp.status_code == 401
def test_401_carries_resource_metadata(mcp_app, monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
app, _ = mcp_app
resp = app.test_client().post("/_mcp")
assert resp.status_code == 401
assert "resource_metadata=" in resp.headers["WWW-Authenticate"]
def test_oauth_token_no_subscription_403(mcp_app, monkeypatch):
import time
from src.auth import db as auth_db
from src.mcp.oauth import server, store
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
monkeypatch.setattr("src.mcp.auth.TOUS_ABONNES", False)
app, db_path = mcp_app
store.init_schema(db_path)
uid = auth_db.create_user("nosuboauth@ex.fr", "h") # aucun abonnement
store.save_token(
db_path,
access_token="nosubtok",
refresh_token=None,
client_id="cid",
user_id=uid,
scope="mcp",
resource="https://colibre.fr/_mcp",
access_expires_at=server._iso(time.time() + 3600),
refresh_expires_at=None,
)
resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer nosubtok"})
assert resp.status_code == 403
+50
View File
@@ -0,0 +1,50 @@
from src.mcp.oauth import consent
def test_subscription_ok_tous_abonnes(monkeypatch):
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", True)
assert consent.subscription_ok(999) is True
def test_subscription_ok_delegates(monkeypatch):
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
monkeypatch.setattr(
"src.mcp.oauth.consent.has_active_subscription", lambda uid: uid == 7
)
assert consent.subscription_ok(7) is True
assert consent.subscription_ok(8) is False
def test_render_consent_shows_redirect_host():
html = consent.render_consent(
"Claude", "https://claude.ai/api/mcp/auth_callback", "mcp"
)
assert "claude.ai" in html
assert "Claude" in html
assert 'name="confirm"' in html
def test_render_subscription_required_links_abonnement():
html = consent.render_subscription_required()
assert "/compte/abonnement" in html
def test_render_consent_escapes_client_name():
html = consent.render_consent(
"<script>alert(1)</script>",
"https://claude.ai/api/mcp/auth_callback",
"mcp",
)
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html
def test_render_consent_includes_csrf_token():
html = consent.render_consent(
"Claude",
"https://claude.ai/api/mcp/auth_callback",
"mcp",
csrf_token="tok-abc-123",
)
assert 'name="csrf_token"' in html
assert "tok-abc-123" in html
+273
View File
@@ -0,0 +1,273 @@
import base64
import hashlib
import re
import pytest
from flask import Flask
from flask_login import LoginManager, UserMixin, login_user
class _U(UserMixin):
def __init__(self, uid):
self.id = uid
def _pkce():
verifier = "a" * 64
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
return verifier, challenge
@pytest.fixture
def flow_app(monkeypatch, tmp_path):
from src.auth import db as auth_db
from src.mcp.oauth import routes, store
from src.subscriptions import db as sub_db
db_path = tmp_path / "u.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
store.init_schema(db_path)
app = Flask(__name__)
app.config["SECRET_KEY"] = "x"
app.config["SERVER_NAME"] = "colibre.fr"
lm = LoginManager()
lm.init_app(app)
lm.user_loader(lambda uid: _U(int(uid)))
routes.init_oauth(app)
@app.route("/_test_login/<int:uid>")
def _test_login(uid):
login_user(_U(uid))
return "ok"
yield app, db_path
auth_db.reset_conn_for_tests()
def _register(client):
resp = client.post(
"/oauth/register",
json={
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"client_name": "Claude",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"scope": "mcp offline_access",
},
)
return resp.get_json()["client_id"]
def test_authorize_requires_subscription(flow_app, monkeypatch):
from src.auth import db as auth_db
app, _ = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("nosub@ex.fr", "h")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
_, challenge = _pkce()
resp = client.get(
"/oauth/authorize",
query_string={
"client_id": cid,
"response_type": "code",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"scope": "mcp",
"code_challenge": challenge,
"code_challenge_method": "S256",
"resource": "https://colibre.fr/_mcp",
},
)
assert b"Abonnement requis" in resp.data
def test_authorize_post_requires_subscription(flow_app, monkeypatch):
from src.auth import db as auth_db
app, _ = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("nosub2@ex.fr", "h")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
_, challenge = _pkce()
qs = {
"client_id": cid,
"response_type": "code",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"scope": "mcp",
"code_challenge": challenge,
"code_challenge_method": "S256",
"resource": "https://colibre.fr/_mcp",
}
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
assert resp.status_code == 403
assert b"Abonnement requis" in resp.data
def test_full_flow_issues_audience_bound_token(flow_app, monkeypatch):
from src.auth import db as auth_db
from src.mcp.oauth import store
from src.subscriptions import db as sub_db
app, db_path = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("sub@ex.fr", "h")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
verifier, challenge = _pkce()
qs = {
"client_id": cid,
"response_type": "code",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"scope": "mcp",
"code_challenge": challenge,
"code_challenge_method": "S256",
"resource": "https://colibre.fr/_mcp",
}
# GET affiche le consentement
assert b"Autoriser" in client.get("/oauth/authorize", query_string=qs).data
# POST confirme → redirection avec ?code=
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
assert resp.status_code == 302
code = re.search(r"code=([^&]+)", resp.headers["Location"]).group(1)
# échange du code
tok = client.post(
"/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"client_id": cid,
"code_verifier": verifier,
"resource": "https://colibre.fr/_mcp",
},
)
assert tok.status_code == 200
access = tok.get_json()["access_token"]
row = store.get_token_by_access(db_path, access)
assert row["user_id"] == uid
assert row["resource"] == "https://colibre.fr/_mcp"
def test_wrong_verifier_rejected(flow_app, monkeypatch):
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
app, _ = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("sub2@ex.fr", "h")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
_, challenge = _pkce()
qs = {
"client_id": cid,
"response_type": "code",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"scope": "mcp",
"code_challenge": challenge,
"code_challenge_method": "S256",
}
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
code = re.search(r"code=([^&]+)", resp.headers["Location"]).group(1)
tok = client.post(
"/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"client_id": cid,
"code_verifier": "wrong" * 13,
},
)
assert tok.status_code == 400
assert tok.get_json()["error"] == "invalid_grant"
def _obtain_tokens(client, cid, monkeypatch):
verifier, challenge = _pkce()
qs = {
"client_id": cid,
"response_type": "code",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"scope": "mcp offline_access",
"code_challenge": challenge,
"code_challenge_method": "S256",
}
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
code = re.search(r"code=([^&]+)", resp.headers["Location"]).group(1)
tok = client.post(
"/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"client_id": cid,
"code_verifier": verifier,
},
)
return tok.get_json()
def test_refresh_rotates_and_requires_subscription(flow_app, monkeypatch):
from src.auth import db as auth_db
from src.mcp.oauth import store
from src.subscriptions import db as sub_db
app, db_path = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("ref@ex.fr", "h")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
first = _obtain_tokens(client, cid, monkeypatch)
assert "refresh_token" in first
# Refresh réussi → nouveau refresh (rotation), ancien révoqué.
r = client.post(
"/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": first["refresh_token"],
"client_id": cid,
},
)
assert r.status_code == 200
assert r.get_json()["refresh_token"] != first["refresh_token"]
assert store.get_token_by_refresh(db_path, first["refresh_token"])["revoked_at"]
# Abonnement perdu → refresh refusé (invalid_grant).
new_refresh = r.get_json()["refresh_token"]
monkeypatch.setattr(
"src.mcp.oauth.consent.has_active_subscription", lambda u: False
)
r2 = client.post(
"/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": new_refresh,
"client_id": cid,
},
)
assert r2.status_code == 400
assert r2.get_json()["error"] == "invalid_grant"
+22
View File
@@ -0,0 +1,22 @@
from src.mcp.oauth import metadata
BASE = "https://colibre.fr"
def test_protected_resource_metadata():
m = metadata.protected_resource_metadata(BASE)
assert m["resource"] == "https://colibre.fr/_mcp"
assert m["authorization_servers"] == ["https://colibre.fr"]
assert "offline_access" in m["scopes_supported"]
def test_authorization_server_metadata():
m = metadata.authorization_server_metadata(BASE)
assert m["issuer"] == "https://colibre.fr"
assert m["authorization_endpoint"] == "https://colibre.fr/oauth/authorize"
assert m["token_endpoint"] == "https://colibre.fr/oauth/token"
assert m["registration_endpoint"] == "https://colibre.fr/oauth/register"
assert m["code_challenge_methods_supported"] == ["S256"]
assert m["token_endpoint_auth_methods_supported"] == ["none"]
assert set(m["grant_types_supported"]) == {"authorization_code", "refresh_token"}
assert "offline_access" in m["scopes_supported"]
+50
View File
@@ -0,0 +1,50 @@
import pytest
from flask import Flask
@pytest.fixture
def oauth_app(monkeypatch, tmp_path):
from src.mcp.oauth import routes, store
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "u.sqlite"))
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
store.init_schema(tmp_path / "u.sqlite")
app = Flask(__name__)
app.config["SECRET_KEY"] = "x"
routes.init_oauth(app)
return app
def test_protected_resource_wellknown(oauth_app):
resp = oauth_app.test_client().get("/.well-known/oauth-protected-resource")
assert resp.status_code == 200
assert resp.get_json()["resource"] == "https://colibre.fr/_mcp"
def test_protected_resource_wellknown_mcp_suffix(oauth_app):
resp = oauth_app.test_client().get("/.well-known/oauth-protected-resource/_mcp")
assert resp.status_code == 200
assert resp.get_json()["resource"] == "https://colibre.fr/_mcp"
def test_as_metadata_and_openid(oauth_app):
for path in (
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
):
resp = oauth_app.test_client().get(path)
assert resp.status_code == 200
assert resp.get_json()["code_challenge_methods_supported"] == ["S256"]
def test_dynamic_client_registration(oauth_app):
resp = oauth_app.test_client().post(
"/oauth/register",
json={
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"client_name": "Claude",
"token_endpoint_auth_method": "none",
},
)
assert resp.status_code == 201
assert "client_id" in resp.get_json()
+12
View File
@@ -0,0 +1,12 @@
from flask import Flask
from src.mcp.oauth import server
def test_create_authorization_server(monkeypatch, tmp_path):
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "u.sqlite"))
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
app = Flask(__name__)
app.config["SECRET_KEY"] = "x"
srv = server.create_authorization_server(app)
assert srv is not None
+60
View File
@@ -0,0 +1,60 @@
from src.mcp.oauth import store
def test_create_and_get_client(tmp_path):
db = tmp_path / "u.sqlite"
store.init_schema(db)
meta = {
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"scope": "mcp",
}
store.create_client(db, "abc123", meta)
row = store.get_client(db, "abc123")
assert row["client_id"] == "abc123"
assert row["client_metadata"] == meta
assert store.get_client(db, "nope") is None
def test_save_get_delete_code(tmp_path):
db = tmp_path / "u.sqlite"
store.init_schema(db)
store.save_code(
db,
"thecode",
client_id="abc",
user_id=7,
redirect_uri="https://claude.ai/api/mcp/auth_callback",
code_challenge="chal",
code_challenge_method="S256",
scope="mcp",
resource="https://colibre.fr/_mcp",
expires_at="2999-01-01T00:00:00+00:00",
)
row = store.get_code(db, "thecode")
assert row["user_id"] == 7
assert row["code_challenge"] == "chal"
assert row["resource"] == "https://colibre.fr/_mcp"
store.delete_code(db, "thecode")
assert store.get_code(db, "thecode") is None
def test_save_get_revoke_token(tmp_path):
db = tmp_path / "u.sqlite"
store.init_schema(db)
tid = store.save_token(
db,
access_token="acc",
refresh_token="ref",
client_id="abc",
user_id=7,
scope="mcp",
resource="https://colibre.fr/_mcp",
access_expires_at="2999-01-01T00:00:00+00:00",
refresh_expires_at="2999-01-01T00:00:00+00:00",
)
assert store.get_token_by_access(db, "acc")["id"] == tid
assert store.get_token_by_refresh(db, "ref")["user_id"] == 7
store.increment_usage(db, tid)
assert store.get_token_by_access(db, "acc")["count_total"] == 1
store.revoke_token(db, tid)
assert store.get_token_by_access(db, "acc")["revoked_at"] is not None
+72
View File
@@ -117,6 +117,19 @@ def test_search_marches_cpv_filters_correctly():
assert search_marches(cpv="999")["meta"]["total"] == 0 assert search_marches(cpv="999")["meta"]["total"] == 0
def test_build_where_args_cpv_is_prefix():
# le filtre nommé cpv est un « commence par » (code CPV hiérarchique)
args = build_where_args({"cpv": "72"}, None)
assert ("codeCPV__startswith", "72") in args
def test_search_marches_cpv_is_prefix_not_contains():
# "1600" est contenu dans le codeCPV de test (71600000) mais n'en est pas
# le préfixe : en sémantique « commence par », aucun résultat.
assert search_marches(cpv="1600")["meta"]["total"] == 0
assert search_marches(cpv="716")["meta"]["total"] >= 1
def test_search_marches_page_2_is_empty_with_correct_meta(): def test_search_marches_page_2_is_empty_with_correct_meta():
result = search_marches(acheteur_id="123", page=2) result = search_marches(acheteur_id="123", page=2)
assert result["meta"]["page"] == 2 assert result["meta"]["page"] == 2
@@ -154,3 +167,62 @@ def test_compute_org_stats_unknown_is_empty():
assert stats["repartition_annuelle"] == [] assert stats["repartition_annuelle"] == []
assert stats["top_titulaires"] == [] assert stats["top_titulaires"] == []
assert stats["top_cpv"] == [] assert stats["top_cpv"] == []
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_duplicate_columns_deduped(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
# Un client peut répéter une colonne malgré l'enum : pas de doublon au SELECT.
result = search_marches(acheteur_id="123", colonnes=["objet", "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"]
+23
View File
@@ -31,3 +31,26 @@ def test_stats_acheteur_returns_stats():
def test_stats_titulaire_returns_stats(): def test_stats_titulaire_returns_stats():
result = tools.stats_titulaire("345") result = tools.stats_titulaire("345")
assert result["nb_marches"] >= 1 assert result["nb_marches"] >= 1
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']}"
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timedelta, timezone
from src.mcp import usage
def test_record_and_count_since(tmp_path):
db = tmp_path / "u.sqlite"
usage.init_schema(db)
usage.record(db, user_id=7, token_id=1, kind="oauth")
usage.record(db, user_id=7, token_id=1, kind="oauth")
usage.record(db, user_id=9, token_id=2, kind="static")
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
assert usage.count_since(db, 7, past) == 2
assert usage.count_since(db, 9, past) == 1
def test_record_never_raises(tmp_path):
# base non initialisée → record ne doit pas lever
usage.record(tmp_path / "missing.sqlite", user_id=1, token_id=1, kind="oauth")
def test_purge_older_than(tmp_path):
db = tmp_path / "u.sqlite"
usage.init_schema(db)
old = (datetime.now(timezone.utc) - timedelta(days=200)).isoformat(
timespec="seconds"
)
import sqlite3
conn = sqlite3.connect(str(db))
conn.execute(
"INSERT INTO mcp_usage (user_id, token_id, kind, created_at) VALUES (7, 1, 'oauth', ?)",
(old,),
)
conn.commit()
conn.close()
usage.purge_older_than(db, days=90)
past = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
assert usage.count_since(db, 7, past) == 0
+32
View File
@@ -44,3 +44,35 @@ def test_roadmap_content_renders(monkeypatch):
assert "Au vote Y" in s assert "Au vote Y" in s
assert "Votes restants" in s assert "Votes restants" in s
assert "value='2'" in s assert "value='2'" in s
def test_roadmap_content_shows_trial_hint(monkeypatch):
monkeypatch.setattr(
ui.github,
"fetch_roadmap_issues",
lambda: {"en_cours": [], "au_vote": []},
)
monkeypatch.setattr(ui.roadmap_db, "vote_counts", lambda: {})
content = ui.roadmap_content(
editable=True,
balance=0,
sub_status="trial",
trial_ends_at="2026-07-20T10:00:00+00:00",
)
assert "20/07/2026" in str(content)
def test_roadmap_content_no_trial_hint_when_active(monkeypatch):
monkeypatch.setattr(
ui.github,
"fetch_roadmap_issues",
lambda: {"en_cours": [], "au_vote": []},
)
monkeypatch.setattr(ui.roadmap_db, "vote_counts", lambda: {})
content = ui.roadmap_content(
editable=True,
balance=3,
sub_status="active",
trial_ends_at=None,
)
assert "période d'essai" not in str(content)
+12 -5
View File
@@ -48,13 +48,14 @@ def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_pat
monkeypatch.setattr(tableau, "ctx", _Ctx) monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)): with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, column_state, hidden_columns = tableau.apply_saved_view( filter_model, column_state, hidden_columns, active = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}] [1], [{"type": "saved-view-item", "index": view_id}]
) )
assert filter_model is dash.no_update assert filter_model is dash.no_update
assert column_state is dash.no_update assert column_state is dash.no_update
assert hidden_columns is dash.no_update assert hidden_columns is dash.no_update
assert active is dash.no_update
def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path):
@@ -75,8 +76,10 @@ def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path):
monkeypatch.setattr(tableau, "ctx", _Ctx) monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)): with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view( filter_model, returned_column_state, hidden_columns, active = (
[1], [{"type": "saved-view-item", "index": view_id}] tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
) )
assert filter_model == { assert filter_model == {
@@ -86,6 +89,8 @@ def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path):
# tableau-hidden-columns doit être resynchronisé à partir du columnState # tableau-hidden-columns doit être resynchronisé à partir du columnState
# rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True. # rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True.
assert hidden_columns == ["acheteur_nom"] assert hidden_columns == ["acheteur_nom"]
# active-view alimente le bloc de partage (token + URL courte).
assert active["token"] and active["url"].endswith(f"_{active['token']}")
def test_apply_saved_view_missing_ast_key_degrades_gracefully( def test_apply_saved_view_missing_ast_key_degrades_gracefully(
@@ -111,8 +116,10 @@ def test_apply_saved_view_missing_ast_key_degrades_gracefully(
monkeypatch.setattr(tableau, "ctx", _Ctx) monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)): with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view( filter_model, returned_column_state, hidden_columns, _active = (
[1], [{"type": "saved-view-item", "index": view_id}] tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
) )
assert filter_model == {} assert filter_model == {}
+71
View File
@@ -1,3 +1,5 @@
import string
from src.auth import db as auth_db from src.auth import db as auth_db
from src.saved_views import db from src.saved_views import db
@@ -75,3 +77,72 @@ def test_views_deleted_on_user_cascade(users_db_path):
db.upsert(uid, "tableau", "Vue A", "filtres=a") db.upsert(uid, "tableau", "Vue A", "filtres=a")
auth_db.delete_user(uid) auth_db.delete_user(uid)
assert db.list_views(uid, "tableau") == [] assert db.list_views(uid, "tableau") == []
def test_generate_token_is_lowercase_base36_and_length_6():
token = db.generate_token()
assert len(token) == 6
alphabet = set(string.ascii_lowercase + string.digits)
assert set(token) <= alphabet
# Jamais de majuscule (lien plus lisible/dictable, cohérent avec le slug).
assert token == token.lower()
def test_upsert_returns_token_on_insert(users_db_path):
db.init_schema()
uid = _make_user()
token = db.upsert(uid, "tableau", "Ma vue", "q1")
assert token
assert db.list_views(uid, "tableau")[0]["token"] == token
def test_upsert_preserves_token_on_overwrite(users_db_path):
db.init_schema()
uid = _make_user()
token1 = db.upsert(uid, "tableau", "Ma vue", "q1")
token2 = db.upsert(uid, "tableau", "Ma vue", "q2")
assert token2 == token1 # écrasement → lien stable
assert db.list_views(uid, "tableau")[0]["query"] == "q2"
def test_get_by_token_public_lookup(users_db_path):
db.init_schema()
uid = _make_user()
token = db.upsert(uid, "tableau", "Ma vue", "q1")
row = db.get_by_token(token)
assert row is not None
assert row["name"] == "Ma vue"
assert db.get_by_token("zzzzzz") is None
def test_tokens_are_unique_across_views(users_db_path):
db.init_schema()
uid = _make_user()
t1 = db.upsert(uid, "tableau", "Vue A", "a")
t2 = db.upsert(uid, "tableau", "Vue B", "b")
assert t1 != t2
def test_backfill_assigns_tokens_to_null_rows(users_db_path):
db.init_schema()
uid = _make_user()
conn = auth_db.get_conn()
# Simule une ligne pré-migration (token NULL) en contournant upsert.
conn.execute(
"INSERT INTO saved_views "
"(user_id, table_name, name, query, token, created_at, updated_at) "
"VALUES (?, 'tableau', 'Ancienne', 'q', NULL, '', '')",
(uid,),
)
db.init_schema() # doit backfiller
row = conn.execute(
"SELECT token FROM saved_views WHERE name = 'Ancienne'"
).fetchone()
assert row["token"] and len(row["token"]) == 6
# Idempotent : un second appel ne change pas le jeton attribué.
token_after_first = row["token"]
db.init_schema()
row2 = conn.execute(
"SELECT token FROM saved_views WHERE name = 'Ancienne'"
).fetchone()
assert row2["token"] == token_after_first
+77
View File
@@ -0,0 +1,77 @@
import json
from src.auth import db as auth_db
from src.db import schema
from src.saved_views import db as saved_views_db
from src.saved_views import resolve
from src.utils.query_ast import And, Condition, ast_to_dict
def _make_user(email="u@ex.fr"):
auth_db.init_schema()
return auth_db.create_user(email, "hash")
def _seed_view(uid, name="Ma vue"):
ast = And([Condition("objet", "contains", "route")])
column_state = [
{"colId": "montant", "sort": "desc"},
{"colId": "acheteur_nom", "hide": True},
]
query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state})
return saved_views_db.upsert(uid, "tableau", name, query)
def test_resolve_found_applies_view(monkeypatch, users_db_path):
monkeypatch.setattr(resolve.ui, "DOMAIN_NAME", "test.colibre.fr")
saved_views_db.init_schema()
uid = _make_user()
token = _seed_view(uid, "Mes Marchés")
out = resolve.resolve_vue_param(f"mes-marches_{token}", schema)
assert out["found"] is True
assert out["filter_model"] == {
"objet": {"filterType": "text", "type": "contains", "filter": "route"}
}
assert out["hidden_columns"] == ["acheteur_nom"]
assert out["token"] == token
assert out["url"] == f"https://test.colibre.fr/tableau?vue=mes-marches_{token}"
assert out["error"] is None
def test_resolve_slug_is_ignored(monkeypatch, users_db_path):
monkeypatch.setattr(resolve.ui, "DOMAIN_NAME", "test.colibre.fr")
saved_views_db.init_schema()
uid = _make_user()
token = _seed_view(uid)
# Slug bidon → même résolution.
out = resolve.resolve_vue_param(f"nimportequoi_{token}", schema)
assert out["found"] is True
assert out["token"] == token
def test_resolve_unknown_token_returns_error(users_db_path):
saved_views_db.init_schema()
_make_user()
out = resolve.resolve_vue_param("slug_zzzzzz", schema)
assert out["found"] is False
assert out["error"] == resolve.NOT_FOUND_MESSAGE
assert out["filter_model"] is None
def test_resolve_empty_param_returns_error(users_db_path):
saved_views_db.init_schema()
out = resolve.resolve_vue_param("", schema)
assert out["found"] is False
assert out["error"] == resolve.NOT_FOUND_MESSAGE
def test_resolve_corrupt_query_returns_error(users_db_path):
saved_views_db.init_schema()
uid = _make_user()
# query pré-migration (pas du JSON) → même message de repli.
token = saved_views_db.upsert(uid, "tableau", "Vieille", "filtres=a&tris=b")
out = resolve.resolve_vue_param(f"vieille_{token}", schema)
assert out["found"] is False
assert out["error"] == resolve.NOT_FOUND_MESSAGE
@@ -0,0 +1,28 @@
"""Régression : pour un visiteur non abonné, la barre des vues sauvegardées
reste visible, mais « Sauvegarder la vue » et « Mes vues » sont grisés et
désactivés (le gating serveur de save_view reste par ailleurs en place).
"""
import src.app # noqa: F401 # instancie l'app → register_page()
def test_controls_disabled_for_anonymous_visitor(dash_duo):
from src.app import app
dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal(".logo > h1", "colibre", timeout=6)
dash_duo.wait_for_page(dash_duo.server_url + "/tableau")
# La barre est visible pour tous.
bar = dash_duo.wait_for_element("#saved-views-bar", timeout=10)
assert bar.value_of_css_property("display") != "none"
# « Sauvegarder la vue » est désactivé (donc grisé) pour un anonyme.
save_btn = dash_duo.find_element("#btn-save-view")
assert save_btn.get_attribute("disabled") is not None
# « Mes vues » (le bouton toggle du DropdownMenu) est désactivé aussi.
menu_toggle = dash_duo.find_element("#saved-views-menu .dropdown-toggle")
disabled_attr = menu_toggle.get_attribute("disabled")
has_disabled_class = "disabled" in (menu_toggle.get_attribute("class") or "")
assert disabled_attr is not None or has_disabled_class
+193
View File
@@ -0,0 +1,193 @@
import json
from unittest.mock import patch
import dash
from selenium.webdriver.support.ui import WebDriverWait
import src.app # noqa: F401 # instancie l'app → register_page()
from src.auth import db as auth_db
from src.pages import tableau
from src.saved_views import db as saved_views_db
from src.saved_views import resolve
from src.utils.query_ast import And, Condition, ast_to_dict
def _make_user(email="u@ex.fr"):
auth_db.init_schema()
return auth_db.create_user(email, "hash")
def _seed(uid, name="Ma vue"):
ast = And([Condition("objet", "contains", "route")])
query = json.dumps(
{"ast": ast_to_dict(ast), "columnState": [{"colId": "montant", "hide": True}]}
)
return saved_views_db.upsert(uid, "tableau", name, query)
def test_resolve_vue_from_url_found(monkeypatch, users_db_path):
monkeypatch.setattr(resolve.ui, "DOMAIN_NAME", "test.colibre.fr")
saved_views_db.init_schema()
uid = _make_user()
token = _seed(uid)
out = tableau.resolve_vue_from_url(f"?vue=ma-vue_{token}")
assert out["found"] is True
assert out["token"] == token
def test_resolve_vue_from_url_no_param_returns_none(users_db_path):
saved_views_db.init_schema()
assert tableau.resolve_vue_from_url("") is None
assert tableau.resolve_vue_from_url("?autre=1") is None
def test_apply_vue_resolution_found_shows_box(users_db_path):
resolution = {
"found": True,
"filter_model": {
"objet": {"filterType": "text", "type": "contains", "filter": "route"}
},
"column_state": [{"colId": "montant", "hide": True}],
"hidden_columns": ["montant"],
"token": "abc123",
"url": "https://test.colibre.fr/tableau?vue=ma-vue_abc123",
"error": None,
}
fm, cs, hidden, active, feedback = tableau.apply_vue_resolution(resolution)
assert fm == resolution["filter_model"]
assert cs == resolution["column_state"]
assert hidden == ["montant"]
assert active == {"token": "abc123", "url": resolution["url"]}
assert feedback == ""
def test_apply_vue_resolution_not_found_shows_alert(users_db_path):
resolution = {
"found": False,
"filter_model": None,
"column_state": None,
"hidden_columns": None,
"token": None,
"url": None,
"error": resolve.NOT_FOUND_MESSAGE,
}
fm, cs, hidden, active, feedback = tableau.apply_vue_resolution(resolution)
assert fm is dash.no_update
assert cs is dash.no_update
assert active is None
assert resolve.NOT_FOUND_MESSAGE in str(feedback)
def test_apply_vue_resolution_none_is_noop(users_db_path):
out = tableau.apply_vue_resolution(None)
assert all(v is dash.no_update for v in out)
def _fake_user(user_id):
u = type("U", (), {})()
u.is_authenticated = True
u.id = user_id
return u
class _Ctx:
triggered_id = None
def test_render_share_box_visible_when_active():
class_name, value = tableau.render_share_box(
{"token": "abc123", "url": "https://x/tableau?vue=a_abc123"}
)
assert "d-flex" in class_name
assert "d-none" not in class_name
assert value == "https://x/tableau?vue=a_abc123"
def test_render_share_box_hidden_when_none():
class_name, value = tableau.render_share_box(None)
assert "d-none" in class_name
assert value == ""
def test_apply_saved_view_sets_active(monkeypatch, users_db_path):
monkeypatch.setattr(tableau.saved_views_ui, "DOMAIN_NAME", "test.colibre.fr")
saved_views_db.init_schema()
uid = _make_user()
token = _seed(uid, "Ma vue")
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
_Ctx.triggered_id = {"type": "saved-view-item", "index": view_id}
monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)):
out = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
# (filter_model, column_state, hidden, active-view)
assert out[3] == {
"token": token,
"url": f"https://test.colibre.fr/tableau?vue=ma-vue_{token}",
}
def test_save_view_shows_box(monkeypatch, users_db_path):
monkeypatch.setattr(tableau.saved_views_ui, "DOMAIN_NAME", "test.colibre.fr")
saved_views_db.init_schema()
uid = _make_user()
monkeypatch.setattr(tableau, "current_user_has_subscription", lambda: True)
with patch.object(tableau, "current_user", _fake_user(uid)):
out = tableau.save_view(1, "Nouvelle", {}, [])
# (is_open, feedback, refresh, active-view)
assert out[3]["url"].startswith("https://test.colibre.fr/tableau?vue=nouvelle_")
def _type_in_first_filter(dash_duo):
"""Saisit du texte dans le premier filtre flottant présent dans le DOM (AG
Grid virtualise horizontalement : 'objet' peut être hors DOM). Re-localise
l'élément à chaque appel : la grille se re-rend juste après l'application de
la vue (echo columnVisible) et remplace l'input."""
selector = "#tableau_grid .ag-floating-filter-input input"
dash_duo.wait_for_element(selector, timeout=8)
el = dash_duo.find_elements(selector)[0]
dash_duo.driver.execute_script(
"arguments[0].scrollIntoView({block: 'center', inline: 'center'});", el
)
el.click()
el.send_keys("z")
def test_open_shared_view_applies_and_shows_box(dash_duo, users_db_path):
"""?vue=<token> applique la vue et affiche le bloc de partage ; une action
utilisateur (saisie d'un filtre) le masque via l'écouteur d'événements AG
Grid (source utilisateur 'api'), l'écho de l'application étant ignoré."""
saved_views_db.init_schema()
uid = _make_user()
token = _seed(uid, "Ma vue")
from src.app import app
dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal(".logo > h1", "colibre", timeout=6)
dash_duo.wait_for_page(dash_duo.server_url + f"/tableau?vue=ma-vue_{token}")
# Le bloc de partage est visible et affiche l'URL courte (jeton). Le texte est
# renseigné par le même callback que l'affichage : le lire non vide prouve que
# l'écho de l'application n'a PAS masqué le bloc.
dash_duo.wait_for_style_to_equal("#share-url-box", "display", "flex", timeout=10)
dash_duo.wait_for_element("#share-url-text", timeout=6)
WebDriverWait(dash_duo.driver, 10).until(
lambda _d: token in (dash_duo.find_element("#share-url-text").text or "")
)
# Le bouton de copie porte un libellé explicite (UX : pas d'icône seule).
assert "Copier le lien" in dash_duo.find_element("#share-url-box").text
# Une action utilisateur (filtre) masque la box. On re-tente la saisie tant
# que le bloc n'est pas masqué : la fenêtre de re-render post-application
# peut invalider l'input entre le find et le send_keys.
def _box_hidden(_d):
_type_in_first_filter(dash_duo)
display = dash_duo.find_element("#share-url-box").value_of_css_property(
"display"
)
return display == "none"
WebDriverWait(dash_duo.driver, 15).until(_box_hidden)
+58 -5
View File
@@ -5,13 +5,13 @@ class _Row(dict):
"""Imite un sqlite3.Row : accès par clé.""" """Imite un sqlite3.Row : accès par clé."""
def _view(view_id, name, query): def _view(view_id, name, query, token="abc123"):
return _Row(id=view_id, name=name, query=query) return _Row(id=view_id, name=name, query=query, token=token)
def test_bar_style_hidden_for_non_subscriber(): def test_controls_disabled_for_non_subscriber():
assert ui.bar_style(False) == {"display": "none"} assert ui.controls_disabled(False) is True
assert ui.bar_style(True) == {} assert ui.controls_disabled(True) is False
def test_clean_view_name_strips_and_empties(): def test_clean_view_name_strips_and_empties():
@@ -58,3 +58,56 @@ def test_views_table_lists_views():
out = ui.views_table([_view(1, "Vue A", "filtres=a")]) out = ui.views_table([_view(1, "Vue A", "filtres=a")])
text = str(out) text = str(out)
assert "Vue A" in text assert "Vue A" in text
def test_slugify_accents_spaces_case():
assert ui.slugify("Mes Marchés 2024") == "mes-marches-2024"
def test_slugify_special_chars_collapse_and_trim():
assert ui.slugify(" Éà!! --- test__ok ") == "ea-test-ok"
def test_slugify_never_contains_underscore():
assert "_" not in ui.slugify("a_b c")
def test_slugify_empty():
assert ui.slugify("") == ""
assert ui.slugify(None) == ""
def test_build_view_url_dev_domain(monkeypatch):
# DOMAIN_NAME est résolu à l'import ; on patche l'attribut du module ui.
monkeypatch.setattr(ui, "DOMAIN_NAME", "test.colibre.fr")
url = ui.build_view_url("Mes Marchés", "abc123")
assert url == "https://test.colibre.fr/tableau?vue=mes-marches_abc123"
def test_build_view_url_empty_slug_omits_prefix(monkeypatch):
monkeypatch.setattr(ui, "DOMAIN_NAME", "test.colibre.fr")
url = ui.build_view_url("!!!", "abc123")
assert url == "https://test.colibre.fr/tableau?vue=abc123"
def test_token_from_vue_param():
assert ui.token_from_vue_param("mes-marches-2024_abc123") == "abc123"
assert ui.token_from_vue_param("zzz_abc123") == "abc123"
assert ui.token_from_vue_param("abc123") == "abc123"
assert ui.token_from_vue_param("") is None
assert ui.token_from_vue_param(None) is None
def test_view_row_open_uses_short_url(monkeypatch):
monkeypatch.setattr(ui, "DOMAIN_NAME", "test.colibre.fr")
row = ui._view_row(_view(1, "Mes Marchés", "q", token="tok123"))
text = str(row)
assert "https://test.colibre.fr/tableau?vue=mes-marches_tok123" in text
def test_view_row_has_clipboard_with_url(monkeypatch):
monkeypatch.setattr(ui, "DOMAIN_NAME", "test.colibre.fr")
row = ui._view_row(_view(1, "Mes Marchés", "q", token="tok123"))
text = str(row)
assert "Clipboard" in text
assert "Copier le lien" in text
@@ -6,6 +6,39 @@ def test_reabo_button_links_to_abonnement_page():
assert "/a-propos/abonnement" in text assert "/a-propos/abonnement" in text
def test_free_access_view_message():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._free_access_view())
assert "temporairement accès à toutes les fonctionnalités" in text
assert "Pensez à copier les liens vers vos vues" in text
def test_no_sub_view_uses_free_access_when_tous_abonnes():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._no_sub_view(True, None))
assert "temporairement accès" in text
assert "Abonnez-vous" not in text
def test_no_sub_view_uses_reabo_when_flag_off():
from src.pages.compte import abonnement as compte_abonnement
text = str(compte_abonnement._no_sub_view(False, None))
assert "Abonnez-vous" in text
assert "temporairement accès" not in text
def test_no_sub_view_shows_expired_alert_when_flag_off():
from src.pages.compte import abonnement as compte_abonnement
row = {"status": "expired", "current_period_end": None}
text = str(compte_abonnement._no_sub_view(False, row))
assert "expiré" in text
assert "Abonnez-vous" in text
def test_active_view_shows_cancel(monkeypatch): def test_active_view_shows_cancel(monkeypatch):
from src.pages.compte import abonnement as compte_abonnement from src.pages.compte import abonnement as compte_abonnement

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