Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a1b43a6fa | |||
| 03bf5686e0 | |||
| dadb11a164 | |||
| 6374ae1ec0 | |||
| 6252b65671 | |||
| 85b0dd0b39 | |||
| 986600d3ef | |||
| 30375a262f | |||
| f571627a6b | |||
| 43d8ba5098 | |||
| 17e9c33934 | |||
| d920e1ca8e | |||
| ca320bdc1f | |||
| 2efb5e067c | |||
| b077baf0a3 | |||
| b4930537a1 | |||
| f668db1419 | |||
| 1636db68ea | |||
| 9961bb19df | |||
| 8905f083ea | |||
| 70ca65e962 | |||
| ad293acd88 | |||
| ea95431eae | |||
| 906c6a7f15 | |||
| f84ca73b04 | |||
| a225dbb0ef | |||
| 34f10f2283 | |||
| 8b9433b7f2 | |||
| c0d8a40c92 | |||
| bee401430b | |||
| 31427a8b3b | |||
| e0828a7e13 | |||
| 770b7cbdf8 | |||
| f0a891b73f | |||
| 3034e911e7 | |||
| 0e0fb32222 | |||
| 2a22951829 | |||
| 301a8668b3 | |||
| 439b6f9cdc | |||
| 3a0d868e93 | |||
| a66af0190d | |||
| 940f0de0a7 | |||
| ac73f6efc1 | |||
| d2fa17761d | |||
| bb2aee85a2 | |||
| 10770c5d14 | |||
| cc412f459f | |||
| 03afb8347c | |||
| 34a8470792 | |||
| 8258b92f98 | |||
| 4e3c8517ad | |||
| 633462f18b | |||
| ca2f6746f8 | |||
| 0970803e5e | |||
| 7dea07a30f | |||
| c693992041 | |||
| db0550a170 | |||
| 13538a4f41 | |||
| adbaffe545 | |||
| f6132d41a2 | |||
| 490ecf940d | |||
| f63c08f381 | |||
| 8b3e72d20a | |||
| 341dd84686 | |||
| 87d59c2ce0 | |||
| 7d413ccda4 | |||
| 91aeca6f04 | |||
| 639c2eb986 | |||
| 0d2ad33d6c | |||
| 2454c8109f | |||
| 76c1096f4c | |||
| 94addf7fae | |||
| 0c22138e19 | |||
| 495fe153b1 | |||
| 9e0aa71910 | |||
| f603d98ddb | |||
| c9074462d6 | |||
| 0e1991683b | |||
| 10c1e45d71 | |||
| f2810640ba | |||
| 8d937d387a | |||
| 04125f91f8 | |||
| b4e778ab8c | |||
| 445c8666cf | |||
| 26de687ece | |||
| ebc4736025 | |||
| 6e9b451734 | |||
| 6dd7dca271 | |||
| f580459bd5 | |||
| 117ce9a4ab | |||
| 4e07c69a85 | |||
| cb205a89f3 | |||
| ea6998ac11 | |||
| 34e5065550 | |||
| d2e114da34 | |||
| a9d9594a31 | |||
| 5b7074529c | |||
| 3493dbba25 | |||
| ed288823c9 | |||
| 6b9cc412f7 | |||
| 6cea1fdc95 | |||
| 98cbcd9311 | |||
| 9bebc96c5d | |||
| cd29c0358b | |||
| 89cc754a95 | |||
| 4b3a2f5432 | |||
| 4c9bb87808 | |||
| d29c245f9d | |||
| a8ba261e7c | |||
| 71ad4dd1ca | |||
| ff78b86ad0 | |||
| 4dbe986310 | |||
| ec45dc05ec | |||
| 4f18cc07f2 | |||
| 697711f676 | |||
| 2318702e29 | |||
| 647d7a7759 | |||
| 9314da7ee6 | |||
| 7941b08d4a |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
@@ -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"])
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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>"
|
||||||
|
)
|
||||||
@@ -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"],
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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 (?, ?)",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
+153
-62
@@ -1,14 +1,101 @@
|
|||||||
|
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(
|
||||||
|
"colibre ouvre les données des marchés publics français à toutes et "
|
||||||
|
"tous : recherchez, filtrez, cartographiez, exportez. Gratuitement "
|
||||||
|
"et sans compte.",
|
||||||
|
className="text-center",
|
||||||
|
style={
|
||||||
|
"fontWeight": 600,
|
||||||
|
"maxWidth": "700px",
|
||||||
|
"margin": "4rem auto 1.5rem",
|
||||||
|
"color": "var(--primary-color-text)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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 +106,74 @@ 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",
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
html.P(
|
],
|
||||||
[
|
),
|
||||||
"...ou bien filtrez les marchés publics dans la vue ",
|
html.P(
|
||||||
dcc.Link("Tableau", href="/tableau"),
|
[
|
||||||
],
|
"...ou bien filtrez les marchés publics dans la vue ",
|
||||||
style={"textAlign": "center"},
|
dcc.Link("Tableau", href="/tableau"),
|
||||||
id="mention_tableau",
|
],
|
||||||
),
|
style={"textAlign": "center"},
|
||||||
# html.Div(
|
id="mention_tableau",
|
||||||
# className="search_options",
|
),
|
||||||
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
|
home_intro,
|
||||||
# ),
|
# html.Div(
|
||||||
dbc.Row(id="search_results"),
|
# 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 +219,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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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 slug↔jeton 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])
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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_"):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" > ?'
|
||||||
|
|||||||
@@ -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",)]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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 "<script>" 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
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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']}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 == {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ def _plan_env(monkeypatch):
|
|||||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||||
|
|
||||||
|
|
||||||
|
def test_layout_redirects_to_abonnement_when_tous_abonnes(monkeypatch):
|
||||||
|
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||||
|
from src.pages.compte import abonnement_mes_infos as m
|
||||||
|
|
||||||
|
text = str(m.layout())
|
||||||
|
# redirection du flag vers /compte/abonnement (href exact), pas le guard de
|
||||||
|
# connexion ni un renvoi vers la page carte bancaire mes-infos
|
||||||
|
assert "href='/compte/abonnement'" in text
|
||||||
|
assert "Mes informations de facturation" not in text
|
||||||
|
assert "connexion" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_selectable_cards_render_both_plans():
|
def test_selectable_cards_render_both_plans():
|
||||||
from src.pages.compte import abonnement_mes_infos as m
|
from src.pages.compte import abonnement_mes_infos as m
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user