Merge branch 'dev' into feature/73_compte_utilisateur

This commit is contained in:
Colin Maudry
2026-06-24 03:01:43 +02:00
76 changed files with 15560 additions and 1888 deletions
@@ -0,0 +1,206 @@
# Observatoire — filtrage natif DuckDB
## Contexte
La page `/observatoire` construit ses cartes, ses téléchargements et sa prévisualisation
tabulaire à partir de la fonction `prepare_dashboard_data` (dans `src/utils/data.py`).
Aujourd'hui, cette fonction prend une `pl.LazyFrame` — typiquement obtenue par
`query_marches().lazy()` — et applique une série de filtres côté Polars.
`query_marches()` matérialise l'intégralité de la table `decp` (~1,5 M lignes) en
DataFrame Polars, même lorsqu'un utilisateur applique des filtres restrictifs. Les
filtres sont ensuite appliqués sur cet ensemble déjà matérialisé.
Le pattern utilisé par `_fetch_page_sql` (dans `src/utils/table.py`) montre comment
déléguer le filtrage à DuckDB :
1. Un traducteur (`filter_query_to_sql`, dans `src/utils/table_sql.py`) transforme le
DSL utilisateur en `(where_sql, params)`.
2. `query_marches(where_sql=..., params=...)` ne matérialise que le sous-ensemble utile.
Ce spec décrit comment appliquer ce même pattern aux filtres de l'observatoire.
## Objectifs
- Réduire la consommation mémoire et le temps de chaque callback de l'observatoire
en poussant le filtrage au niveau DuckDB.
- Conserver strictement la sémantique des filtres actuels (pas de régression
fonctionnelle).
- Garder une frontière claire : un helper pur `dashboard_filters_to_sql` qui ne
touche pas à la base, et une `prepare_dashboard_data` fine qui appelle DuckDB.
## Non-objectifs
- Pas de refonte de l'UI de filtres.
- Pas d'optimisation ou de cache supplémentaire autour de
`_compute_dashboard_children` (déjà `@cache.memoize()`).
- Pas de changement du comportement par défaut (365 derniers jours quand aucune
année n'est sélectionnée).
## Architecture
### Nouveau helper — `src/utils/table_sql.py`
```python
def dashboard_filters_to_sql(
dashboard_year=None,
dashboard_acheteur_id=None,
dashboard_acheteur_categorie=None,
dashboard_acheteur_departement_code=None,
dashboard_titulaire_id=None,
dashboard_titulaire_categorie=None,
dashboard_titulaire_departement_code=None,
dashboard_marche_type=None,
dashboard_marche_objet=None,
dashboard_marche_code_cpv=None,
dashboard_marche_considerations_sociales=None,
dashboard_marche_considerations_environnementales=None,
dashboard_marche_techniques=None,
dashboard_marche_innovant=None,
dashboard_marche_sous_traitance_declaree=None,
dashboard_montant_min=None,
dashboard_montant_max=None,
) -> tuple[str, list]:
"""Traduit les filtres du tableau de bord en (where_clause, params) DuckDB."""
```
Fonction pure, sans accès à la base. Même signature que `prepare_dashboard_data`
actuelle (hors `lff`). Retourne `("TRUE", [])` si aucun filtre n'est actif.
### Réécriture — `prepare_dashboard_data` (`src/utils/data.py`)
```python
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
where_sql, params = dashboard_filters_to_sql(**filter_params)
return query_marches(where_sql=where_sql, params=params)
```
- **Signature** : suppression du paramètre `lff`. Retour `pl.DataFrame` (et non plus
`pl.LazyFrame`).
- Les appelants qui ont besoin d'une LazyFrame appellent `.lazy()` sur le résultat.
### Appelants — `src/pages/observatoire.py`
Trois sites d'appel à adapter :
1. **`_compute_dashboard_children`** (ligne ~668) — on remplace
```python
lff: pl.LazyFrame = query_marches().lazy()
lff = prepare_dashboard_data(lff=lff, **filter_params)
dff = lff.collect(engine="streaming")
```
par
```python
dff = prepare_dashboard_data(**filter_params)
lff = dff.lazy()
```
Les appels existants à `make_donut`, `get_distance_histogram`, `get_top_org_table`,
`get_barchart_sources` continuent de recevoir `lff` ; `get_geographic_maps`
continue de recevoir `dff`. `df_per_uid` est calculé à partir de `dff`.
2. **`download_observatoire`** (ligne ~791) —
```python
dff = prepare_dashboard_data(**(filter_params or {}))
if hidden_columns:
dff = dff.drop(hidden_columns)
def to_bytes(buffer):
dff.write_excel(buffer, worksheet="DECP")
```
3. **`populate_preview_table`** (ligne ~882) —
```python
dff = prepare_dashboard_data(**(filter_params or {}))
return prepare_table_data(
dff.lazy(), # prepare_table_data accepte une LazyFrame
...
)
```
## Traduction des filtres
| Filtre | Actuel (Polars) | Cible (SQL DuckDB) |
| --------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------- |
| `dashboard_year` (présent) | `dt.year() == int(year)` | `YEAR("dateNotification") = ?` |
| `dashboard_year` (absent) — comportement par défaut | `> now - 365j` | `"dateNotification" > ?` (datetime calculé à l'appel) |
| `dashboard_acheteur_id` | `str.contains(val)` | `"acheteur_id" LIKE ?` avec `%val%` |
| `dashboard_acheteur_categorie` | `== val` (skip si acheteur_id présent) | `"acheteur_categorie" = ?` |
| `dashboard_acheteur_departement_code` | `is_in(list)` (skip si acheteur_id présent) | `"acheteur_departement_code" IN (?, ?, ...)` |
| `dashboard_titulaire_id` | idem acheteur | idem |
| `dashboard_titulaire_categorie` | idem | idem |
| `dashboard_titulaire_departement_code` | idem | idem |
| `dashboard_marche_type` | `== val` | `"type" = ?` |
| `dashboard_marche_objet` | `str.contains("(?i)val")` | `"objet" ILIKE ?` avec `%val%` |
| `dashboard_marche_code_cpv` | `str.starts_with(val)` | `"codeCPV" LIKE ?` avec `val%` |
| `dashboard_marche_techniques` | `str.split(", ").list.set_intersection(xs).list.len() > 0` | `list_has_any(string_split("techniques", ', '), ?::VARCHAR[])` |
| `dashboard_marche_considerations_sociales` | idem | idem sur `"considerationsSociales"` |
| `dashboard_marche_considerations_environnementales` | idem | idem sur `"considerationsEnvironnementales"` |
| `dashboard_marche_innovant` (`"oui"`/`"non"`, sinon skip) | `== val` | `"marcheInnovant" = ?` |
| `dashboard_marche_sous_traitance_declaree` | idem | `"sousTraitanceDeclaree" = ?` |
| `dashboard_montant_min` | `>= val` | `"montant" >= ?` |
| `dashboard_montant_max` | `<= val` | `"montant" <= ?` |
**Logique conditionnelle conservée** : si `dashboard_acheteur_id` est fourni, les filtres
`categorie` et `departement_code` acheteur sont ignorés (même chose pour titulaire).
**Traitement des valeurs spéciales** :
- `dashboard_marche_innovant` / `dashboard_marche_sous_traitance_declaree` : valeur
`"all"` ou falsy → aucun filtre ajouté.
- `dashboard_year` : converti en `int` avant injection.
- `dashboard_montant_min` / `_max` : `None` → aucun filtre (distinct de `0`, qui reste
un filtre valide via `>=` ou `<=`).
**Sécurité SQL** : toutes les valeurs utilisateurs passent par DuckDB en paramètres liés
(`?`). Seuls des noms de colonnes statiques (contrôlés par le code) sont injectés dans le
fragment SQL via `f"..."`. Pas de différence avec le pattern existant de
`filter_query_to_sql`.
## Tests
### Unitaires (nouveaux)
Nouveau fichier `tests/test_dashboard_filters_to_sql.py` :
- Cas vide → `("TRUE", [])`.
- Un seul filtre simple (année, type, etc.) → fragment SQL et params attendus.
- Filtre montant min/max (migration de l'actuel `test_010_observatoire_montant_filter`).
- Filtre liste (techniques, considerationsSociales) → usage de `list_has_any`.
- Filtre acheteur_id fourni → catégorie/département acheteur ignorés.
- Filtre `"all"` / `None` sur innovant/sous_traitance → aucun fragment ajouté.
- Comportement par défaut sans année → fragment `"dateNotification" > ?` avec un param
datetime à ~365 j dans le passé (tolérance de quelques secondes).
### Intégration (nouveau, léger)
Un test qui appelle `prepare_dashboard_data` contre `tests/test.parquet` avec un ou
deux filtres connus, vérifie le `height` et la bonne nature du retour (`pl.DataFrame`).
### Test Selenium existant
`test_009_observatoire_filter_persistence` et `test_008_observatoire_navigation_from_search`
ne touchent pas à la signature ; ils doivent continuer à passer.
## Risques et migration
- **Risque sémantique** : la fonction Polars `str.contains` utilisée pour les IDs est
un regex. Les utilisateurs attendent probablement un contains littéral sur un SIRET
(14 chiffres). Le passage à `LIKE '%val%'` est neutre si la valeur ne contient pas de
caractère spécial regex — ce qui est le cas pour des SIRET. **Hypothèse** acceptée :
le contenu `dashboard_acheteur_id`/`dashboard_titulaire_id` est alphanumérique.
- **Risque de drift du cache** : la date "365 derniers jours" n'est pas incluse dans
la clé de cache de `_compute_dashboard_children`. C'est un comportement pré-existant
; non traité par ce spec.
- **Import circulaire** : `src/utils/data.py` importe déjà depuis `src/db.py`.
`src/utils/table_sql.py` importe depuis `src/utils/table.py`. Pas de nouveau cycle.
## Succès
- Les 3 callbacks de l'observatoire restent fonctionnellement équivalents.
- Les tests unitaires et d'intégration passent.
- Une inspection manuelle confirme un temps d'exécution réduit sur un filtre
sélectif (par ex. un département + une année).
@@ -0,0 +1,428 @@
# API privée decp.info — Design
**Date** : 2026-05-13
**Statut** : design validé, en attente du plan d'implémentation
## 1. Contexte et objectifs
decp.info reçoit des demandes récurrentes pour un accès programmatique aux
données DECP exposées par l'application web. Le besoin est d'ouvrir une API
HTTP **privée** (accès sur token), inspirée de l'API tabulaire de data.gouv.fr
(https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/swagger/),
qu'un utilisateur en cours s'est déjà appropriée comme référence.
Objectifs explicites :
- Réponses rapides.
- API documentée (OpenAPI + Swagger UI).
- Suivi de la consommation par utilisateur.
Non-objectifs (V1) :
- Self-service de création de tokens via UI web.
- Rate-limiting / quotas.
- Formats de sortie autres que JSON (CSV, Parquet…).
- Endpoints sémantiques métier (`/acheteurs/{id}`, etc.).
## 2. Choix structurants
### 2.1 Framework : Flask + flask-smorest
L'API est ajoutée à l'application Flask existante (serveur Dash) sous forme
d'un blueprint flask-smorest monté sur `/api/v1`. Choix motivé par :
- L'app Dash actuelle tourne déjà sur Flask via gunicorn.
- DuckDB est ouvert une seule fois au boot dans `src/db.py` (`conn` read-only)
et peut être partagé directement par les endpoints API.
- L'API est tabulaire avec filtres **dynamiques** : la liste des colonnes et
des types vient du schéma DuckDB, pas d'une déclaration Pydantic. Les
bénéfices de FastAPI (auto-validation Pydantic) sont donc faibles.
- flask-smorest génère OpenAPI + sert Swagger UI nativement.
- Un seul process, un seul serveur, un seul déploiement.
Alternatives écartées :
- **FastAPI séparé reverse-proxié** : deux processus, ops plus complexe,
bénéfice marginal vu les filtres dynamiques.
- **FastAPI englobant Flask via WSGIMiddleware** : changerait le serveur de
toute l'app Dash existante, migration risquée.
### 2.2 Style d'API : tabulaire générique
Un endpoint unique de requête (`/api/v1/data`) avec filtres dynamiques sur
toutes les colonnes du schéma, à l'image du swagger cible. Aucun endpoint
sémantique métier en V1.
### 2.3 Authentification : tokens admin manuels
Tokens Bearer émis manuellement par l'admin via un CLI. Pas de page web de
gestion en V1. Modèle prévu pour se lier ultérieurement aux comptes
utilisateurs (cf. `comptes_utilisateurs.md`) sans migration de données.
### 2.4 Suivi de consommation : Matomo asynchrone + compteurs locaux
- Matomo en fire-and-forget pour l'analyse fine (qui, quand, quoi, code HTTP).
- Compteurs locaux SQLite (`count_total`, `last_used_at`) pour identifier
les tokens inactifs et préparer un éventuel rate-limit futur.
## 3. Architecture
### 3.1 Arborescence
```
src/api/
├── __init__.py # init_api(server) — enregistre le blueprint flask-smorest
├── routes.py # endpoints /data, /schema, /health
├── schemas.py # marshmallow : query params, réponses
├── filters.py # parsing & validation `col__op=val` → (where_sql, params)
├── auth.py # décorateur @require_token, header Authorization Bearer
├── tracking.py # worker thread compteurs SQLite + httpx fire-and-forget Matomo
├── tokens_db.py # CRUD api_tokens dans users.sqlite
└── tokens_cli.py # python -m src.api.tokens_cli create|list|revoke
```
`src/auth/` reste réservé aux comptes utilisateurs interactifs
(`comptes_utilisateurs.md`), distincts des tokens API.
### 3.2 Branchement
Dans `src/app.py`, après l'init Dash :
```python
from src.api import init_api
init_api(app.server)
```
`init_api` enregistre le blueprint sur `/api/v1` et expose :
- `/api/v1/data`
- `/api/v1/schema`
- `/api/v1/health`
- `/api/v1/swagger` (UI)
- `/api/v1/openapi.json`
### 3.3 Partage de la connexion DuckDB
Les routes importent `src.db.conn` et utilisent les helpers existants
(`query_marches`, `count_marches`) ainsi que `src.db.schema` (Polars Schema)
pour la whitelist de colonnes.
## 4. Stockage
### 4.1 SQLite consolidée
Une seule base SQLite, `users.sqlite` à la racine, contient :
- `users` (futur — cf. `comptes_utilisateurs.md`)
- `api_tokens` (V1)
Bénéfice : un seul fichier à sauvegarder et migrer ; la liaison future
`api_tokens.user_id → users.id` est immédiate sans migration de données.
### 4.2 Schéma `api_tokens`
```sql
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
);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
```
`user_id` est `NULL` pour les tokens admin manuels. Quand le self-service
arrivera, il suffira de le renseigner.
## 5. Endpoints
### 5.1 Vue d'ensemble
| Méthode | Path | Auth | Rôle |
| ------- | ---------------------- | ------ | ------------------------------------------- |
| GET | `/api/v1/data` | Bearer | Endpoint tabulaire principal |
| GET | `/api/v1/schema` | Bearer | Liste des colonnes (nom, type, description) |
| GET | `/api/v1/health` | Aucune | Sonde monitoring |
| GET | `/api/v1/swagger` | Aucune | Swagger UI |
| GET | `/api/v1/openapi.json` | Aucune | Spec OpenAPI |
### 5.2 `/api/v1/data` — langage de requête
Filtres en query string, opérateurs suffixés par `__` (mirror swagger cible) :
| Opérateur | Sens |
| -------------------- | ----------------------------------------------------- |
| `__exact` | égalité |
| `__contains` | sous-chaîne (LIKE %v%) |
| `__notcontains` | négation de `__contains` |
| `__less` | ≤ |
| `__greater` | ≥ |
| `__strictly_less` | < |
| `__strictly_greater` | > |
| `__in` | liste séparée par virgules |
| `__notin` | négation de `__in` |
| `__isnull` | `IS NULL` (valeur ignorée) |
| `__isnotnull` | `IS NOT NULL` (valeur ignorée) |
| `__sort` | `asc` ou `desc` — ordre = ordre des params dans l'URL |
Autres paramètres réservés :
- `page` (int, défaut 1, ≥1)
- `page_size` (int, défaut 50, max 1000)
- `columns` (string, liste séparée par virgules ; défaut = toutes)
- `count` (bool, défaut `true` ; `false``meta.total` absent, économise un `COUNT(*)`)
Exemple :
```
GET /api/v1/data?acheteur_departement_code__exact=44
&dateNotification__greater=2024-01-01
&montant__strictly_greater=100000
&objet__contains=informatique
&cpv_8__in=72000000,72200000
&dateNotification__sort=desc
&page=1
&page_size=50
&columns=uid,objet,montant,dateNotification
```
### 5.3 Sécurité du parsing
`filters.py` est l'unique chemin de génération du `WHERE` SQL :
1. Chaque clé `<col>__<op>` est splittée puis validée :
- `<col>` doit être dans `src.db.schema` (whitelist stricte).
- `<op>` doit être dans la liste blanche d'opérateurs.
- La valeur est convertie selon le type Polars de la colonne :
- `String` : utilisée telle quelle.
- `Int*` : `int(value)`, 400 si non parseable.
- `Float*` : `float(value)`, 400 si non parseable.
- `Date` / `Datetime` : ISO 8601 (`YYYY-MM-DD` ou `YYYY-MM-DDTHH:MM:SS`), 400 sinon.
- Booléens : **les colonnes booléennes sont stockées comme strings
"oui"/"non" en DuckDB** (cf. `src/db.py:43`), donc traitées comme
`String`. L'utilisateur filtre avec `colonne__exact=oui`.
2. Le `WHERE` est composé de fragments paramétrés (`?`) ; les valeurs
utilisateur sont passées au moteur DuckDB via les paramètres, **jamais
concaténées** dans le SQL.
3. Le résultat est consommé par `src.db.query_marches(where_sql=..., params=...)`
qui existe déjà.
### 5.4 Format de réponse
```json
{
"data": [{ "uid": "...", "objet": "...", "montant": 12345.0 }],
"meta": { "page": 1, "page_size": 50, "total": 1234 },
"links": {
"next": "/api/v1/data?...&page=2",
"prev": null
}
}
```
`meta.total` est omis si `count=false`. `links.next`/`links.prev` sont
`null` aux extrémités.
### 5.5 `/api/v1/schema`
```json
{
"columns": [
{ "name": "uid", "type": "string", "description": "..." },
{ "name": "montant", "type": "float", "description": "..." }
]
}
```
Descriptions tirées de `../decp-processing/reference/base_schema.json` si
disponible ; sinon vides.
### 5.6 V1 : JSON only
Pas de CSV / Parquet. Ajout possible plus tard via `?format=`.
## 6. Authentification
### 6.1 Transmission
Header HTTP standard :
```
Authorization: Bearer decpinfo_a1b2c3d4...
```
Pas de support via query string (fuites dans les logs).
### 6.2 Format du token
Préfixe `decpinfo_` + 32 octets aléatoires hex (43 caractères au total).
Le préfixe facilite la détection de fuites (gitleaks, etc.).
### 6.3 Hashing
`sha256(token)` stocké dans `api_tokens.token_hash`. Pas de bcrypt/argon2 :
les tokens ont 256 bits d'entropie, le brute-force est impossible et un
hash lent ralentirait inutilement chaque requête API.
### 6.4 Décorateur `@require_token`
1. Lit `Authorization` ; absent → 401 `missing_token`.
2. Calcule `sha256`, `SELECT` indexé.
3. Pas trouvé → 401 `invalid_token`.
4. `revoked_at IS NOT NULL` → 401 `revoked_token`.
5. Pose `flask.g.token_id` pour `tracking.py`.
### 6.5 CLI de gestion
`python -m src.api.tokens_cli` :
```
create --label "Marie Dupont - étude transport 2026"
→ affiche UNE FOIS le token plaintext (irrécupérable ensuite)
list
→ id | label | created_at | last_used_at | count_total | revoked?
revoke <id>
→ set revoked_at = now() (ISO 8601 UTC)
```
Pas d'UI web pour les tokens en V1.
## 7. Suivi de consommation
### 7.1 Hook
`@bp.after_request` déclenche deux actions **sans bloquer la réponse** :
1. Enfilage d'un update SQLite dans une `queue.Queue` consommée par un
worker thread unique (writer série, pas de contention SQLite).
2. POST httpx fire-and-forget vers la Tracking API Matomo.
Les erreurs des deux chemins sont loggées en `warning` mais jamais propagées
à l'utilisateur.
### 7.2 Update SQLite
```sql
UPDATE api_tokens
SET count_total = count_total + 1,
last_used_at = ?
WHERE id = ?
```
### 7.3 Event Matomo
```
POST https://analytics.maudry.com/matomo.php
idsite=14
rec=1
url=https://decp.info/api/v1/data?<query>
action_name=API /data
uid=token-<id> # jamais le token plaintext
dimension1=<token_id>
dimension2=<status_code>
ua=<user_agent client>
```
Custom Dimensions à créer côté Matomo : `dimension1=token_id`,
`dimension2=http_status`.
### 7.4 Variables d'environnement nouvelles
```
MATOMO_URL=https://analytics.maudry.com/matomo.php
MATOMO_SITE_ID=14
MATOMO_TRACKING_ENABLED=true # false en dev/test par défaut
USERS_DB_PATH=./users.sqlite # tests : tests/users.test.sqlite
```
## 8. Erreurs
Format uniforme (RFC 7807, déjà standard flask-smorest) :
```json
{
"code": 400,
"status": "Bad Request",
"message": "Colonne inconnue 'foo'.",
"errors": { "field": "foo__exact" }
}
```
| HTTP | Cas |
| ---- | --------------------------------------------------------------------- |
| 200 | Succès |
| 400 | Colonne/opérateur/valeur invalide, `page_size` hors bornes |
| 401 | `missing_token` / `invalid_token` / `revoked_token` |
| 404 | Path API inexistant |
| 500 | Exception non gérée — message générique, stack trace loggée seulement |
Pas de 429 en V1.
Les 4xx sont loggées en `info` (path + token_id), les 500 en `error` avec
stack trace.
## 9. Tests
Tests pytest purs (pas de Selenium) via `app.server.test_client()`.
```
tests/api/
├── test_filters.py # parsing, génération SQL/params, erreurs
├── test_auth.py # 401 cases, last_used_at update
├── test_tokens_cli.py # create/list/revoke
├── test_endpoints_data.py # pagination, filtres, sort, columns, count=false
├── test_endpoints_schema.py # /schema renvoie les colonnes attendues
├── test_health.py # /health 200 sans auth
└── test_tracking.py # compteurs SQLite, Matomo désactivé par défaut + mock httpx
```
Fixtures pytest :
- `api_client` : `app.server.test_client()`
- `valid_token_header` : crée un token dans `tests/users.test.sqlite`, renvoie le header `Authorization: Bearer …`
- `revoked_token_header` : idem avec `revoked_at` set
Ajouts `pyproject.toml` `[tool.pytest.ini_options].env` :
```
USERS_DB_PATH=tests/users.test.sqlite
MATOMO_TRACKING_ENABLED=false
```
Couverture cible : 100% de `filters.py` et `auth.py` (sécurité-critique) ;
raisonnable ailleurs.
## 10. Dépendances nouvelles
À ajouter dans `pyproject.toml` :
- `flask-smorest` (blueprint + OpenAPI + Swagger UI)
- `marshmallow` (déjà transitif de flask-smorest, à expliciter)
`httpx` est déjà présent. Pas d'autres dépendances.
## 11. Documentation utilisateur
À fournir séparément (hors scope spec, à inclure dans le plan d'implémentation) :
- Section "API" dans la page À propos ou page dédiée `/api` avec :
- lien vers Swagger UI
- exemples curl
- procédure pour obtenir un token (« contactez X »)
- Mention dans le `CHANGELOG.md` à la sortie de version.
## 12. Risques et points ouverts
- **Coût du `COUNT(*)`** sur gros filtres : mitigé par `count=false` opt-out.
- **Charge SQLite write** : un worker série suffira pour le trafic attendu
(admin tokens manuels, faible volume). Si le volume monte, passer à un
buffer en RAM avec flush périodique.
- **Matomo down** : impact nul sur l'API (fire-and-forget loggué).
- **Évolution vers self-service** : déjà préparée par `user_id` nullable et
séparation `src/api/` vs `src/auth/`.
@@ -0,0 +1,122 @@
# Page `/etapes` — « Quelles données pour quelles étapes et quels seuils ? »
Date : 2026-06-04
Branche : `dev`
## Objectif
Créer une page pédagogique sur decp.info qui montre, sur un seul graphique, **quelles données sont publiées à chaque étape de la passation d'un marché public** et **à partir de quel seuil réglementaire** (en € HT).
La page aide à comprendre l'écosystème des publications de données de la commande publique et à situer les DECP (le cœur de decp.info) parmi les autres sources.
## Portée
- Une page dédiée à l'URL `/etapes`.
- Layout standard (bandeau de navigation global affiché en haut, comme toutes les pages).
- **Non listée** dans la navbar pour l'instant (on ne sait pas encore comment la lier depuis le reste de l'app — elle n'est pas secrète).
- **Référencée** dans le sitemap pour le SEO.
- Graphique en **HTML/CSS statique** (pas de Plotly, pas de SVG, pas d'interactivité).
- Pas de test automatisé spécifique (contenu statique) ; vérification visuelle via `python run.py`.
Hors portée : tout lien entrant depuis la navbar ou d'autres pages, toute interactivité (survol, filtre), toute donnée dynamique.
## Le graphique
### Axes
- **Axe Y** (de haut en bas) — étapes de la passation :
1. Programmation
2. Publicité (appel d'offres)
3. Attribution
4. Contrat — _vide_ (« aucune donnée publiée aujourd'hui »)
5. Paiement — _vide_ (« aucune donnée publiée aujourd'hui »)
- **Axe X** — seuils réglementaires en € HT, **segmenté** (espacement égal entre seuils, pas linéaire, sinon tout serait écrasé entre 40 k€ et 5,4 M€). Marqueurs de colonnes :
- `0 €`
- `40 000 €` — seuil DECP
- `90 000 €` — seuil de publicité
- `140 000 € / 216 000 €` — seuils formalisés (UE)
- `5 404 000 €` — travaux (UE)
### Barres (publications de données)
Chaque barre est une bande horizontale colorée, positionnée sur sa ligne d'étape et couvrant la plage de seuils où la publication s'applique.
| Publication | Étape(s) | Plage de seuils | Note |
| ------------------------------- | ------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------- |
| **Approch** | Programmation | toute la largeur | sourcing / préinformation, publication **non réglementaire** |
| **Journaux d'annonces légales** | Publicité | 90 000 € → seuil formalisé | remplit exactement cette case |
| **BOAMP** | Publicité | ≥ 90 000 € (jusqu'à l'infini) | au-delà des seuils UE, publicité obligatoire au BOAMP **et** au JOUE |
| **JOUE** | Publicité (avis de marché) + Attribution (avis d'attribution) | ≥ seuils formalisés | deux barres, une par étape |
| **DECP** | Attribution | ≥ 40 000 € (jusqu'à l'infini) | données essentielles de la commande publique |
### Légende
Sous le graphique : une pastille de couleur + le nom complet pour chaque publication (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP).
## Implémentation
### Nouveau fichier `src/pages/etapes.py`
Enregistrement de la page :
```python
register_page(
__name__,
path="/etapes",
title="Quelles données pour quelles étapes et quels seuils ? | decp.info",
name="Étapes et données",
description="À chaque étape d'un marché public (programmation, publicité, attribution), quelles données sont publiées et à partir de quel seuil : DECP, BOAMP, JOUE, journaux d'annonces légales, Approch.",
image_url=META_CONTENT["image_url"],
)
```
Le `name="Étapes et données"` n'est pas dans la liste blanche de la navbar (`src/app.py:181`), la page reste donc hors navigation tout en étant accessible.
`layout` = `html.Div(className="container", children=[...])` :
1. `html.H2("Quelles données pour quelles étapes et quels seuils ?")`
2. Paragraphe d'intro (`dcc.Markdown`) expliquant ce que montre le graphique.
3. Le graphique (composants `html.Div` reproduisant la maquette v3, barres positionnées en `left`/`right` en `%`).
4. La légende.
5. Note de bas (`dcc.Markdown`) : axe X segmenté (non linéaire) ; Contrat et Paiement sans données ouvertes à ce jour.
### Modification de `src/app.py`
Ajouter `"/etapes"` à la liste des URLs du sitemap (`sitemap()`, ~ligne 73) :
```python
pages = [
"/",
"/observatoire",
"/tableau",
"/a-propos",
"/etapes",
]
```
Aucune modification de la navbar.
### CSS
Bloc dédié dans `src/assets/css/` (fichier existant ou nouveau), avec classes préfixées (ex. `.etapes-chart`, `.etapes-lane`, `.etapes-bar`…) pour éviter toute collision.
### Responsive — deux rendus
Le graphique en grille n'est pas lisible sur écran portrait étroit (la vue d'ensemble est perdue). On rend donc **deux représentations des mêmes données**, basculées par media query (point de rupture ~768 px) :
- **Desktop / tablette (≥ 768 px)** : le graphique en grille (maquette v3), enveloppé dans un conteneur `overflow-x:auto` + `min-width` pour les écrans intermédiaires. Le rendu mobile est masqué.
- **Mobile (< 768 px)** : le graphique est masqué et remplacé par une **liste verticale par étape**. Chaque étape est un bloc qui liste ses publications, chacune avec sa pastille de couleur, son nom, et sa **plage de seuils en texte** (ex. « DECP — à partir de 40 000 € »). Les étapes Contrat/Paiement affichent « aucune donnée publiée aujourd'hui ».
Pour éviter la duplication, les publications de chaque étape (libellé, couleur, texte de plage) sont décrites **une seule fois** dans une structure de données Python, consommée par le rendu mobile et la légende. Le graphique en grille garde son positionnement explicite (intrinsèquement spatial).
## Vérification
- `python run.py` puis ouvrir `/etapes` : le graphique s'affiche, fidèle à la maquette v3, avec le bandeau de navigation en haut.
- `/etapes` **absente** de la navbar.
- `/sitemap.xml` **contient** `/etapes`.
- Sur fenêtre intermédiaire : défilement horizontal du graphique, pas d'écrasement.
- Sur écran portrait étroit (< 768 px) : le graphique en grille est masqué, remplacé par la liste verticale par étape, lisible sans défilement horizontal.
## Référence
Maquette validée : `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`.
@@ -0,0 +1,293 @@
# Bootstrap résilient des données et du schéma
**Date :** 2026-06-12
**Branche :** `feature/78_api`
**Statut :** design approuvé, à implémenter
## Problème
L'API et l'appli Web Dash partagent le même process Python (`gunicorn app:server`).
L'API sera consommée par des clients en production. Or decp.info tombe « de temps
en temps », et comme tout est dans le même process, une chute du Web emporte l'API.
**Diagnostic (clé).** Les chutes ne sont **pas** des crashs runtime aléatoires
pendant l'ingestion. Ce sont des **échecs de bootstrap au déploiement** :
- env oubliée lors d'un déploiement (ex. `DATA_FILE_PARQUET_PATH` vide) ;
- `DATA_FILE_PARQUET_PATH` (désormais une URL data.gouv.fr) injoignable ou
pointant vers un parquet absent/invalide à cause d'un souci dans
`decp-processing` ;
- `DATA_SCHEMA_PATH` (URL data.gouv.fr) qui renvoie une erreur.
Le process démarre sur des ressources manquantes/invalides, lève une exception
**au moment de l'import** (`src/db.py` et `src/utils/data.py` font leur bootstrap
au niveau module), et meurt au boot — API comprise.
## Pourquoi pas « séparer les process » ?
La séparation API / Web protège contre la **contagion runtime** (un callback Dash
qui tue le worker). Elle ne protège **pas** contre le mode d'échec réel : si les
deux process partagent les mêmes ressources de bootstrap (parquet, schéma, env),
ils échouent **tous les deux** au démarrage, de manière identique.
Le levier réel est donc le **durcissement du bootstrap avec fallback
« last-known-good »** : garantir présence + validité des ressources, et sinon
repartir sur les dernières ressources fonctionnelles.
La séparation des process reste **hors périmètre** de ce spec. La couche données
(`src/db.py`) est déjà process-agnostique et sans dépendance à Dash, donc la
séparation restera bon marché à dégainer plus tard _si_ un vrai crash runtime
touche l'API. On ne paie pas cette complexité tant qu'on n'en a pas la preuve.
## État actuel du code (post-merge `main`)
### `src/db.py`
- Bootstrap au niveau module : `DB_PATH = _ensure_database()` puis ouverture d'une
connexion DuckDB read-only partagée et lecture du `schema`.
- `build_database()` écrit dans un fichier temporaire puis `os.replace()` atomique :
un build qui échoue en cours de route **laisse l'ancien DuckDB intact**. ✅
- **Faille :** `should_rebuild()` appelle `get_last_modified(parquet_path)` qui
fait un `httpx.head(...).headers["last-modified"]` **sans aucune gestion
d'erreur** (`src/utils/__init__.py:12`). URL injoignable, lente, ou sans en-tête
`last-modified` ⇒ exception ⇒ remonte jusqu'à l'import ⇒ **mort au démarrage
alors qu'un DuckDB valide existe sur disque**.
- **Faille :** `_load_source_frame()` fait `assert os.path.exists(parquet_path)`
(non-http) et `scan_parquet` (http) — les deux peuvent lever et ne sont pas
rattrapés au niveau de `_ensure_database()`.
### `src/utils/data.py` — `get_data_schema()`
- Tente l'URL, attrape **seulement 4 erreurs httpx** (`ReadTimeout`, `ReadError`,
`ConnectError`, `ConnectTimeout`), sinon fallback sur `DATA_SCHEMA_LOCAL`.
- **Faille :** pas de `raise_for_status()`. Quand data.gouv renvoie une **erreur
HTTP** (le cas cité par l'utilisateur), `.json()` ne contient pas `"fields"`
`KeyError` ligne 92, **sans fallback local**.
- **Faille :** un payload distant valide JSON mais malformé (sans `"fields"`)
plante aussi sans fallback.
- **Faille :** si les deux sources échouent, `original_schema["fields"]`
`KeyError` opaque au lieu d'une erreur claire.
## Décisions
1. **Schéma** : URL primaire, **cache seul** en fallback (on supprime
`DATA_SCHEMA_LOCAL`). (confirmé)
2. **DuckDB** : réutiliser le dernier DuckDB construit en cas d'échec. (confirmé)
3. **Last-known-good réel du schéma** : après un fetch distant réussi, persister
le schéma dans un cache local pour que le fallback soit toujours le _dernier
schéma distant fonctionnel_. (confirmé)
### Chemin de persistance du schéma : `DATA_SCHEMA_CACHE` seul
On remplace `DATA_SCHEMA_LOCAL` (qui pointait, en dev, vers
`../decp-processing/dist/schema.json` — un fichier cross-repo qu'on ne veut pas
écraser) par un **cache unique possédé par l'app**.
- `DATA_SCHEMA_PATH` (URL) — source primaire.
- `DATA_SCHEMA_CACHE` (nouveau, ex. défaut `./schema.cache.json`) — écrit après
chaque fetch distant réussi, lu en fallback.
Chaîne de résolution : `URL → cache → RuntimeError`.
**Pourquoi c'est suffisant.** Le déploiement est en place sur un VM persistant
(`ssh → cd /var/www/APP_NAME → git pull → restart systemd`), donc le fichier de
cache survit aux déploiements — **même garantie de persistance que le DuckDB
réutilisé**. Tous les incidents constatés (env oubliée, parquet KO, URL schéma en
erreur) surviennent sur un **redéploiement** d'un hôte déjà chaud, où le cache a
déjà été écrit par un boot précédent réussi ⇒ couvert.
**Seul cas non couvert (assumé) :** le _cold start absolu_ — un hôte qui n'a jamais
booté avec succès **et** URL distante down au même instant. Étroit, non-récurrent.
Fermable plus tard par une graine commitée in-repo si jamais il se matérialise
(YAGNI).
**Contraintes :**
- `DATA_SCHEMA_CACHE` (`./schema.cache.json`) doit être **`.gitignore`** — sinon le
`git pull` du déploiement entrerait en conflit. (Comme `decp.duckdb` aujourd'hui.)
- En dev, plus de fallback vers le schéma frais de `decp-processing` : on bascule
sur le cache (dernier schéma data.gouv). Acceptable, l'URL restant primaire.
## Design
### Invariant 1 — Bootstrap DuckDB (`src/db.py`)
> Le process démarre tant qu'un DuckDB exploitable existe, quel que soit l'état de
> la source distante/parquet. Échec dur **seulement** s'il n'existe aucune base
> (cold start).
Garde-fou unique dans `_ensure_database()` :
```python
def _ensure_database() -> Path:
db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb"))
parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "")
lock_path = db_path.with_suffix(".duckdb.lock")
db_exists = db_path.exists()
with open(lock_path, "w") as lock_fd:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
if should_rebuild(db_path, parquet_path):
build_database(db_path)
except Exception as e:
if db_exists:
logger.error(
f"Bootstrap données KO ({e}). "
f"Réutilisation du DuckDB existant : {db_path}"
)
else:
logger.critical("Aucune base DuckDB et reconstruction impossible.")
raise
return db_path
```
- `should_rebuild()` qui lève (via `get_last_modified()`) est désormais rattrapé :
base existante ⇒ on la réutilise.
- `build_database()` qui lève sur parquet invalide : base existante intacte
(atomicité) ⇒ on la réutilise.
- Le mode `DEVELOPMENT` sort de `should_rebuild()` **avant** tout appel réseau
(court-circuit `if dev and not force: return False`) ⇒ dev inchangé.
### Invariant 2 — Schéma (`src/utils/data.py`)
> Un schéma valide non-vide est toujours retourné si une source (distant ou cache)
> en fournit un. Échec dur seulement si aucune.
```python
def get_data_schema() -> dict:
cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) # dict valide | None
if raw is not None:
_persist_schema_cache(raw, cache_path)
else:
raw = _load_schema_file(cache_path)
if raw is None:
raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
return OrderedDict((c["name"], c) for c in raw["fields"])
```
Helpers :
- `_fetch_remote_schema(url) -> dict | None` : `get(...).raise_for_status().json()`,
**valide `"fields" in data`**, attrape large (`httpx.HTTPError`,
`json.JSONDecodeError`, `KeyError`), log l'erreur, renvoie `None` sur tout échec.
- `_load_schema_file(path) -> dict | None` : lit le fichier s'il existe, parse,
valide `"fields"`, renvoie `None` sinon.
- `_persist_schema_cache(data, path)` : écriture atomique (tmp + `os.replace`) ;
un échec d'écriture est loggé mais **non bloquant** (le schéma en mémoire reste
valide).
### Invariant 3 — Chargements au niveau module des pages
> L'import d'une page (exécuté au boot via `use_pages`) ne doit jamais tuer le
> démarrage à cause d'une ressource externe KO. Une ressource indisponible
> dégrade gracieusement l'affichage.
Audit des chargements à l'import (tous les `layout` de pages sont au niveau
module ⇒ leur contenu s'exécute au boot). Deux points de rupture **externes** :
**C — `src/pages/tableau.py:36-38`.** `get_last_modified(URL parquet)` fait un
HTTP HEAD **sans gestion d'erreur** (URL injoignable, en-tête `last-modified`
absent) ⇒ import KO ⇒ boot KO. C'est le même piège que `db.py`, mais dans une page.
Correctif : un helper best-effort dans `src/utils/__init__.py` qui ne lève jamais
et retombe sur le mtime du DuckDB (garanti présent par l'Invariant 1) :
```python
def get_data_update_timestamp(parquet_path: str, fallback_path: str | None = None) -> float | None:
"""Date de MAJ des données, best-effort, sans jamais lever (usage au boot)."""
try:
return get_last_modified(parquet_path)
except Exception as e:
logger.warning(f"Date de mise à jour des données indisponible ({e})")
if fallback_path:
try:
return os.path.getmtime(fallback_path)
except OSError:
pass
return None
```
`tableau.py` l'utilise et gère le cas `None` (affiche « date inconnue »,
`update_date_iso = ""`).
**D — `src/pages/a-propos.py:103`.** `get_sources_tables(SOURCE_STATS_CSV_PATH)`
(`src/figures.py:121`) fait `pl.read_csv(source_path)` mais ne rattrape que
`URLError, HTTPError` — pas les erreurs Polars, ni `source_path` vide/`None`, ni
fichier absent ⇒ import KO ⇒ boot KO.
Correctif : élargir le `except` et gérer le chemin vide :
```python
def get_sources_tables(source_path) -> html.Div:
try:
if not source_path:
raise ValueError("SOURCE_STATS_CSV_PATH non défini")
dff = pl.read_csv(source_path)
except Exception as e:
logger.warning(f"Sources de données indisponibles ({e})")
return html.Div("Sources de données momentanément indisponibles.")
... # suite inchangée
```
Hors périmètre des pages : `data/departements.json` + `.geojson` (fichiers
in-repo apportés par `git pull`, pas pilotés par env/URL — voir Hors périmètre).
## Tests (TDD)
Couvrir chaque branche de fallback. Sans dépendre du réseau réel.
**Schéma (`get_data_schema` / helpers) :**
1. URL OK ⇒ schéma distant retourné **et** cache écrit.
2. URL renvoie une erreur HTTP (mock 500) ⇒ fallback cache.
3. URL renvoie un JSON malformé (sans `"fields"`) ⇒ fallback cache.
4. URL KO + cache présent ⇒ schéma du cache.
5. URL KO + cache absent ⇒ `RuntimeError` claire.
6. Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant).
**Bootstrap DuckDB (`_ensure_database`) :**
7. `should_rebuild` lève + DuckDB existant ⇒ réutilisé, pas d'exception.
8. `build_database` lève + DuckDB existant ⇒ réutilisé, pas d'exception.
9. Échec + **aucun** DuckDB (cold start) ⇒ ré-lève.
10. Cas nominal : rebuild nécessaire et possible ⇒ build effectué.
Mocker `get_last_modified` / `build_database` / `httpx.get` ; utiliser des fichiers
DuckDB et schéma temporaires (`tmp_path`).
**Chargements de pages (Invariant 3) :**
11. `get_data_update_timestamp` : `get_last_modified` lève + `fallback_path`
existant ⇒ retourne le mtime du fallback (pas d'exception).
12. `get_data_update_timestamp` : tout KO (lève + pas de fallback) ⇒ `None`.
13. `get_data_update_timestamp` : cas nominal ⇒ retourne la valeur de
`get_last_modified` (mocké).
14. `get_sources_tables(None)``html.Div` de repli (pas d'exception).
15. `get_sources_tables("/inexistant.csv")``html.Div` de repli.
16. `get_sources_tables(<csv valide>)``html.Div` contenant la `DataTable`.
## Hors périmètre
- Séparation des process API / Web (reportée — voir plus haut).
- Surveillance / alerting externe (les logs `error`/`critical` suffisent pour ce lot).
- Validation fine du contenu du parquet au-delà de « lisible par Polars/DuckDB ».
- Durcissement des `open()` in-repo (`data/departements.json` + `.geojson`) :
fichiers versionnés, apportés par `git pull`, jamais pilotés par env/URL (YAGNI).
## Variables d'environnement
| Variable | Rôle | Changement |
| ------------------------ | --------------------------------------- | ------------ |
| `DATA_FILE_PARQUET_PATH` | Source parquet (URL ou chemin) | inchangé |
| `DATA_SCHEMA_PATH` | URL schéma (primaire) | inchangé |
| `DATA_SCHEMA_LOCAL` | Ancien fichier de secours statique | **supprimé** |
| `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** |
| `DUCKDB_PATH` | Fichier DuckDB | inchangé |
| `SOURCE_STATS_CSV_PATH` | CSV stats sources (page À propos, D) | inchangé |
À faire côté config :
- Ajouter `DATA_SCHEMA_CACHE` à `.template.env`, retirer `DATA_SCHEMA_LOCAL` de
`.template.env` / `.env`.
- Ajouter `schema.cache.json` (ou la valeur de `DATA_SCHEMA_CACHE`) au `.gitignore`.
@@ -0,0 +1,220 @@
# Parité de l'API decp.info avec tabular-api (data.gouv.fr) — opérateurs manquants
**Date :** 2026-06-22
**Périmètre :** `count_results` + `differs` + suite d'agrégation. **Hors périmètre :** le paramètre réservé `or` (itération dédiée ultérieure).
## Contexte
L'API `/api/v1/data` de decp.info reproduit le schéma de requête de
`tabular-api` (`datagouv/api-tabular`), qui sert la même donnée DECP sur
data.gouv.fr. L'objectif est d'atteindre la parité sur les **opérateurs**
de filtrage/agrégation, pour qu'une requête écrite pour data.gouv.fr
fonctionne à l'identique sur decp.info.
Source faisant autorité du comportement cible : `api_tabular/core/query.py`
du dépôt `datagouv/api-tabular`. Tous les comportements ci-dessous ont été
vérifiés en direct contre la ressource DECP
`22847056-61df-452d-837d-8b8ceadbfc52`.
### Écart constaté
Opérateurs présents chez data.gouv.fr et absents de decp.info :
| Mot-clé | Nature |
| ----------------------------------- | ----------------------------------- |
| `differs` | opérateur de filtre |
| `groupby` | drapeau d'agrégation (sans valeur) |
| `count`, `sum`, `avg`, `min`, `max` | drapeaux d'agrégation (sans valeur) |
De plus, le paramètre réservé `count=true|false` de decp.info entre en
collision avec l'opérateur d'agrégation `count` de data.gouv.fr.
État courant pertinent :
- `src/api/filters.py` : `OPERATORS`, `RESERVED_PARAMS`, `build_where()`.
- `src/api/routes.py` : route `data()`, doc swagger des paramètres.
- `src/db.py` : `query_marches()`, `count_marches()`.
## Objectifs
1. Renommer le paramètre réservé `count``count_results` (valeurs
`true|false`, défaut `true`), libérant `count` comme opérateur.
2. Ajouter l'opérateur de filtre `differs`.
3. Ajouter les opérateurs d'agrégation `groupby`, `count`, `sum`, `avg`,
`min`, `max`, avec la même forme de réponse que data.gouv.fr.
4. **Documenter** chaque mot-clé dans le Swagger UI de l'API, de façon à
mettre en valeur les possibilités de l'API decp.info.
Non-objectifs : le paramètre `or` (grammaire récursive imbriquée), les
opérateurs `groupby`/agrégats appliqués via `or`, toute évolution du
benchmark (sera traitée après).
## Conception
### 1. Renommage `count` → `count_results`
- `RESERVED_PARAMS` : `{"page", "page_size", "columns", "count_results"}`.
- `routes.data()` : lire `request.args.get("count_results", "true")`.
- Doc swagger : remplacer le paramètre `count` par `count_results`, même
description (« inclure le total `COUNT(*)` ; `false` pour accélérer »).
- Le mot `count` n'est donc plus réservé ; il est interprété comme
opérateur d'agrégation (section 3).
**Rupture de contrat :** un client qui passait `count=false` verra ce
paramètre ré-interprété. Sans conséquence : l'API n'est pas encore en
production, on peut donc itérer librement.
### 2. Opérateur `differs`
Sémantique data.gouv.fr : `col__differs=val` → PostgREST `isdistinct`, soit
`IS DISTINCT FROM` (≠ null-safe : `NULL differs 44` est vrai).
- Ajouter `"differs"` à `OPERATORS`.
- Dans `build_where()`, après coercition de la valeur :
`where_parts.append('"col" IS DISTINCT FROM ?')` ; `params.append(v)`.
- DuckDB supporte nativement `IS DISTINCT FROM`.
### 3. Opérateurs d'agrégation
#### Forme des requêtes (vérifiée)
Drapeaux **sans valeur** dans la query string :
`?acheteur_departement_code__groupby&uid__count&montant__sum&montant__avg&montant__min&montant__max`
Une valeur (`__groupby=1`) est un cas d'erreur côté data.gouv.fr ; on
n'impose pas cette stricte interdiction mais on accepte la forme sans
valeur (Werkzeug fournit alors la valeur `""`).
Opérateurs : `groupby`, `count`, `sum`, `avg`, `min`, `max`.
#### Forme de la réponse (vérifiée)
```
SELECT <cols groupby>, FN("<col>") AS "<col>__<op>", ...
FROM decp
WHERE <filtres>
GROUP BY <cols groupby>
LIMIT <page_size> OFFSET <offset>
```
- Colonnes de sortie : la colonne `groupby` garde son nom ; chaque agrégat
est nommé `"<colonne>__<opérateur>"` (ex. `uid__count`, `montant__sum`).
- `meta` : `{"page", "page_size"}` **sans `total`** (data.gouv.fr n'en
renvoie pas en mode agrégation). `count_results` est ignoré dans ce mode.
- Pas de tri par défaut.
- Les filtres `WHERE` (y compris `differs`) restent appliqués.
#### Contraintes répliquées
- `columns` + agrégation → erreur 400 (`columns ne peut pas être combiné avec des agrégateurs`). Vérifié identique chez data.gouv.fr.
- Un agrégat (`count`/`sum`/…) sans `groupby` est autorisé (agrégat global,
une ligne).
#### Architecture
Nouvelle fonction de parsing dans `src/api/filters.py` :
```
parse_aggregators(args, schema) -> AggregationSpec | None
```
- Retourne `None` si aucun opérateur d'agrégation présent → la route suit
le chemin existant.
- Sinon retourne les colonnes `groupby` et la liste des agrégats
`(fonction_sql, colonne, alias)`.
- Valide que chaque colonne existe dans le schéma ; opérateur inconnu →
`FilterError`.
`build_where()` est inchangé pour WHERE/ORDER ; il continue d'ignorer les
clés réservées et **doit ignorer les drapeaux d'agrégation** (ne pas les
traiter comme des filtres). Comme les drapeaux d'agrégation arrivent comme
`(col__op, "")`, et que `op` ∈ agrégateurs, `build_where` les saute.
Nouvelle fonction dans `src/db.py` :
```
aggregate_marches(select_sql, where_sql, params, group_by, limit, offset) -> pl.DataFrame
```
- `select_sql` et `group_by` sont des fragments SQL construits depuis des
noms de colonnes validés contre le schéma (jamais de valeur utilisateur
libre) ; les valeurs de filtre passent par le binding `?`.
Orchestration dans `routes.data()` :
```
agg = parse_aggregators(args, schema)
where_sql, params, order_sql = build_where(args, schema) # filtres seuls
if agg:
if columns: -> abort(400)
df = aggregate_marches(agg.select_sql, where_sql, params, agg.group_by, page_size, offset)
meta = {"page", "page_size"} # pas de total
else:
<chemin existant>
```
### 4. Documentation (Swagger UI)
La doc de l'API est générée par flask-smorest et exposée sur
`/api/v1/swagger`, pilotée par le docstring de `routes.data()` et le bloc
`@bp.doc(parameters=[...])`. C'est la surface de documentation à enrichir
(aucune autre page de doc API n'existe).
À mettre à jour :
- Remplacer le paramètre `count` par `count_results` (même description).
- Étendre la description du paramètre dynamique `<colonne>__<opérateur>`
avec une **définition d'une ligne par opérateur**, regroupés par
catégorie :
- _Filtres_ : `exact`, `differs`, `contains`, `notcontains`, `in`,
`notin`, `less`, `greater`, `strictly_less`, `strictly_greater`,
`isnull`, `isnotnull`, `sort`.
- _Agrégation_ (drapeaux sans valeur) : `groupby`, `count`, `sum`,
`avg`, `min`, `max`.
- Décrire le **mode agrégation** : drapeaux sans valeur, réponse en lignes
groupées, colonnes `col__op`, `columns` interdit, pas de `total`.
- Mettre à jour le docstring de `data()` (visible dans Swagger) en
cohérence, avec au moins un exemple de requête d'agrégation.
Objectif éditorial : un lecteur qui découvre l'API doit comprendre, depuis
le seul Swagger UI, l'ensemble des opérateurs disponibles et comment s'en
servir.
### Sécurité SQL
Les noms de colonnes proviennent du schéma DuckDB validé (`col in schema`),
jamais interpolés depuis une valeur arbitraire ; les fonctions d'agrégation
sont une liste blanche fixe (`COUNT/SUM/AVG/MIN/MAX`). Les valeurs de
filtre restent liées par paramètres `?`. Aucun chemin n'interpole de valeur
utilisateur dans le SQL.
## Tests
Tests existants à adapter (renommage `count``count_results`).
Nouveaux tests (`tests/` API) :
- `differs` : exclut les lignes égales, inclut les NULL.
- agrégation `groupby` + `count` : nombre de groupes, noms de colonnes
`col__count`.
- agrégation multiple `groupby`+`count`+`sum`+`avg`+`min`+`max` : alias et
types corrects, `meta` sans `total`.
- agrégat sans `groupby` : une ligne.
- `groupby` + filtre `WHERE` : le filtre s'applique avant l'agrégation.
- `columns` + agrégation : 400.
- `count_results=false` : réponse sans `total` (chemin non-agrégé).
- non-régression : opérateurs existants inchangés.
- doc : le spec OpenAPI généré (`/api/v1/openapi.json`) référence
`count_results` et mentionne les nouveaux opérateurs (vérif légère, p. ex.
présence des chaînes attendues).
Comparaison de référence : pour quelques requêtes, les valeurs agrégées
doivent correspondre à celles renvoyées par data.gouv.fr sur la même
ressource (aux différences de fraîcheur de données près).
## Gestion des erreurs
- Opérateur inconnu, colonne inconnue → `FilterError` → 400 (existant).
- `columns` + agrégation → 400 avec message explicite.
- Valeur non coercible pour `differs``FilterError` (existant via
`_coerce`).
@@ -0,0 +1,112 @@
# Tuile « Considérations sociales et environnementales » — Observatoire
## Objectif
Ajouter dans `/observatoire` une tuile (card) qui visualise, à l'aide de barres
de progression « plus ou moins remplies », la part des marchés publics filtrés
qui comportent **au moins une considération sociale** et la part qui comportent
**au moins une considération environnementale**.
La tuile s'insère juste **après la tuile « Type d'achat »**, avec le même style
que les autres cards.
## Données
Colonnes concernées (type `String`, valeurs libres potentiellement composées) :
- `considerationsSociales`
- `considerationsEnvironnementales`
Exemples de valeurs : `Sans objet`, `Clause sociale`, `Critère social`,
`Marché réservé`, `Clause environnementale`, `Critère environnemental`,
`Pas de considération sociale`, `null`, ou des combinaisons
(`Critère social, Clause sociale`).
### Définition « au moins une considération »
Un marché compte comme ayant une considération si la valeur de la colonne
**contient** l'un des mots-clés (insensible à la casse) :
- `Clause`
- `Critère`
- `Marché réservé`
Regex utilisée : `(?i)Clause|Critère|Marché réservé`.
Conséquence (validée avec l'utilisateur) : **`Marché réservé` compte comme
considération sociale**. Les valeurs `Sans objet`, `Pas de considération…` et
`null` ne contiennent aucun de ces mots-clés et ne comptent donc pas.
### Calcul du pourcentage
- **Dédoublonnage par `uid`** : un marché est compté une seule fois même s'il
apparaît sur plusieurs lignes (plusieurs titulaires). On prend la première
valeur de chaque colonne par `uid`.
- **Dénominateur** : **tous** les marchés filtrés (y compris `Sans objet` et
non renseignés) — validé avec l'utilisateur.
- **Numérateur** : nombre de marchés (uid distincts) dont la valeur de colonne
satisfait la regex.
- `pourcentage = round(100 * numérateur / dénominateur)` ; si dénominateur = 0,
pourcentage = 0.
## Composant visuel
Nouvelle fonction `get_considerations_card_content(lff: pl.LazyFrame)` dans
`src/figures.py`, renvoyant un `html.Div` contenant deux barres `dbc.Progress`
empilées :
| Considération | Couleur (px.colors.qualitative.Safe) | Valeur RGB |
| ----------------- | ------------------------------------ | -------------------- |
| Sociales | index 1 (rouge) | `rgb(204, 102, 119)` |
| Environnementales | index 3 (vert) | `rgb(17, 119, 51)` |
Chaque barre :
- `dbc.Progress(value=pourcentage, label=f"{pourcentage} %", style={"backgroundColor": <couleur>})`
- précédée d'un libellé (`Sociales` / `Environnementales`) et suivie du nombre
de marchés concernés (`N marchés`), formaté avec `format_number`.
### Robustesse (colonne absente)
`tests/test.parquet` peut ne pas contenir ces colonnes. La fonction vérifie la
présence de chaque colonne via `lff.collect_schema().names()` ; si une colonne
manque, son pourcentage et son compte valent 0 (pas d'exception), à l'image de
`get_distance_histogram`.
## Intégration
Dans `src/pages/observatoire.py`, fonction `_compute_dashboard_children` :
```python
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
cards.append(make_card(title="Type d'achat", ...))
# NOUVEAU
considerations = get_considerations_card_content(lff)
cards.append(
make_card(
title="Considérations sociales et environnementales",
subtitle="part des marchés concernés",
fig=considerations,
)
)
```
`make_card` utilise ses dimensions par défaut (`lg=6, xl=4`), comme la tuile
« Type d'achat ».
Import à ajouter : `get_considerations_card_content` depuis `src.figures`.
## Tests
- Test unitaire de `get_considerations_card_content` sur un petit `LazyFrame`
construit en mémoire couvrant : valeur avec considération, `Sans objet`,
`null`, `Marché réservé`, doublon de `uid`. Vérifier les pourcentages
attendus.
- Cas colonne absente → 0 % sans exception.
## Hors périmètre (YAGNI)
- Pas de tooltip détaillé sur les types de considérations.
- Pas de graphe de répartition par type (clause vs critère).
- Pas de nouveau filtre (les filtres existants `social`/`env` restent inchangés).
@@ -0,0 +1,100 @@
# Design : amélioration du style des exports Excel (#83)
**Date :** 2026-06-23
**Issue :** #83
## Contexte
Les 6 fonctions d'export Excel du projet produisent des fichiers basiques : toutes les colonnes ont la même largeur par défaut et les en-têtes ne sont pas mis en valeur. L'objectif est d'améliorer la lisibilité en imitant les largeurs de colonnes du `DataTable` commun et en stylisant les en-têtes.
## Périmètre
Toutes les fonctions d'export Excel :
| Fichier | Fonction | Page |
| --------------------------- | ---------------------------------- | --------------- |
| `src/pages/tableau.py` | `download_data` | `/tableau` |
| `src/pages/acheteur.py` | `download_acheteur_data` | `/acheteur` |
| `src/pages/acheteur.py` | `download_filtered_acheteur_data` | `/acheteur` |
| `src/pages/titulaire.py` | `download_titulaire_data` | `/titulaire` |
| `src/pages/titulaire.py` | `download_filtered_titulaire_data` | `/titulaire` |
| `src/pages/observatoire.py` | `download_observatoire` | `/observatoire` |
## Solution retenue : wrapper `write_styled_excel` (approche C)
Le besoin de `text_wrap` impose de créer le workbook manuellement (`xlsxwriter.Workbook` avec `default_format_properties`). Répéter ce boilerplate 6 fois est peu maintenable, donc on centralise dans une fonction utilitaire dans `src/utils/table.py`.
## Détail du design
### Constantes et wrapper — `src/utils/table.py`
```python
import xlsxwriter
_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI
_EXCEL_HEADER_FORMAT = {
"bold": True,
"bg_color": "#b33821", # couleur primaire de l'app
"font_color": "white",
}
_EXCEL_COLUMN_WIDTHS = { # tirés des minWidth du DataTable commun (src/figures.py:269)
"objet": 350,
"acheteur_nom": 250,
"titulaire_nom": 250,
"acheteur_id": 160,
}
def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None:
col_widths = {
col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0))
for col in df.columns
}
wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}})
ws = wb.add_worksheet(worksheet)
df.write_excel(
workbook=wb,
worksheet=ws,
header_format=_EXCEL_HEADER_FORMAT,
column_widths=col_widths,
)
wb.close()
```
**Comportement :**
- `text_wrap=True` via `default_format_properties` s'applique à toutes les cellules de données.
- Chaque colonne reçoit au minimum 132 px (≈ 3.5 cm) ; les colonnes avec largeur explicite utilisent leur valeur si elle est supérieure.
- En-têtes : fond rouge (`#b33821`), texte blanc, gras.
- Le wrapper accepte un `pl.DataFrame` ; les callbacks qui travaillent avec une `LazyFrame` appellent `.collect()` (éventuellement `engine="streaming"`) avant d'appeler le wrapper.
### Mise à jour des 6 callbacks
Chaque bloc `def to_bytes(buffer):` est remplacé par un appel au wrapper.
**Exemples :**
```python
# tableau.py — download_data
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
# acheteur.py — download_acheteur_data (worksheet dynamique selon l'année)
def to_bytes(buffer):
write_styled_excel(
df_to_download, buffer,
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
)
# acheteur.py — download_filtered_acheteur_data
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
Titulaire et observatoire : même pattern.
## Décisions clés
- **`autofit=False`** (pas d'autofit Polars) : les largeurs sont entièrement contrôlées par `col_widths`.
- **`text_wrap` via workbook** : seule façon d'appliquer le wrapping à toutes les cellules via `write_excel` (les paramètres `column_formats`/`dtype_formats` de Polars n'exposent pas les propriétés xlsxwriter de format cellule).
- **Constantes privées** (`_EXCEL_*`) : non exportées, consommées uniquement par `write_styled_excel`.
- **Worksheet dynamique** : `download_acheteur_data` et `download_titulaire_data` utilisent l'année comme nom de feuille quand elle est définie — conservé via le paramètre `worksheet`.
@@ -0,0 +1,145 @@
# Défilement horizontal ergonomique des tableaux (#82)
## Problème
Les tableaux de données (`DataTable` Dash) sont souvent plus larges que l'écran et
**débordent vers la droite**. Aujourd'hui aucun `overflowX` n'est défini sur leur
conteneur : le tableau étire la page entière, et le seul moyen de faire défiler
horizontalement est la **barre de défilement de la fenêtre du navigateur**, tout en
bas du viewport. Cette barre :
- est discrète et fait défiler **toute la page** (pas seulement le tableau) ;
- n'est pas comprise comme « le moyen de voir le reste du tableau ».
De plus, les tableaux dépassent souvent du bas de l'écran : une barre placée en bas
du tableau serait invisible sans scroller.
## Objectif
Rendre le défilement horizontal **évident et toujours accessible**, et garder les
**en-têtes de colonnes visibles** pendant le défilement vertical, sans introduire de
zone scrollable imbriquée gênante.
## Approche retenue (option B — sticky au niveau page)
Le tableau **reste dans le flux de la page** (pas de conteneur à hauteur fixe, pas de
scroll imbriqué). On combine deux mécanismes :
1. **En-têtes collants**`position: sticky; top: 0` sur la ligne d'en-tête du
tableau. Quand l'utilisateur descend dans la page, les en-têtes se figent en haut
de la fenêtre au lieu d'être « avalés ».
2. **Barre de défilement horizontale miroir en haut** — un petit élément placé
juste au-dessus du tableau, lui aussi `sticky` en haut, dont le défilement
horizontal est **synchronisé** avec celui du tableau. Elle est donc toujours
visible dès qu'on voit le haut du tableau, et pilote le défilement horizontal sans
devoir descendre en bas du tableau.
La barre miroir et les en-têtes collants se figent ensemble en haut de la fenêtre :
l'utilisateur garde en permanence le repère des colonnes **et** le contrôle du
défilement horizontal.
### Pourquoi pas l'option A (tableau « fenêtré » à hauteur fixe)
Écartée volontairement : un conteneur à hauteur fixe avec scroll interne crée un
**scroll imbriqué** (la molette agit d'abord sur le tableau, pas sur la page), source
de confusion. L'option B garde un comportement de défilement vertical unique (celui
de la page) ; seul le défilement horizontal est « custom ».
## Contrainte technique CSS à gérer
Un conteneur en `overflow-x: auto` devient automatiquement un conteneur de
défilement **vertical** (règle CSS : `overflow-y: visible` recalculé en `auto` dès
que l'autre axe n'est pas `visible`), ce qui **casse** le `position: sticky; top: 0`
des en-têtes par rapport à la page.
Conséquences pour l'implémentation :
- Le **défilement horizontal réel** doit se faire dans un conteneur dédié en
`overflow-x: auto` ; mais ce conteneur ne peut pas, en même temps, héberger des
en-têtes sticky « page ». La barre miroir du haut résout ce conflit : c'est **elle**
qui porte le `overflow-x: auto`, séparée du tableau, et synchronisée par JS.
- Il faudra **vérifier et neutraliser au besoin l'`overflow` interne** que Dash
DataTable applique à ses propres conteneurs (`.dash-spreadsheet-container`,
`.dash-spreadsheet-inner`) pour que le sticky des en-têtes fonctionne.
- Le rendu réel de Dash DataTable doit être inspecté avant de figer le CSS : la
structure DOM exacte (où poser `sticky`, quel élément porte la largeur totale)
conditionne la solution. **À valider en testant dans le navigateur.**
## Composants
| Élément | Rôle | Emplacement probable |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| CSS en-têtes sticky | `position: sticky; top: 0` sur la ligne d'en-tête, z-index, fond opaque | `src/assets/css/style.css` (cible `.marches_table`) |
| Barre miroir (markup) | `<div>` scrollable au-dessus du tableau, avec un enfant à la largeur du tableau | composant partagé `DataTable` / wrapper dans `src/figures.py` |
| Synchronisation JS | lier scrollLeft barre ↔ tableau ; recopier la largeur du tableau dans la barre ; recalcul au resize / changement de données | nouveau fichier `src/assets/js/*.js` (assets Dash, chargé automatiquement) |
| CSS barre miroir | hauteur, sticky `top: 0`, masquage si pas de débordement | `src/assets/css/style.css` |
## Portée
Les **quatre pages** utilisant `className="marches_table"` :
`/tableau`, `/acheteur`, `/titulaire`, `/observatoire`. La solution passe par le
composant `DataTable` partagé et la classe CSS `marches_table`, donc l'effort est
quasi identique pour une ou quatre pages.
## Flux de données / interactions
1. Au rendu (et à chaque changement de données / largeur de fenêtre), le JS mesure la
largeur totale du tableau et la reporte dans l'élément interne de la barre miroir →
la barre miroir affiche une glissière proportionnelle.
2. Événement `scroll` sur la barre miroir → on applique `scrollLeft` au conteneur du
tableau ; et inversement (scroll du tableau → barre miroir), avec garde anti-boucle.
3. Si le tableau ne déborde pas, la barre miroir est masquée.
## Cas limites
- **Pas de débordement** : barre miroir masquée, en-têtes sticky inoffensifs.
- **Pagination / re-render** (pages en `page_action="custom"`) : la largeur peut
changer → la synchro doit se recalculer après mise à jour des données.
- **Resize de la fenêtre** : recalcul de la largeur miroir.
- **Plusieurs tableaux sur une page** (`/observatoire`, `/titulaire`, `/acheteur` ont
plusieurs `marches_table`) : le JS doit gérer chaque tableau indépendamment.
- **Persistance / tri / filtre** : ne doit pas casser la synchro (réattacher les
écouteurs si le DOM est recréé).
## Tests / validation
- Vérification **manuelle dans le navigateur** (point critique vu l'incertitude sur le
DOM de DataTable) : débordement horizontal sur `/tableau`, sticky des en-têtes en
scrollant, synchro des deux barres, comportement sur les pages à tableaux multiples.
- S'assurer que les tests Selenium existants ne régressent pas
(`pytest tests/test_main.py`).
## Hors périmètre (YAGNI)
- Colonnes figées (1re colonne sticky horizontalement).
- Réduction du nombre de colonnes par défaut / refonte du sélecteur de colonnes.
- `overscroll-behavior` et zones scrollables imbriquées (option A écartée).
## Verdict du spike
Le spike (Steps 13 exécutés par l'utilisateur en DevTools) doit valider les points suivants. En cas de déviation, le reste du plan reste applicable ; seule l'implémentation du sticky (Task 2) ajustera sa stratégie.
### Éléments attendus du DOM
- **Conteneur scrollable** : `.dash-spreadsheet-container` (enfant direct de `.marches_table`)
- **Conteneur interne** : `.dash-spreadsheet-inner` (enfant de `.dash-spreadsheet-container`) — peut aussi porter un `overflow` interne
- **En-têtes** : sélecteur exact `th.dash-header` (dans un `tr` au sein du tableau)
- **Table complète** : `.cell-table` avec ses dimensions (`scrollWidth` >> `clientWidth` du parent → débordement confirmé)
### Hypothèse sticky
L'astuce CSS consiste à :
1. Neutraliser l'`overflow` sur `.dash-spreadsheet-container` et `.dash-spreadsheet-inner` en les ramenant à `overflow: visible` (ou en supprimant le style si possible)
2. Appliquer `position: sticky; top: 0; z-index: 10; background: #fff` aux en-têtes `th.dash-header`
**Verdict attendu :** ✅ Oui — les en-têtes restent collés au haut de la fenêtre quand on scroll verticalement la page, sans recours à du JS supplémentaire (hormis la synchro scrollLeft pour le miroir).
**Si verdict = ❌ Non :** les en-têtes seront pilotés entièrement en JS (repositionnement au scroll), avec synchronisation du scroll vertical. Le reste du plan (barre miroir, synchro horizontale) reste valable.
### Références (à noter lors du spike)
- `scrollWidth` et `clientWidth` de la table vs. ses parents
- Styles `overflow` en _Computed_ sur `.dash-spreadsheet-container`, `.dash-spreadsheet-inner` et `.cell-table`
- Résultat du test d'hypothèse JS (sticky page fonctionne-t-il ?)