Merge branch 'dev' into feature/73_compte_utilisateur
This commit is contained in:
@@ -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 1–3 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 ?)
|
||||
Reference in New Issue
Block a user