Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06fad7ec2d | |||
| 3eec248dab | |||
| fc22eca661 | |||
| e591c5500f | |||
| 074716bc16 | |||
| ae8292d68d | |||
| 26455a7616 | |||
| 19e181734c | |||
| 8efd9b45eb | |||
| eab06eedae | |||
| 5586db23aa | |||
| a85baca408 | |||
| 2ce762b6cc | |||
| 826448d6be | |||
| e44af8add8 | |||
| 992f6d4f93 | |||
| 966d193ec4 | |||
| c264c36684 | |||
| 70ee819a51 | |||
| b87eb4328c | |||
| 7c9a95355b | |||
| 28709a162a | |||
| 9324a734d4 | |||
| 2a8e82ccd6 | |||
| f5db674e22 | |||
| 5166c88b8b | |||
| b0fbe89c2c | |||
| 441d36273a | |||
| ffaf200c07 | |||
| fede22f314 | |||
| d591d7f47a | |||
| b8d73c7f7b | |||
| cb93dbe05a | |||
| fa2709b38c | |||
| bba53d81e1 | |||
| 5a6aa31c86 | |||
| 42ba73cd36 | |||
| 21c65c34fb | |||
| afd6590c54 | |||
| 20f81b5732 | |||
| 0ebbfcd872 | |||
| 8a853b2384 | |||
| 1aea71be7f | |||
| 317b2b5cdb | |||
| c304f05d85 | |||
| 5341ca002e | |||
| ed32b0f66b | |||
| 4d3e8ac344 | |||
| ab74717679 | |||
| c9a97fe9d2 | |||
| b8db0cddf8 | |||
| 4c60fed0c2 | |||
| 070a91fed1 | |||
| e638ce45be | |||
| e55f3447db | |||
| 6cf213add5 | |||
| 80c27bf357 | |||
| 2e9b587613 | |||
| ad7e3e5b1f | |||
| 7d4df0e6ad | |||
| 8dc5287918 | |||
| 63f51d7b98 | |||
| 2f0cbada34 | |||
| c683034e2b | |||
| 985af0fdc7 | |||
| 912507b1d9 | |||
| 04c8bb8b6d | |||
| 6d2a95d775 | |||
| 6c591aeb08 | |||
| 7571b9a984 | |||
| 6f4e7f1853 | |||
| a05c869b0d | |||
| 686218c639 | |||
| f24d0ba168 | |||
| f12ada2fac | |||
| a3cfad3d8f | |||
| 4b67cde147 | |||
| ca44b99016 | |||
| 1d190e4086 | |||
| bc3b09e2a9 | |||
| 0363e89301 | |||
| 19d69e965f | |||
| 8fb1a2084d | |||
| 75e5aeaf40 | |||
| b8af6a17a1 | |||
| 0c9666204b | |||
| cd468a837c | |||
| 8a2f7f620c | |||
| 3a73cdf4b9 | |||
| 62eb4d98f0 | |||
| 6fdec32b29 | |||
| 1447a9fcaf | |||
| 9d7f33905f | |||
| 755b8c13ab | |||
| f4b57dbe5c | |||
| 0322c20513 | |||
| edbdeaa370 | |||
| 25746b4869 | |||
| 1a5f049b1a | |||
| bd6a4ff266 | |||
| 1839928e69 | |||
| a6049b3244 | |||
| d8ee6e5b37 | |||
| b437decf5f | |||
| 10f24dec30 | |||
| 18b5488051 | |||
| 7d8f8a7c19 | |||
| 93777cce6d | |||
| dadbb0aeff | |||
| f7b7954ed2 | |||
| fc4d965b20 | |||
| a715140af0 | |||
| c45d4e0ea1 | |||
| 0777153c82 | |||
| 6e670c97c9 | |||
| 522c467702 | |||
| 74ae1fb008 | |||
| e3a0fba4df | |||
| a382370767 | |||
| 653999693c | |||
| aaf54eef91 | |||
| b996eb97cc |
@@ -20,7 +20,7 @@ jobs:
|
|||||||
environment: ${{ github.ref_name }}
|
environment: ${{ github.ref_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up SSH key
|
- name: Set up SSH key
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ build
|
|||||||
**/decp.duckdb
|
**/decp.duckdb
|
||||||
**/decp.duckdb.tmp
|
**/decp.duckdb.tmp
|
||||||
**/decp.duckdb.lock
|
**/decp.duckdb.lock
|
||||||
|
**/schema.cache.json
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ ANNOUNCEMENTS=
|
|||||||
|
|
||||||
# Chemin vers le schéma de données
|
# Chemin vers le schéma de données
|
||||||
DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
|
DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
|
||||||
|
DATA_SCHEMA_CACHE=./schema.cache.json
|
||||||
|
|
||||||
# Colonnes masquées par défaut
|
# Colonnes masquées par défaut
|
||||||
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset"
|
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset"
|
||||||
@@ -24,3 +25,10 @@ TO_EMAIL="to@example.com" # adresse de destination des emails (To)
|
|||||||
MATOMO_ID_SITE=
|
MATOMO_ID_SITE=
|
||||||
MATOMO_BASE_URL=
|
MATOMO_BASE_URL=
|
||||||
MATOMO_TOKEN=
|
MATOMO_TOKEN=
|
||||||
|
|
||||||
|
# API privée
|
||||||
|
DISABLE_API_AUTH="false"
|
||||||
|
USERS_DB_PATH=./users.sqlite
|
||||||
|
MATOMO_URL=https://analytics.maudry.com/matomo.php
|
||||||
|
MATOMO_SITE_ID=14
|
||||||
|
MATOMO_TRACKING_ENABLED=true
|
||||||
|
|||||||
@@ -1,3 +1,38 @@
|
|||||||
|
##### 2.8.1 (25 juin 2026)
|
||||||
|
|
||||||
|
- Correction du bug dans la création de token d'API
|
||||||
|
|
||||||
|
#### 2.8.0 (23 juin 2026)
|
||||||
|
|
||||||
|
- Ajout des considérations sociales et environnementales à l'observatoire
|
||||||
|
- Les filtres textuels dans les vues tableaux ne sont plus sensibles à l'accentuation
|
||||||
|
- API privée tabulaire sur abonnement (filtres dynamiques, pagination, tri) avec documentation interactive Swagger UI à `/api/v1/swagger` (📨 <colin@colmo.tech> si intéressé)
|
||||||
|
|
||||||
|
##### 2.7.9 (9 juin 2026)
|
||||||
|
|
||||||
|
- Ajout d'une vue "étapes" (elle sera mieux intégrée dans le site à l'avenir)
|
||||||
|
- Correction de petites erreurs qui polluent les logs
|
||||||
|
|
||||||
|
##### 2.7.8 (18 mai 2026)
|
||||||
|
|
||||||
|
- Récupération du schéma de données plus robuste, ne pas dépendre de data.gouv.fr
|
||||||
|
|
||||||
|
##### 2.7.7 (11 mai 2026)
|
||||||
|
|
||||||
|
- Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour.
|
||||||
|
|
||||||
|
##### 2.7.6 (5 mai 2026)
|
||||||
|
|
||||||
|
- Correction du problème de filtre par date dans les tableaux
|
||||||
|
- Retour des cartes dans les pages acheteur et titulaire
|
||||||
|
- Possibilité de chercher un SIRET/SIREN avec des espaces dans les champs `SIRET acheteur` et `Identifiant titulaire`
|
||||||
|
|
||||||
|
##### 2.7.5 (24 avril 2026)
|
||||||
|
|
||||||
|
- Amélioration des permormances de l'observatoire
|
||||||
|
- Possibilité dans observatoire (champ objet) et tableau (tous champs texte) de soit chercher des mots présents, soit une suite de mot précise (voir mode d'emploi dans Tableau)
|
||||||
|
- Ajout d'une animation pendant le chargement de la prévisualisation des données de l'observatoire
|
||||||
|
|
||||||
##### 2.7.4 (22 avril 2026)
|
##### 2.7.4 (22 avril 2026)
|
||||||
|
|
||||||
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72)
|
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# decp.info
|
# decp.info
|
||||||
|
|
||||||
> v2.7.4
|
|
||||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||||
|
|
||||||
=> [decp.info](https://decp.info)
|
=> [decp.info](https://decp.info)
|
||||||
|
|||||||
@@ -0,0 +1,951 @@
|
|||||||
|
# Observatoire — filtrage natif DuckDB — Plan d'implémentation
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remplacer le filtrage Polars sur LazyFrame dans `prepare_dashboard_data` par un requêtage natif DuckDB, pour ne matérialiser que le sous-ensemble utile au lieu de l'intégralité de la table `decp` (~1,5 M lignes).
|
||||||
|
|
||||||
|
**Architecture:** Nouveau helper pur `dashboard_filters_to_sql(**filter_params) -> (where_sql, params)` dans `src/utils/table_sql.py` (modèle de `filter_query_to_sql`). `prepare_dashboard_data` devient une fonction fine qui appelle `query_marches(where_sql, params)` et retourne une `pl.DataFrame`. Les 3 appelants dans `src/pages/observatoire.py` sont adaptés à la nouvelle signature.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, Polars, DuckDB, Dash, pytest.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**À créer :**
|
||||||
|
|
||||||
|
- `tests/test_dashboard_filters_to_sql.py` — tests unitaires du nouveau helper SQL (cas vide + cas par filtre).
|
||||||
|
- `tests/test_prepare_dashboard_data.py` — test d'intégration léger (appel DuckDB réel sur `tests/test.parquet`).
|
||||||
|
|
||||||
|
**À modifier :**
|
||||||
|
|
||||||
|
- `src/utils/table_sql.py` — ajouter `dashboard_filters_to_sql` + import `datetime`/`timedelta`.
|
||||||
|
- `src/utils/data.py` — réécrire `prepare_dashboard_data` (signature et implémentation), ajouter `query_marches` aux imports `from src.db`.
|
||||||
|
- `src/pages/observatoire.py` — adapter 3 sites d'appel (lignes ~668, ~791, ~882) ; retirer `query_marches` de l'import `from src.db` (plus utilisé).
|
||||||
|
- `tests/test_main.py` — supprimer `test_010_observatoire_montant_filter` (migré en test unitaire du helper).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Tests unitaires — cas par défaut + filtre année
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `tests/test_dashboard_filters_to_sql.py`
|
||||||
|
- Modify: `src/utils/table_sql.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tests/test_dashboard_filters_to_sql.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from src.utils.table_sql import dashboard_filters_to_sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_filters_uses_default_365_day_window():
|
||||||
|
where_sql, params = dashboard_filters_to_sql()
|
||||||
|
assert where_sql == '"dateNotification" > ?'
|
||||||
|
assert len(params) == 1
|
||||||
|
assert isinstance(params[0], datetime)
|
||||||
|
expected = datetime.now() - timedelta(days=365)
|
||||||
|
assert abs((params[0] - expected).total_seconds()) < 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_year_filter_overrides_default_window():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(dashboard_year="2025")
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ?'
|
||||||
|
assert params == [2025]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: FAIL with `ImportError: cannot import name 'dashboard_filters_to_sql'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the helper**
|
||||||
|
|
||||||
|
Add to the top of `src/utils/table_sql.py` (below existing imports):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
```
|
||||||
|
|
||||||
|
Append this function at the end of `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."""
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if dashboard_year:
|
||||||
|
clauses.append('YEAR("dateNotification") = ?')
|
||||||
|
params.append(int(dashboard_year))
|
||||||
|
else:
|
||||||
|
clauses.append('"dateNotification" > ?')
|
||||||
|
params.append(datetime.now() - timedelta(days=365))
|
||||||
|
|
||||||
|
return " AND ".join(clauses), params
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git commit -m "feat(observatoire): squelette de dashboard_filters_to_sql (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Filtres d'égalité simples (catégorie, type, innovant, sous-traitance)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||||
|
- Modify: `src/utils/table_sql.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_marche_type_equality():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_type="Marché",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?'
|
||||||
|
assert params == [2025, "Marché"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_innovant_value_all_is_skipped():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_innovant="all",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ?'
|
||||||
|
assert params == [2025]
|
||||||
|
|
||||||
|
|
||||||
|
def test_innovant_value_oui_adds_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_innovant="oui",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?'
|
||||||
|
assert params == [2025, "oui"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sous_traitance_value_non_adds_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_sous_traitance_declaree="non",
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
where_sql
|
||||||
|
== 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?'
|
||||||
|
)
|
||||||
|
assert params == [2025, "non"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: 4 new tests FAIL (missing clauses).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend the helper**
|
||||||
|
|
||||||
|
Insert the following block in `dashboard_filters_to_sql`, **after** the `if dashboard_year / else` block and **before** `return " AND ".join(clauses), params`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if dashboard_marche_type:
|
||||||
|
clauses.append('"type" = ?')
|
||||||
|
params.append(dashboard_marche_type)
|
||||||
|
|
||||||
|
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
|
||||||
|
clauses.append('"marcheInnovant" = ?')
|
||||||
|
params.append(dashboard_marche_innovant)
|
||||||
|
|
||||||
|
if (
|
||||||
|
dashboard_marche_sous_traitance_declaree
|
||||||
|
and dashboard_marche_sous_traitance_declaree != "all"
|
||||||
|
):
|
||||||
|
clauses.append('"sousTraitanceDeclaree" = ?')
|
||||||
|
params.append(dashboard_marche_sous_traitance_declaree)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: PASS (6 tests total).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git commit -m "feat(observatoire): filtres d'égalité simples dans dashboard_filters_to_sql (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Filtres LIKE/ILIKE (ids, objet, cpv)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||||
|
- Modify: `src/utils/table_sql.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_acheteur_id_uses_like_wildcards():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_id="12345678900010",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
|
||||||
|
assert params == [2025, "%12345678900010%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titulaire_id_uses_like_wildcards():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_titulaire_id="999",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
|
||||||
|
assert params == [2025, "%999%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_marche_objet_uses_case_insensitive_ilike():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_objet="travaux",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "objet" ILIKE ?'
|
||||||
|
assert params == [2025, "%travaux%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_cpv_uses_prefix_like():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_code_cpv="4521",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?'
|
||||||
|
assert params == [2025, "4521%"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: 4 new tests FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend the helper**
|
||||||
|
|
||||||
|
Insert the following block, **just after** the year/default block and **before** the `if dashboard_marche_type` block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if dashboard_acheteur_id:
|
||||||
|
clauses.append('"acheteur_id" LIKE ?')
|
||||||
|
params.append(f"%{dashboard_acheteur_id}%")
|
||||||
|
|
||||||
|
if dashboard_titulaire_id:
|
||||||
|
clauses.append('"titulaire_id" LIKE ?')
|
||||||
|
params.append(f"%{dashboard_titulaire_id}%")
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert in the "marché" block, **after** `dashboard_marche_type` and **before** `dashboard_marche_innovant`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if dashboard_marche_objet:
|
||||||
|
clauses.append('"objet" ILIKE ?')
|
||||||
|
params.append(f"%{dashboard_marche_objet}%")
|
||||||
|
|
||||||
|
if dashboard_marche_code_cpv:
|
||||||
|
clauses.append('"codeCPV" LIKE ?')
|
||||||
|
params.append(f"{dashboard_marche_code_cpv}%")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: PASS (10 tests total).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git commit -m "feat(observatoire): filtres LIKE/ILIKE dans dashboard_filters_to_sql (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Filtre IN (départements) + skip conditionnel par ID
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||||
|
- Modify: `src/utils/table_sql.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_acheteur_departement_multiple_uses_in_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_departement_code=["75", "92", "93"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
'AND "acheteur_departement_code" IN (?, ?, ?)'
|
||||||
|
)
|
||||||
|
assert params == [2025, "75", "92", "93"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acheteur_categorie_adds_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_categorie="Commune",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?'
|
||||||
|
assert params == [2025, "Commune"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titulaire_categorie_and_departement():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_titulaire_categorie="PME",
|
||||||
|
dashboard_titulaire_departement_code=["35"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
'AND "titulaire_categorie" = ? '
|
||||||
|
'AND "titulaire_departement_code" IN (?)'
|
||||||
|
)
|
||||||
|
assert params == [2025, "PME", "35"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acheteur_id_present_skips_categorie_and_departement():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_id="123",
|
||||||
|
dashboard_acheteur_categorie="Commune",
|
||||||
|
dashboard_acheteur_departement_code=["75"],
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
|
||||||
|
assert params == [2025, "%123%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titulaire_id_present_skips_categorie_and_departement():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_titulaire_id="999",
|
||||||
|
dashboard_titulaire_categorie="PME",
|
||||||
|
dashboard_titulaire_departement_code=["35"],
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
|
||||||
|
assert params == [2025, "%999%"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: 5 new tests FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Refactor the helper with conditional skip**
|
||||||
|
|
||||||
|
Replace the two simple `if dashboard_acheteur_id` / `if dashboard_titulaire_id` blocks added in Task 3 with the nested form:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if dashboard_acheteur_id:
|
||||||
|
clauses.append('"acheteur_id" LIKE ?')
|
||||||
|
params.append(f"%{dashboard_acheteur_id}%")
|
||||||
|
else:
|
||||||
|
if dashboard_acheteur_categorie:
|
||||||
|
clauses.append('"acheteur_categorie" = ?')
|
||||||
|
params.append(dashboard_acheteur_categorie)
|
||||||
|
if dashboard_acheteur_departement_code:
|
||||||
|
placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code))
|
||||||
|
clauses.append(f'"acheteur_departement_code" IN ({placeholders})')
|
||||||
|
params.extend(dashboard_acheteur_departement_code)
|
||||||
|
|
||||||
|
if dashboard_titulaire_id:
|
||||||
|
clauses.append('"titulaire_id" LIKE ?')
|
||||||
|
params.append(f"%{dashboard_titulaire_id}%")
|
||||||
|
else:
|
||||||
|
if dashboard_titulaire_categorie:
|
||||||
|
clauses.append('"titulaire_categorie" = ?')
|
||||||
|
params.append(dashboard_titulaire_categorie)
|
||||||
|
if dashboard_titulaire_departement_code:
|
||||||
|
placeholders = ", ".join(
|
||||||
|
["?"] * len(dashboard_titulaire_departement_code)
|
||||||
|
)
|
||||||
|
clauses.append(f'"titulaire_departement_code" IN ({placeholders})')
|
||||||
|
params.extend(dashboard_titulaire_departement_code)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: PASS (15 tests total).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git commit -m "feat(observatoire): IN départements et skip conditionnel par ID (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Filtre liste (techniques, considérations sociales/environnementales)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||||
|
- Modify: `src/utils/table_sql.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_marche_techniques_uses_list_has_any():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_techniques=["Enchère", "Accord-cadre"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
"AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
assert params == [2025, ["Enchère", "Accord-cadre"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_considerations_sociales_uses_list_has_any():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_considerations_sociales=["Clause sociale"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
"AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
assert params == [2025, ["Clause sociale"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_considerations_environnementales_uses_list_has_any():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_considerations_environnementales=["Clause env."],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
"AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
assert params == [2025, ["Clause env."]]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: 3 new tests FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend the helper**
|
||||||
|
|
||||||
|
Insert the following block in `dashboard_filters_to_sql`, **after** the `dashboard_marche_sous_traitance_declaree` block and **before** `return " AND ".join(clauses), params`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if dashboard_marche_techniques:
|
||||||
|
clauses.append(
|
||||||
|
"list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
params.append(list(dashboard_marche_techniques))
|
||||||
|
|
||||||
|
if dashboard_marche_considerations_sociales:
|
||||||
|
clauses.append(
|
||||||
|
"list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
params.append(list(dashboard_marche_considerations_sociales))
|
||||||
|
|
||||||
|
if dashboard_marche_considerations_environnementales:
|
||||||
|
clauses.append(
|
||||||
|
"list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
params.append(list(dashboard_marche_considerations_environnementales))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: PASS (18 tests total).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git commit -m "feat(observatoire): filtres liste via list_has_any (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Filtres montant min/max (incluant 0)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||||
|
- Modify: `src/utils/table_sql.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_montant_min_only():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_min=1000,
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
|
||||||
|
assert params == [2025, 1000]
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_max_only():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_max=500,
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?'
|
||||||
|
assert params == [2025, 500]
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_zero_is_a_valid_lower_bound():
|
||||||
|
# 0 est falsy mais reste un filtre valide (distinct de None)
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_min=0,
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
|
||||||
|
assert params == [2025, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_min_and_max_combined():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_min=100,
|
||||||
|
dashboard_montant_max=1000,
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?'
|
||||||
|
)
|
||||||
|
assert params == [2025, 100, 1000]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: 4 new tests FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend the helper**
|
||||||
|
|
||||||
|
Insert at the very end of `dashboard_filters_to_sql`, **just before** `return " AND ".join(clauses), params`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if dashboard_montant_min is not None:
|
||||||
|
clauses.append('"montant" >= ?')
|
||||||
|
params.append(dashboard_montant_min)
|
||||||
|
|
||||||
|
if dashboard_montant_max is not None:
|
||||||
|
clauses.append('"montant" <= ?')
|
||||||
|
params.append(dashboard_montant_max)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
Expected: PASS (22 tests total).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||||
|
rtk git commit -m "feat(observatoire): filtres montant min/max (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Réécriture de `prepare_dashboard_data`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/utils/data.py`
|
||||||
|
- Modify: `tests/test_main.py` (supprimer `test_010_observatoire_montant_filter`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the obsolete Polars-based test**
|
||||||
|
|
||||||
|
Delete the function `test_010_observatoire_montant_filter` from `tests/test_main.py` (lines ~218-256). La couverture du filtre montant est déjà assurée par les tests unitaires `test_montant_*` de la Task 6.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite `prepare_dashboard_data`**
|
||||||
|
|
||||||
|
Replace the entire `prepare_dashboard_data` function in `src/utils/data.py` (lines ~86-194) with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
|
||||||
|
"""Exécute la requête DuckDB filtrée pour le tableau de bord.
|
||||||
|
|
||||||
|
Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble
|
||||||
|
correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame
|
||||||
|
appellent `.lazy()` sur le résultat.
|
||||||
|
"""
|
||||||
|
from src.utils.table_sql import dashboard_filters_to_sql
|
||||||
|
|
||||||
|
where_sql, params = dashboard_filters_to_sql(**filter_params)
|
||||||
|
return query_marches(where_sql=where_sql, params=params)
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the import at the top of `src/utils/data.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.db import get_cursor, query_marches, schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the now-unused import in `src/utils/data.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
```
|
||||||
|
|
||||||
|
(Si `datetime` n'est plus référencé dans `data.py` hors de `prepare_dashboard_data`, sinon garder.)
|
||||||
|
|
||||||
|
**Vérification rapide à effectuer avant de supprimer `datetime`/`timedelta`** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk grep -n "datetime\|timedelta" src/utils/data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Si d'autres occurrences existent, conserver les imports.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py tests/test_main.py -v -k "not selenium and not dash_duo"`
|
||||||
|
|
||||||
|
Ou, si filter n'est pas pratique :
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (22 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files src/utils/data.py tests/test_main.py
|
||||||
|
rtk git add src/utils/data.py tests/test_main.py
|
||||||
|
rtk git commit -m "refactor(observatoire): prepare_dashboard_data utilise DuckDB (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Adaptation des 3 appelants dans `observatoire.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/pages/observatoire.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `_compute_dashboard_children`**
|
||||||
|
|
||||||
|
Remplacer dans `src/pages/observatoire.py` (autour des lignes 660-670) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
@cache.memoize()
|
||||||
|
def _compute_dashboard_children(filter_params_normalized: tuple):
|
||||||
|
logger.debug("Cache miss — computing dashboard")
|
||||||
|
filter_params = {
|
||||||
|
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
lff: pl.LazyFrame = query_marches().lazy()
|
||||||
|
lff = prepare_dashboard_data(lff=lff, **filter_params)
|
||||||
|
|
||||||
|
dff = lff.collect(engine="streaming")
|
||||||
|
```
|
||||||
|
|
||||||
|
Par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
@cache.memoize()
|
||||||
|
def _compute_dashboard_children(filter_params_normalized: tuple):
|
||||||
|
logger.debug("Cache miss — computing dashboard")
|
||||||
|
filter_params = {
|
||||||
|
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
dff = prepare_dashboard_data(**filter_params)
|
||||||
|
lff = dff.lazy()
|
||||||
|
```
|
||||||
|
|
||||||
|
Le reste de la fonction (à partir de `df_per_uid = ...`) est inchangé.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `download_observatoire`**
|
||||||
|
|
||||||
|
Remplacer dans `src/pages/observatoire.py` (autour des lignes 789-800) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def download_observatoire(_n_clicks, filter_params, hidden_columns):
|
||||||
|
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
||||||
|
|
||||||
|
if hidden_columns:
|
||||||
|
lff = lff.drop(hidden_columns)
|
||||||
|
|
||||||
|
def to_bytes(buffer):
|
||||||
|
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
|
||||||
|
|
||||||
|
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
|
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
|
||||||
|
```
|
||||||
|
|
||||||
|
Par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def download_observatoire(_n_clicks, filter_params, hidden_columns):
|
||||||
|
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")
|
||||||
|
|
||||||
|
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
|
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `populate_preview_table`**
|
||||||
|
|
||||||
|
Remplacer dans `src/pages/observatoire.py` (autour des lignes 879-892) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not is_open:
|
||||||
|
return (no_update,) * 9
|
||||||
|
|
||||||
|
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
||||||
|
|
||||||
|
return prepare_table_data(
|
||||||
|
lff,
|
||||||
|
data_timestamp,
|
||||||
|
filter_query,
|
||||||
|
page_current,
|
||||||
|
page_size,
|
||||||
|
sort_by,
|
||||||
|
"observatoire-preview",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not is_open:
|
||||||
|
return (no_update,) * 9
|
||||||
|
|
||||||
|
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||||
|
|
||||||
|
return prepare_table_data(
|
||||||
|
dff.lazy(),
|
||||||
|
data_timestamp,
|
||||||
|
filter_query,
|
||||||
|
page_current,
|
||||||
|
page_size,
|
||||||
|
sort_by,
|
||||||
|
"observatoire-preview",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove unused `query_marches` import**
|
||||||
|
|
||||||
|
Dans `src/pages/observatoire.py`, ligne ~19 :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.db import query_marches, schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Devient :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.db import schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifier avant de committer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk grep -n "query_marches" src/pages/observatoire.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: aucun résultat (ou uniquement des commentaires).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Smoke test**
|
||||||
|
|
||||||
|
Démarrer l'app et naviguer sur `/observatoire`, vérifier à la main que :
|
||||||
|
|
||||||
|
- Les cartes s'affichent.
|
||||||
|
- Un filtre année se propage.
|
||||||
|
- Un filtre acheteur par SIRET partiel fonctionne.
|
||||||
|
- Un filtre département (multi-valeur) fonctionne.
|
||||||
|
- Un filtre montant_min fonctionne.
|
||||||
|
- Le bouton « Télécharger au format Excel » génère un fichier non vide.
|
||||||
|
- Le bouton « Voir les données » ouvre l'offcanvas et peuple la table.
|
||||||
|
|
||||||
|
Run: `python run.py`
|
||||||
|
|
||||||
|
Expected: app démarre sans erreur ; les filtres se comportent comme avant.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files src/pages/observatoire.py
|
||||||
|
rtk git add src/pages/observatoire.py
|
||||||
|
rtk git commit -m "refactor(observatoire): appelants utilisent la nouvelle signature (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Test d'intégration — `prepare_dashboard_data` sur `tests/test.parquet`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `tests/test_prepare_dashboard_data.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Le but : vérifier que la fonction s'exécute réellement contre DuckDB, retourne une `pl.DataFrame`, et applique bien les filtres simples. `conftest.py` construit `tests/test.parquet` avec un jeu de données d'une ligne : acheteur_id `123`, acheteur_departement_code `75`, dateNotification `2025-01-01`, montant `10`.
|
||||||
|
|
||||||
|
Create `tests/test_prepare_dashboard_data.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_dataframe_with_year_filter():
|
||||||
|
from src.utils.data import prepare_dashboard_data
|
||||||
|
|
||||||
|
dff = prepare_dashboard_data(dashboard_year="2025")
|
||||||
|
assert isinstance(dff, pl.DataFrame)
|
||||||
|
assert dff.height == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_year_mismatch_returns_empty():
|
||||||
|
from src.utils.data import prepare_dashboard_data
|
||||||
|
|
||||||
|
dff = prepare_dashboard_data(dashboard_year="2024")
|
||||||
|
assert isinstance(dff, pl.DataFrame)
|
||||||
|
assert dff.height == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_acheteur_id_partial_match():
|
||||||
|
from src.utils.data import prepare_dashboard_data
|
||||||
|
|
||||||
|
dff = prepare_dashboard_data(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_id="12",
|
||||||
|
)
|
||||||
|
assert dff.height == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_departement_in_clause():
|
||||||
|
from src.utils.data import prepare_dashboard_data
|
||||||
|
|
||||||
|
dff = prepare_dashboard_data(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_departement_code=["75", "92"],
|
||||||
|
)
|
||||||
|
assert dff.height == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_min_above_value_excludes_row():
|
||||||
|
from src.utils.data import prepare_dashboard_data
|
||||||
|
|
||||||
|
dff = prepare_dashboard_data(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_min=1000,
|
||||||
|
)
|
||||||
|
assert dff.height == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/test_prepare_dashboard_data.py -v`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk pre-commit run --files tests/test_prepare_dashboard_data.py
|
||||||
|
rtk git add tests/test_prepare_dashboard_data.py
|
||||||
|
rtk git commit -m "test(observatoire): intégration DuckDB pour prepare_dashboard_data (#72)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Vérification finale
|
||||||
|
|
||||||
|
**Files:** (aucune modification)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `rtk pytest -v`
|
||||||
|
Expected: tous les tests unitaires passent. Les tests Selenium peuvent échouer si Chrome n'est pas disponible — ce n'est pas bloquant s'ils étaient déjà rouges avant.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check for leftover references**
|
||||||
|
|
||||||
|
Run: `rtk grep -rn "prepare_dashboard_data(lff" src/ tests/`
|
||||||
|
Expected: aucun résultat (plus d'appels avec l'ancienne signature).
|
||||||
|
|
||||||
|
Run: `rtk grep -rn "query_marches().lazy()" src/`
|
||||||
|
Expected: aucun résultat (ou uniquement dans `src/utils/table.py:prepare_table_data` pour le fallback).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm `datetime`/`timedelta` in data.py if needed**
|
||||||
|
|
||||||
|
Run: `rtk grep -n "datetime\|timedelta" src/utils/data.py`
|
||||||
|
|
||||||
|
Si aucune occurrence hors imports, vérifier que les imports inutiles ont bien été retirés dans Task 7.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual timing sanity check (optionnel)**
|
||||||
|
|
||||||
|
Si possible, comparer informellement le temps de `_compute_dashboard_children` sur un filtre sélectif (ex. un département) avant/après. Pas de benchmark formel attendu.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Push (manuel, à l'initiative de l'utilisateur)**
|
||||||
|
|
||||||
|
Conformément aux consignes projet, ne jamais `git push`. Laisser l'utilisateur pousser la branche `feature/72_observatoire_duckdb_filters` et ouvrir la PR.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Plan: Ajouter des cartes de localisation aux pages acheteur et titulaire
|
||||||
|
|
||||||
|
## Date: 2026-04-28
|
||||||
|
|
||||||
|
## Statut: Approuvé
|
||||||
|
|
||||||
|
## Objectif: Ajouter des cartes interactives montrant la localisation des organisations sur les pages acheteur et titulaire
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
- Les pages acheteur et titulaire ont déjà des placeholders pour les cartes (`acheteur_map` et `titulaire_map`)
|
||||||
|
- La fonction `point_on_map()` existe déjà dans `src/figures.py` mais utilise un centrage fixe sur la France
|
||||||
|
- Les données de localisation proviennent de l'API Annuaire des Entreprises
|
||||||
|
- Les codes départementaux sont disponibles et plus fiables que les coordonnées pour la détection de région
|
||||||
|
|
||||||
|
## Exigences
|
||||||
|
|
||||||
|
### 1. Carte interactive
|
||||||
|
|
||||||
|
- **Localisation**: Colonne de droite dans la section d'informations sur l'organisation
|
||||||
|
- **Taille**: 400px de largeur × 300px de hauteur (fixe)
|
||||||
|
- **Contenu**: Carte centrée sur la France ou le département d'outre-mer approprié avec un point rouge à l'emplacement de l'organisation
|
||||||
|
- **Niveau de zoom**: Approprié pour montrer l'Hexagone ou le département d'outre-mer spécifique
|
||||||
|
- **Style**: Fond de carte clair avec point rouge visible
|
||||||
|
- **Interactivité**: Carte zoomable et déplaçable (pas de configuration statique)
|
||||||
|
|
||||||
|
### 2. Sources de données
|
||||||
|
|
||||||
|
- Utiliser les colonnes `acheteur_latitude` et `acheteur_longitude` pour les pages acheteur
|
||||||
|
- Utiliser les colonnes `titulaire_latitude` et `titulaire_longitude` pour les pages titulaire
|
||||||
|
- Utiliser les codes départementaux (`acheteur_departement_code`, `titulaire_departement_code`) pour la détection de région
|
||||||
|
- Solution de repli: Si les coordonnées ou codes départementaux sont manquants ou invalides, afficher une div vide
|
||||||
|
|
||||||
|
### 3. Détection de région
|
||||||
|
|
||||||
|
- **Départements métropolitains**: Codes à 2 caractères (ex: "75" pour Paris) → Carte Hexagone
|
||||||
|
- **Départements d'outre-mer**:
|
||||||
|
- "971" → Guadeloupe
|
||||||
|
- "972" → Martinique
|
||||||
|
- "973" → Guyane
|
||||||
|
- "974" → La Réunion
|
||||||
|
- "976" → Mayotte
|
||||||
|
- **Code département manquant**: Retourner une div vide (pas de détection basée sur les coordonnées)
|
||||||
|
|
||||||
|
### 4. Gestion des erreurs
|
||||||
|
|
||||||
|
- Coordonnées invalides → div vide
|
||||||
|
- Code département manquant → div vide
|
||||||
|
- Échec de l'API Annuaire → div vide (comportement existant)
|
||||||
|
- Format de code département invalide → div vide
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
|
||||||
|
### Fichiers à modifier
|
||||||
|
|
||||||
|
#### 1. `src/figures.py` - Améliorer la fonction `point_on_map()`
|
||||||
|
|
||||||
|
**Ligne 178-209**: Remplacer la fonction existante par une version améliorée avec:
|
||||||
|
|
||||||
|
- Détection de région basée sur les codes départementaux
|
||||||
|
- Configuration de carte interactive (zoomable)
|
||||||
|
- Point plus grand (size=15)
|
||||||
|
- Commentaires en français
|
||||||
|
|
||||||
|
#### 2. `src/pages/acheteur.py` - Mettre à jour le callback
|
||||||
|
|
||||||
|
**Ligne 249-297**: Modifier `update_acheteur_infos()` pour:
|
||||||
|
|
||||||
|
- Extraire le code département du code postal
|
||||||
|
- Passer le code département à `point_on_map()`
|
||||||
|
- Ajouter des commentaires en français
|
||||||
|
|
||||||
|
#### 3. `src/pages/titulaire.py` - Mettre à jour le callback
|
||||||
|
|
||||||
|
**Ligne 259-297**: Modifier `update_titulaire_infos()` pour:
|
||||||
|
|
||||||
|
- Extraire le code département du code postal
|
||||||
|
- Passer le code département à `point_on_map()`
|
||||||
|
- Ajouter des commentaires en français
|
||||||
|
|
||||||
|
## Plan de Test
|
||||||
|
|
||||||
|
### Cas de test prioritaires
|
||||||
|
|
||||||
|
1. **Organisation métropolitaine**: Code département "75" (Paris) → Carte Hexagone
|
||||||
|
2. **Organisation à La Réunion**: Code département "974" → Carte centrée sur La Réunion
|
||||||
|
3. **Code département manquant**: Retourne une div vide
|
||||||
|
4. **Coordonnées invalides**: Retourne une div vide
|
||||||
|
5. **Interactivité**: Vérifier zoom et déplacement
|
||||||
|
|
||||||
|
### Critères d'acceptation
|
||||||
|
|
||||||
|
- [ ] Cartes fonctionnelles avec codes départementaux valides
|
||||||
|
- [ ] Div vide pour codes manquants/invalides
|
||||||
|
- [ ] Cartes correctement centrées et zoomées
|
||||||
|
- [ ] Interactivité (zoom et déplacement)
|
||||||
|
- [ ] Point de localisation visible (size=15)
|
||||||
|
|
||||||
|
## Approbation
|
||||||
|
|
||||||
|
Plan approuvé avec spécifications:
|
||||||
|
|
||||||
|
- Réutiliser et améliorer `point_on_map`
|
||||||
|
- Retourner div vide sans code département
|
||||||
|
- Point légèrement plus grand
|
||||||
|
- Cartes zoomables
|
||||||
|
- Utiliser codes départementaux pour détection de région
|
||||||
|
- Commentaires en français
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,699 @@
|
|||||||
|
# Page `/etapes` — « Quelles données pour quelles étapes et quels seuils ? » — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Créer une page statique `/etapes` qui affiche un graphique HTML/CSS montrant quelles données (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP) sont publiées à chaque étape de la passation d'un marché public et à partir de quel seuil réglementaire.
|
||||||
|
|
||||||
|
**Architecture:** Une nouvelle page Dash auto-enregistrée (`src/pages/etapes.py`) qui expose un `layout` composé uniquement de `html.Div`/`dcc.Markdown` (aucun callback, aucune donnée dynamique). La page rend **deux représentations des mêmes données** basculées par media query : sur desktop/tablette, un graphique en grille CSS (1 colonne de libellés + 5 colonnes de seuils) où chaque publication est une barre positionnée en pourcentage ; sur mobile portrait (< 768 px), une liste verticale par étape. Le style vit dans `src/assets/css/style.css` (auto-chargé par Dash). L'URL est ajoutée au sitemap mais pas à la navbar.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3, Dash 3.4 (pages API), CSS (grille + positionnement absolu), Flask (route sitemap existante).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte pour l'engineer (à lire avant de commencer)
|
||||||
|
|
||||||
|
- decp.info est une app Dash multi-pages. Chaque page est un module dans `src/pages/` qui appelle `register_page(...)` au niveau du module et expose une variable `layout`. Dash découvre ces pages automatiquement grâce à `use_pages=True` (voir `src/app.py:30`).
|
||||||
|
- **Imports** : toujours importer les modules de l'app avec le préfixe `src.` (ex. `from src.utils.seo import META_CONTENT`).
|
||||||
|
- La navbar (`src/app.py:170-182`) est construite à partir d'une **liste blanche de noms** : `["Recherche", "À propos", "Tableau", "Observatoire"]`. Une page dont le `name` n'est pas dans cette liste **n'apparaît pas** dans la navbar. On ne touche donc PAS à la navbar.
|
||||||
|
- Le sitemap (`src/app.py:70-86`) est une **liste d'URLs codée en dur**. Il faut y ajouter `/etapes`.
|
||||||
|
- Le CSS personnalisé est dans `src/assets/css/style.css` (Dash charge automatiquement tout ce qui est dans `src/assets/`). On y ajoute les règles du graphique.
|
||||||
|
- **Pré-requis commit** : ce dépôt utilise `pre-commit` (prettier, ruff). Les hooks ne tournent que si le virtualenv est activé. Avant chaque `git commit`, faire `source .venv/bin/activate` dans la même commande shell. Prettier peut reformater les fichiers Markdown/CSS : si un commit échoue parce que des fichiers ont été modifiés par un hook, refaire `git add` puis `git commit`.
|
||||||
|
- **Référence visuelle** : la maquette validée est `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`. Le code HTML/CSS ci-dessous en est la transposition.
|
||||||
|
- Ce projet n'a **pas** de test automatisé pour cette page (contenu 100 % statique). La vérification est manuelle via `python run.py`. Les tâches ci-dessous remplacent donc le cycle TDD par des vérifications de rendu explicites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Create** `src/pages/etapes.py` — la page : `register_page(...)` + `layout`. Contient `build_chart()` (graphique grille desktop), `build_mobile()` (liste verticale mobile, alimentée par la structure `STAGES_MOBILE`) et `build_legend()`, pour garder le `layout` lisible. Responsabilité unique : décrire la page `/etapes`.
|
||||||
|
- **Modify** `src/app.py` — ajouter `"/etapes"` à la liste `pages` de la fonction `sitemap()`.
|
||||||
|
- **Modify** `src/assets/css/style.css` — ajouter un bloc de règles préfixées `.etapes-*` : graphique en grille, liste mobile `.etapes-m-*`, et media query de bascule à 768 px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Squelette de la page `/etapes`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/pages/etapes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer le fichier avec l'enregistrement de page et un layout minimal**
|
||||||
|
|
||||||
|
Créer `src/pages/etapes.py` avec exactement ce contenu (le graphique sera ajouté en Task 2) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dash import dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils.seo import META_CONTENT
|
||||||
|
|
||||||
|
NAME = "Quelles données pour quelles étapes et quels seuils ?"
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/etapes",
|
||||||
|
title=f"{NAME} | 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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.H2(NAME),
|
||||||
|
dcc.Markdown(
|
||||||
|
"Un marché public passe par plusieurs étapes. À chacune, des "
|
||||||
|
"données peuvent être publiées — selon le montant du marché et "
|
||||||
|
"des obligations réglementaires. Ce graphique situe les "
|
||||||
|
"principales publications de données par **étape** (de haut en "
|
||||||
|
"bas) et par **seuil** (de gauche à droite, en euros hors taxes)."
|
||||||
|
),
|
||||||
|
# Le graphique sera inséré ici en Task 2
|
||||||
|
dcc.Markdown(
|
||||||
|
"**À noter :** l'axe horizontal n'est pas linéaire — les seuils "
|
||||||
|
"sont espacés régulièrement pour rester lisibles. Les étapes "
|
||||||
|
"*Contrat* et *Paiement* n'ont aujourd'hui aucune donnée publiée "
|
||||||
|
"en open data.",
|
||||||
|
className="etapes-note",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer l'app et vérifier que la page se charge**
|
||||||
|
|
||||||
|
Run :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis ouvrir `http://127.0.0.1:8050/etapes` dans un navigateur.
|
||||||
|
Expected : la page affiche le titre « Quelles données pour quelles étapes et quels seuils ? », le paragraphe d'intro et la note, avec le bandeau de navigation en haut. Aucune erreur dans la console du serveur. Arrêter le serveur (Ctrl-C).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier l'absence dans la navbar**
|
||||||
|
|
||||||
|
Sur n'importe quelle page, vérifier visuellement que « Étapes et données » **n'apparaît pas** dans la barre de navigation (la liste blanche `src/app.py:181` ne la contient pas).
|
||||||
|
Expected : la navbar montre uniquement Recherche / Tableau / Observatoire / À propos.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Si le commit échoue car un hook a reformaté le fichier : refaire `git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes"`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Le graphique HTML/CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/pages/etapes.py`
|
||||||
|
|
||||||
|
Le graphique est une grille de 6 colonnes : 1 colonne de libellés d'étape (150 px) + 5 colonnes de seuils égales. L'en-tête X et chaque ligne d'étape occupent les colonnes 2 → 6 (`grid-column: 2 / -1`). À l'intérieur d'une ligne, les barres sont positionnées en `position:absolute` avec `left`/`right` en pourcentage, où chaque segment de seuil = 20 % de la largeur :
|
||||||
|
|
||||||
|
- Segment 1 (0 € → 40 k€) : 0 % – 20 %
|
||||||
|
- Segment 2 (40 k€ → 90 k€) : 20 % – 40 %
|
||||||
|
- Segment 3 (90 k€ → 140/216 k€) : 40 % – 60 %
|
||||||
|
- Segment 4 (140/216 k€ → 5,404 M€) : 60 % – 80 %
|
||||||
|
- Segment 5 (≥ 5,404 M€) : 80 % – 100 %
|
||||||
|
|
||||||
|
Une barre qui « commence à 40 k€ et va jusqu'à l'infini » s'écrit donc `left:20%; right:2%` (les `2%` de marge évitent de coller au bord). Une barre qui remplit la case 90 k€ → seuil formalisé s'écrit `left:40%; right:40%`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter la fonction `build_chart()` au-dessus de `layout`**
|
||||||
|
|
||||||
|
Dans `src/pages/etapes.py`, insérer cette fonction entre le bloc `register_page(...)` et la définition de `layout` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _lane(*bars):
|
||||||
|
"""Une ligne d'étape : fond segmenté en 5 + barres positionnées."""
|
||||||
|
return html.Div(
|
||||||
|
className="etapes-lane",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="etapes-segs",
|
||||||
|
children=[html.Div() for _ in range(5)],
|
||||||
|
),
|
||||||
|
*bars,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(label, color, style):
|
||||||
|
base = {"backgroundColor": color}
|
||||||
|
base.update(style)
|
||||||
|
return html.Div(label, className="etapes-bar", style=base)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chart():
|
||||||
|
return html.Div(
|
||||||
|
className="etapes-chart-scroll",
|
||||||
|
children=html.Div(
|
||||||
|
className="etapes-chart",
|
||||||
|
children=[
|
||||||
|
# En-tête : coin vide + 5 marqueurs de seuils
|
||||||
|
html.Div(className="etapes-corner"),
|
||||||
|
html.Div(
|
||||||
|
className="etapes-xhead",
|
||||||
|
children=[
|
||||||
|
html.Div("0 €", className="etapes-xcell"),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("40 000 €"), "seuil DECP"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("90 000 €"), "publicité"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("140 k€ / 216 k€"), "seuils formalisés (UE)"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("5,404 M€"), "travaux (UE)"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# Programmation
|
||||||
|
html.Div("Programmation", className="etapes-stage"),
|
||||||
|
_lane(
|
||||||
|
_bar(
|
||||||
|
"Approch — sourcing / préinformation (non réglementaire)",
|
||||||
|
"#7c5cff",
|
||||||
|
{"left": "2%", "right": "2%"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Publicité (appel d'offres)
|
||||||
|
html.Div(
|
||||||
|
["Publicité ", html.Small("(appel d'offres)")],
|
||||||
|
className="etapes-stage",
|
||||||
|
),
|
||||||
|
_lane(
|
||||||
|
_bar(
|
||||||
|
"Journaux d'annonces légales",
|
||||||
|
"#f79009",
|
||||||
|
{"left": "40%", "right": "40%", "top": "6px", "height": "20px"},
|
||||||
|
),
|
||||||
|
_bar(
|
||||||
|
"BOAMP",
|
||||||
|
"#1570ef",
|
||||||
|
{"left": "40%", "right": "2%", "top": "28px", "height": "20px"},
|
||||||
|
),
|
||||||
|
_bar(
|
||||||
|
"JOUE — avis de marché",
|
||||||
|
"#0e9384",
|
||||||
|
{"left": "60%", "right": "2%", "top": "6px", "height": "20px"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Attribution
|
||||||
|
html.Div("Attribution", className="etapes-stage"),
|
||||||
|
_lane(
|
||||||
|
_bar(
|
||||||
|
"DECP — données essentielles",
|
||||||
|
"#12b76a",
|
||||||
|
{"left": "20%", "right": "2%", "top": "6px", "height": "20px"},
|
||||||
|
),
|
||||||
|
_bar(
|
||||||
|
"JOUE — avis d'attribution",
|
||||||
|
"#0e9384",
|
||||||
|
{"left": "60%", "right": "2%", "top": "28px", "height": "20px"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Contrat (vide)
|
||||||
|
html.Div("Contrat", className="etapes-stage"),
|
||||||
|
html.Div(
|
||||||
|
"— aucune donnée publiée aujourd'hui —",
|
||||||
|
className="etapes-lane etapes-empty",
|
||||||
|
),
|
||||||
|
# Paiement (vide)
|
||||||
|
html.Div("Paiement", className="etapes-stage"),
|
||||||
|
html.Div(
|
||||||
|
"— aucune donnée publiée aujourd'hui —",
|
||||||
|
className="etapes-lane etapes-empty",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_legend():
|
||||||
|
items = [
|
||||||
|
("Approch", "#7c5cff"),
|
||||||
|
("Journaux d'annonces légales", "#f79009"),
|
||||||
|
("BOAMP", "#1570ef"),
|
||||||
|
("JOUE", "#0e9384"),
|
||||||
|
("DECP", "#12b76a"),
|
||||||
|
]
|
||||||
|
return html.Div(
|
||||||
|
className="etapes-legend",
|
||||||
|
children=[
|
||||||
|
html.Span(
|
||||||
|
[
|
||||||
|
html.I(style={"backgroundColor": color}),
|
||||||
|
label,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for label, color in items
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insérer le graphique et la légende dans `layout`**
|
||||||
|
|
||||||
|
Dans `layout`, remplacer la ligne de commentaire `# Le graphique sera inséré ici en Task 2` par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
build_chart(),
|
||||||
|
build_legend(),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lancer l'app et vérifier le rendu**
|
||||||
|
|
||||||
|
Run :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir `http://127.0.0.1:8050/etapes`.
|
||||||
|
Expected (comparer à la maquette `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`) :
|
||||||
|
|
||||||
|
- En-tête X : `0 € · 40 000 € (seuil DECP) · 90 000 € (publicité) · 140 k€/216 k€ (seuils formalisés UE) · 5,404 M€ (travaux UE)`.
|
||||||
|
- Lignes de haut en bas : Programmation (barre Approch pleine largeur), Publicité (Journaux d'annonces légales + BOAMP + JOUE), Attribution (DECP + JOUE), Contrat (vide), Paiement (vide).
|
||||||
|
- La barre « Journaux d'annonces légales » occupe la case 90 k€ → seuil formalisé ; DECP démarre à 40 k€ ; JOUE et BOAMP démarrent aux bons segments.
|
||||||
|
- La légende sous le graphique liste les 5 publications avec leurs couleurs.
|
||||||
|
|
||||||
|
À ce stade le style brut (couleurs des barres) doit déjà être visible car appliqué inline ; la mise en page de la grille sera finalisée en Task 4. Si la grille n'est pas encore correcte (colonnes non alignées), c'est attendu — continuer. Arrêter le serveur.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): graphique données par étape et par seuil"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Si échec dû à un hook : refaire `git add` puis `git commit`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Vue mobile (liste verticale par étape)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/pages/etapes.py`
|
||||||
|
|
||||||
|
Sur écran portrait étroit, le graphique en grille n'est pas lisible (vue d'ensemble perdue). On ajoute une **liste verticale par étape** qui décrit les mêmes données en texte. Le basculement entre les deux rendus se fera en CSS (Task 4). Pour éviter la duplication, les publications de chaque étape sont décrites dans une structure de données Python consommée par le rendu mobile.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter la structure de données et `build_mobile()`**
|
||||||
|
|
||||||
|
Dans `src/pages/etapes.py`, ajouter ce bloc juste avant la fonction `build_legend()` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Données par étape, partagées par la vue mobile.
|
||||||
|
# Chaque item : (libellé, couleur, plage de seuils en texte).
|
||||||
|
STAGES_MOBILE = [
|
||||||
|
(
|
||||||
|
"Programmation",
|
||||||
|
[
|
||||||
|
("Approch", "#7c5cff", "tous montants — publication non réglementaire"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Publicité (appel d'offres)",
|
||||||
|
[
|
||||||
|
("Journaux d'annonces légales", "#f79009", "de 90 000 € au seuil formalisé"),
|
||||||
|
("BOAMP", "#1570ef", "à partir de 90 000 €"),
|
||||||
|
(
|
||||||
|
"JOUE — avis de marché",
|
||||||
|
"#0e9384",
|
||||||
|
"à partir des seuils formalisés (140 k€ / 216 k€)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Attribution",
|
||||||
|
[
|
||||||
|
("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"),
|
||||||
|
("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("Contrat", []),
|
||||||
|
("Paiement", []),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_mobile():
|
||||||
|
blocks = []
|
||||||
|
for stage, items in STAGES_MOBILE:
|
||||||
|
if items:
|
||||||
|
children = [
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.I(style={"backgroundColor": color}),
|
||||||
|
html.Span(label, className="etapes-m-label"),
|
||||||
|
html.Span(seuil, className="etapes-m-seuil"),
|
||||||
|
],
|
||||||
|
className="etapes-m-item",
|
||||||
|
)
|
||||||
|
for label, color, seuil in items
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
children = [
|
||||||
|
html.Div(
|
||||||
|
"aucune donnée publiée aujourd'hui",
|
||||||
|
className="etapes-m-item etapes-m-empty",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
blocks.append(
|
||||||
|
html.Div(
|
||||||
|
[html.H4(stage, className="etapes-m-stage"), *children],
|
||||||
|
className="etapes-m-block",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return html.Div(blocks, className="etapes-mobile")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insérer `build_mobile()` dans `layout`**
|
||||||
|
|
||||||
|
Dans `layout`, la ligne `build_chart(),` (insérée en Task 2) est suivie de `build_mobile(),`, soit :
|
||||||
|
|
||||||
|
```python
|
||||||
|
build_chart(),
|
||||||
|
build_mobile(),
|
||||||
|
build_legend(),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lancer l'app et vérifier (rendu brut, avant CSS de bascule)**
|
||||||
|
|
||||||
|
Run :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir `http://127.0.0.1:8050/etapes`. À ce stade les deux rendus s'affichent l'un sous l'autre (la bascule CSS arrive en Task 4) : sous le graphique, la liste affiche Programmation (Approch…), Publicité (3 publications), Attribution (2 publications), puis Contrat et Paiement avec « aucune donnée publiée aujourd'hui ». C'est attendu. Arrêter le serveur.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): vue mobile liste par étape"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Si échec dû à un hook : refaire `git add` puis `git commit`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : CSS du graphique + bascule mobile
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/assets/css/style.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter le bloc CSS à la fin de `src/assets/css/style.css`**
|
||||||
|
|
||||||
|
Ajouter à la fin du fichier :
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ===== Page /etapes : graphique données par étape et par seuil ===== */
|
||||||
|
|
||||||
|
.etapes-chart-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-chart {
|
||||||
|
min-width: 720px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-corner {
|
||||||
|
border-bottom: 2px solid #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-xhead {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
border-bottom: 2px solid #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-xcell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475467;
|
||||||
|
border-left: 1px dashed #d0d5dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-xcell strong {
|
||||||
|
display: block;
|
||||||
|
color: #101828;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-stage {
|
||||||
|
padding: 14px 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
border-bottom: 1px solid #eaecf0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-stage small {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-lane {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid #eaecf0;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-segs {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-segs > div {
|
||||||
|
border-left: 1px dashed #eaecf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-empty {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-legend {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-legend span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-legend i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Vue mobile (liste par étape) : masquée par défaut --- */
|
||||||
|
|
||||||
|
.etapes-mobile {
|
||||||
|
display: none;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-block {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-stage {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #eaecf0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f2f4f7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-item i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-seuil {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-empty {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bascule desktop / mobile au point de rupture 768 px --- */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.etapes-chart-scroll,
|
||||||
|
.etapes-legend {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.etapes-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer l'app et vérifier le rendu final**
|
||||||
|
|
||||||
|
Run :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir `http://127.0.0.1:8050/etapes` en grand écran (≥ 768 px).
|
||||||
|
Expected : le graphique est identique à la maquette v3 — colonnes alignées, en-tête X avec ligne de séparation foncée, barres colorées bien positionnées dans chaque segment, lignes Contrat/Paiement grisées en italique, légende sous le graphique. La **liste mobile est masquée** (le graphique seul est visible).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier la bascule responsive**
|
||||||
|
|
||||||
|
Dans le navigateur, ouvrir les devtools et passer en mode mobile portrait (largeur < 768 px, ex. iPhone SE 375 px). Tester aussi une largeur intermédiaire (~800 px).
|
||||||
|
Expected :
|
||||||
|
|
||||||
|
- À largeur intermédiaire (~800 px, ≥ 768) : le **graphique** s'affiche, défilable horizontalement (`overflow-x:auto` + `min-width:720px`), barres non écrasées ; liste mobile masquée.
|
||||||
|
- En portrait (< 768 px) : le graphique **et la légende disparaissent**, remplacés par la **liste verticale par étape** — chaque étape est un bloc avec son titre, et chaque publication a sa pastille de couleur, son nom et sa plage de seuils en texte. Aucun défilement horizontal nécessaire. Contrat/Paiement affichent « aucune donnée publiée aujourd'hui » en italique.
|
||||||
|
|
||||||
|
Arrêter le serveur.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && git add src/assets/css/style.css && git commit -m "feat(etapes): styles du graphique étapes/seuils"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Si échec dû à un hook prettier : refaire `git add` puis `git commit`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Référencement de la page dans le sitemap
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/app.py` (fonction `sitemap()`, ~ligne 73)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter `/etapes` à la liste des URLs du sitemap**
|
||||||
|
|
||||||
|
Dans `src/app.py`, dans la fonction `sitemap()`, modifier la liste `pages` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
pages = [
|
||||||
|
"/",
|
||||||
|
"/observatoire",
|
||||||
|
"/tableau",
|
||||||
|
"/a-propos",
|
||||||
|
"/etapes",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le sitemap**
|
||||||
|
|
||||||
|
Run :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir `http://127.0.0.1:8050/sitemap.xml`.
|
||||||
|
Expected : le XML contient désormais une entrée `<loc>https://decp.info/etapes</loc>`. Arrêter le serveur.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate && git add src/app.py && git commit -m "feat(etapes): référencement de /etapes dans le sitemap"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vérification finale (checklist de la spec)
|
||||||
|
|
||||||
|
- [ ] `/etapes` affiche le graphique fidèle à la maquette v3, avec le bandeau de navigation global en haut.
|
||||||
|
- [ ] La page est **absente** de la navbar.
|
||||||
|
- [ ] `/sitemap.xml` **contient** `/etapes`.
|
||||||
|
- [ ] Sur fenêtre intermédiaire (≥ 768 px), le graphique défile horizontalement sans s'écraser.
|
||||||
|
- [ ] Sur écran portrait étroit (< 768 px), le graphique est masqué et remplacé par la liste verticale par étape, lisible sans défilement horizontal.
|
||||||
|
- [ ] Titre H2 de la page = « Quelles données pour quelles étapes et quels seuils ? ».
|
||||||
|
- [ ] `name` de la page = « Étapes et données ».
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
# Bootstrap résilient des données et du schéma — Plan d'implémentation
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Rendre le démarrage de decp.info résilient aux ressources externes KO (parquet, schéma, stats), pour que l'API partagée ne tombe plus à cause d'un déploiement défaillant.
|
||||||
|
|
||||||
|
**Architecture:** Trois invariants. (1) Le DuckDB est réutilisé si la reconstruction échoue. (2) Le schéma suit la chaîne `URL → cache → RuntimeError`, le dernier schéma distant fonctionnel étant persisté localement. (3) Les chargements de pages au boot ne lèvent jamais sur une ressource externe KO. Une tâche préalable répare le baseline de tests laissé rouge par le merge de `main`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, Polars, DuckDB, httpx, flask-caching, pytest (+ monkeypatch).
|
||||||
|
|
||||||
|
**Spec de référence:** `docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure des fichiers
|
||||||
|
|
||||||
|
| Fichier | Responsabilité | Action |
|
||||||
|
| ------------------------------------- | ---------------------------------------- | ----------------------------- |
|
||||||
|
| `tests/test_db.py` | Tests bootstrap DuckDB | Modifier (réparer + ajouter) |
|
||||||
|
| `tests/conftest.py` | Setup déterministe des tests | Modifier (seed schéma) |
|
||||||
|
| `tests/schema.fixture.json` | Schéma complet figé pour tests (offline) | Créer (commité) |
|
||||||
|
| `tests/test_schema.py` | Tests résolution schéma | Créer |
|
||||||
|
| `tests/test_page_loads.py` | Tests chargements best-effort (C/D) | Créer |
|
||||||
|
| `src/utils/data.py` | Résolution schéma | Modifier |
|
||||||
|
| `src/db.py` | Bootstrap DuckDB | Modifier (`_ensure_database`) |
|
||||||
|
| `src/utils/__init__.py` | Helper date MAJ best-effort | Modifier (ajout fonction) |
|
||||||
|
| `src/pages/tableau.py` | Date MAJ au niveau module | Modifier |
|
||||||
|
| `src/figures.py` | `get_sources_tables` | Modifier |
|
||||||
|
| `.template.env`, `.env`, `.gitignore` | Config | Modifier |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Réparer le baseline de tests `test_db.py`
|
||||||
|
|
||||||
|
Le merge de `main` a changé `build_database(db_path)` (1 arg, parquet lu via env) et memoïsé `get_last_modified` (besoin du contexte d'app). Trois corrections pour repartir au vert. **Aucune logique applicative ne change ici.**
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tests/test_db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Corriger la fixture `built_db` (signature `build_database`)**
|
||||||
|
|
||||||
|
Dans `tests/test_db.py`, remplacer :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.db import build_database
|
||||||
|
|
||||||
|
build_database(db_path, parquet_path)
|
||||||
|
return db_path
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.db import build_database
|
||||||
|
|
||||||
|
build_database(db_path)
|
||||||
|
return db_path
|
||||||
|
```
|
||||||
|
|
||||||
|
(L'env `DATA_FILE_PARQUET_PATH` est déjà posé juste au-dessus dans la fixture.)
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Corriger `test_concurrent_build_serialized`**
|
||||||
|
|
||||||
|
Ajouter `monkeypatch` à la signature et poser l'env du parquet ; corriger l'appel `build_database`.
|
||||||
|
|
||||||
|
Remplacer la ligne de signature :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_concurrent_build_serialized(tmp_path):
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_concurrent_build_serialized(tmp_path, monkeypatch):
|
||||||
|
```
|
||||||
|
|
||||||
|
Juste après `df.write_parquet(parquet_path)`, ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
|
||||||
|
```
|
||||||
|
|
||||||
|
Et dans `worker()`, remplacer :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if db.should_rebuild(db_path, parquet_path):
|
||||||
|
db.build_database(db_path, parquet_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if db.should_rebuild(db_path, parquet_path):
|
||||||
|
db.build_database(db_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Corriger les 2 tests prod de `should_rebuild` (memoïsation)**
|
||||||
|
|
||||||
|
`should_rebuild` non-dev appelle `get_last_modified`, memoïsé et inutilisable hors contexte d'app en test. On le monkeypatche pour tester la logique de comparaison de dates.
|
||||||
|
|
||||||
|
Dans `test_should_rebuild_prod_when_parquet_newer`, juste avant l'`assert`, ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `test_should_not_rebuild_prod_when_parquet_older`, juste avant l'`assert`, ajouter la même ligne :
|
||||||
|
|
||||||
|
```python
|
||||||
|
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer les tests, vérifier le vert**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest tests/test_db.py -q`
|
||||||
|
Expected: tous PASS (plus aucun `TypeError`/`AttributeError`).
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_db.py
|
||||||
|
git commit -m "test: réparer le baseline test_db cassé par le merge (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Schéma résilient — chaîne `URL → cache → RuntimeError`
|
||||||
|
|
||||||
|
Réécrire `get_data_schema` avec helpers robustes et persistance du dernier schéma distant fonctionnel. Supprimer `DATA_SCHEMA_LOCAL` au profit de `DATA_SCHEMA_CACHE`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `tests/schema.fixture.json`, `tests/test_schema.py`
|
||||||
|
- Modify: `tests/conftest.py`, `src/utils/data.py`, `.template.env`, `.env`, `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer le fixture schéma complet (offline, déterministe)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ../decp-processing/dist/schema.json tests/schema.fixture.json
|
||||||
|
test -s tests/schema.fixture.json && python -c "import json;assert 'fields' in json.load(open('tests/schema.fixture.json'))" && echo OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK` (le fixture contient bien une clé `fields`).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Rendre la résolution du schéma déterministe en test (conftest)**
|
||||||
|
|
||||||
|
Dans `tests/conftest.py`, ajouter `import json` en tête (avec les autres imports) puis, au niveau module **avant** toute logique existante (juste après la ligne `_DB_PATH = Path(...)`), ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un
|
||||||
|
# fixture commité et on désactive la récupération distante.
|
||||||
|
_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json"))
|
||||||
|
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE)
|
||||||
|
os.environ.pop("DATA_SCHEMA_PATH", None)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Écrire les tests schéma (échouent d'abord)**
|
||||||
|
|
||||||
|
Créer `tests/test_schema.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.utils import data as data_mod
|
||||||
|
|
||||||
|
VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
def __init__(self, payload, ok=True, bad_json=False):
|
||||||
|
self._payload = payload
|
||||||
|
self._ok = ok
|
||||||
|
self._bad_json = bad_json
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if not self._ok:
|
||||||
|
raise httpx.HTTPError("boom")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if self._bad_json:
|
||||||
|
raise json.JSONDecodeError("bad", "", 0)
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
|
||||||
|
result = data_mod.get_data_schema()
|
||||||
|
assert "uid" in result
|
||||||
|
assert json.loads(cache.read_text())["fields"][0]["name"] == "uid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
cache.write_text(json.dumps(VALID))
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
cache.write_text(json.dumps(VALID))
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1}))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_url_uses_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
cache.write_text(json.dumps(VALID))
|
||||||
|
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_source_raises(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json"))
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch):
|
||||||
|
# parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé
|
||||||
|
cache = tmp_path / "nodir" / "schema.cache.json"
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer les tests, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest tests/test_schema.py -q`
|
||||||
|
Expected: FAIL (les helpers/comportements n'existent pas encore ; `get_data_schema` actuel plante différemment).
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Réécrire `get_data_schema` + helpers**
|
||||||
|
|
||||||
|
Dans `src/utils/data.py`, remplacer entièrement la fonction `get_data_schema` (actuellement lignes ~68-95) par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _validate_schema(raw) -> dict | None:
|
||||||
|
if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]:
|
||||||
|
return raw
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_remote_schema(url: str | None) -> dict | None:
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = get(url, follow_redirects=True).raise_for_status().json()
|
||||||
|
except (httpx.HTTPError, json.JSONDecodeError) as e:
|
||||||
|
logger.error(f"Schéma distant indisponible ({url}) : {e}")
|
||||||
|
return None
|
||||||
|
return _validate_schema(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_schema_file(path: str) -> dict | None:
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.error(f"Schéma local illisible ({path}) : {e}")
|
||||||
|
return None
|
||||||
|
return _validate_schema(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_schema_cache(raw: dict, path: str) -> None:
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(raw, f)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_schema() -> dict:
|
||||||
|
cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
|
||||||
|
raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH"))
|
||||||
|
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"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifier que les imports en tête de `src/utils/data.py` couvrent : `json`, `os`, `OrderedDict`, `httpx`, `get` (déjà présents : `from httpx import HTTPError, get`). `HTTPError` peut devenir inutilisé — voir Step 7.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Lancer les tests, vérifier le vert**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest tests/test_schema.py -q`
|
||||||
|
Expected: 6 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Nettoyer imports + config + gitignore**
|
||||||
|
|
||||||
|
Dans `src/utils/data.py`, si `HTTPError` n'est plus utilisé, remplacer `from httpx import HTTPError, get` par `from httpx import get` (garder `import httpx`). Vérifier avec :
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m ruff check src/utils/data.py`
|
||||||
|
Expected: pas d'erreur F401.
|
||||||
|
|
||||||
|
Dans `.gitignore`, ajouter sous la ligne `**/decp.duckdb` :
|
||||||
|
|
||||||
|
```
|
||||||
|
**/schema.cache.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `.template.env`, supprimer la ligne `DATA_SCHEMA_PATH_LOCAL=...` et ajouter :
|
||||||
|
|
||||||
|
```
|
||||||
|
DATA_SCHEMA_CACHE=./schema.cache.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `.env` (local, non versionné), supprimer la ligne `DATA_SCHEMA_LOCAL=...` et ajouter `DATA_SCHEMA_CACHE=./schema.cache.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 8 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/schema.fixture.json tests/test_schema.py tests/conftest.py src/utils/data.py .template.env .gitignore
|
||||||
|
git commit -m "feat: schéma résilient URL→cache + suppression DATA_SCHEMA_LOCAL (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Bootstrap DuckDB résilient
|
||||||
|
|
||||||
|
Ajouter le garde-fou `try/except` dans `_ensure_database` : réutiliser le DuckDB existant si la reconstruction échoue ; ne lever qu'en cold start.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/db.py:117-128` (`_ensure_database`)
|
||||||
|
- Test: `tests/test_db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests (échouent d'abord)**
|
||||||
|
|
||||||
|
Ajouter à la fin de `tests/test_db.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb"
|
||||||
|
dbf.write_bytes(b"existing")
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", _raise)
|
||||||
|
result = db._ensure_database() # ne doit pas lever
|
||||||
|
assert result == dbf
|
||||||
|
assert dbf.read_bytes() == b"existing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb"
|
||||||
|
dbf.write_bytes(b"existing")
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(db, "build_database", _raise)
|
||||||
|
db._ensure_database() # ne doit pas lever
|
||||||
|
assert dbf.read_bytes() == b"existing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb" # n'existe pas
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(db, "build_database", _raise)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
db._ensure_database()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_builds_when_needed(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb"
|
||||||
|
dbf.write_bytes(b"old")
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x")
|
||||||
|
called = {}
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p))
|
||||||
|
db._ensure_database()
|
||||||
|
assert called.get("built") == dbf
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter `import pytest` en tête de `tests/test_db.py` s'il n'y est pas déjà (il y est).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest tests/test_db.py -q -k ensure_database`
|
||||||
|
Expected: FAIL (le `try/except` n'existe pas ; les exceptions remontent).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Implémenter le garde-fou**
|
||||||
|
|
||||||
|
Dans `src/db.py`, remplacer `_ensure_database` (lignes ~117-128) par :
|
||||||
|
|
||||||
|
```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)
|
||||||
|
else:
|
||||||
|
logger.debug("Base de données déjà disponible et à jour.")
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer, vérifier le vert**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest tests/test_db.py -q`
|
||||||
|
Expected: tous PASS (anciens + 4 nouveaux).
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/db.py tests/test_db.py
|
||||||
|
git commit -m "feat: réutiliser le DuckDB existant si le bootstrap échoue (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Chargements de pages best-effort (C + D)
|
||||||
|
|
||||||
|
`tableau.py` (date MAJ via `get_last_modified`) et `a-propos.py` (stats via `get_sources_tables`) chargent au boot et peuvent tuer le démarrage. On les rend best-effort.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `tests/test_page_loads.py`
|
||||||
|
- Modify: `src/utils/__init__.py`, `src/pages/tableau.py`, `src/figures.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests (échouent d'abord)**
|
||||||
|
|
||||||
|
Créer `tests/test_page_loads.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch):
|
||||||
|
import src.utils as u
|
||||||
|
from src.utils import get_data_update_timestamp
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("net down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(u, "get_last_modified", boom)
|
||||||
|
fb = tmp_path / "decp.duckdb"
|
||||||
|
fb.write_bytes(b"x")
|
||||||
|
assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb))
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_timestamp_none_when_all_fail(monkeypatch):
|
||||||
|
import src.utils as u
|
||||||
|
from src.utils import get_data_update_timestamp
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("net down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(u, "get_last_modified", boom)
|
||||||
|
assert get_data_update_timestamp("http://x", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_timestamp_nominal(monkeypatch):
|
||||||
|
import src.utils as u
|
||||||
|
from src.utils import get_data_update_timestamp
|
||||||
|
|
||||||
|
monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0)
|
||||||
|
assert get_data_update_timestamp("http://x", None) == 123.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_tables_none_path():
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
|
||||||
|
div = get_sources_tables(None)
|
||||||
|
assert "indisponible" in str(div.children).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_tables_missing_file():
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
|
||||||
|
div = get_sources_tables("/does/not/exist.csv")
|
||||||
|
assert "indisponible" in str(div.children).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_tables_valid_csv(tmp_path):
|
||||||
|
from dash import dash_table
|
||||||
|
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
|
||||||
|
csv = tmp_path / "s.csv"
|
||||||
|
csv.write_text(
|
||||||
|
"nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n"
|
||||||
|
"Source A,Org A,5,2,XA,http://a,1\n"
|
||||||
|
)
|
||||||
|
div = get_sources_tables(str(csv))
|
||||||
|
assert isinstance(div.children, dash_table.DataTable)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest tests/test_page_loads.py -q`
|
||||||
|
Expected: FAIL (`get_data_update_timestamp` n'existe pas ; `get_sources_tables(None)` plante).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Ajouter `get_data_update_timestamp` dans `src/utils/__init__.py`**
|
||||||
|
|
||||||
|
À la fin de `src/utils/__init__.py`, ajouter :
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
(`os` et `logger` sont déjà disponibles dans ce module.)
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Utiliser le helper dans `tableau.py`**
|
||||||
|
|
||||||
|
Dans `src/pages/tableau.py`, remplacer l'import ligne 24 :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.utils import get_last_modified, logger
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.utils import get_data_update_timestamp, logger
|
||||||
|
```
|
||||||
|
|
||||||
|
Et remplacer les lignes 36-38 :
|
||||||
|
|
||||||
|
```python
|
||||||
|
update_date_timestamp = get_last_modified(os.getenv("DATA_FILE_PARQUET_PATH", ""))
|
||||||
|
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||||
|
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
update_date_timestamp = get_data_update_timestamp(
|
||||||
|
os.getenv("DATA_FILE_PARQUET_PATH", ""),
|
||||||
|
os.getenv("DUCKDB_PATH", "./decp.duckdb"),
|
||||||
|
)
|
||||||
|
if update_date_timestamp is not None:
|
||||||
|
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||||
|
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||||
|
else:
|
||||||
|
update_date = "date inconnue"
|
||||||
|
update_date_iso = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Élargir `get_sources_tables` dans `src/figures.py`**
|
||||||
|
|
||||||
|
Dans `src/figures.py` (`get_sources_tables`, ~lignes 122-125), remplacer :
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
dff = pl.read_csv(source_path)
|
||||||
|
except (URLError, HTTPError):
|
||||||
|
return html.Div("Erreur de connexion")
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
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.")
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `URLError`/`HTTPError` (import ligne 3 `from urllib.error import HTTPError, URLError`) ne sont plus utilisés ailleurs dans le fichier, supprimer cet import.
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m ruff check src/figures.py`
|
||||||
|
Expected: pas d'erreur F401.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Lancer, vérifier le vert**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest tests/test_page_loads.py -q`
|
||||||
|
Expected: 6 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Smoke test — l'import des modules modifiés ne casse pas**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -c "import src.figures, src.pages.tableau; print('import OK')"`
|
||||||
|
Expected: `import OK`.
|
||||||
|
(NB : `a-propos.py` a un tiret, non importable par nom — son correctif `get_sources_tables` est couvert par les tests unitaires du Step 6 et la page sera validée par la suite Selenium au Step 8.)
|
||||||
|
|
||||||
|
- [ ] **Step 8 : Suite complète**
|
||||||
|
|
||||||
|
Run: `rtk proxy python -m pytest -q`
|
||||||
|
Expected: vert (hors tests Selenium nécessitant Chrome, à lancer si l'environnement le permet).
|
||||||
|
|
||||||
|
- [ ] **Step 9 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_page_loads.py src/utils/__init__.py src/pages/tableau.py src/figures.py
|
||||||
|
git commit -m "feat: chargements de pages best-effort au boot (tableau, sources) (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes d'exécution
|
||||||
|
|
||||||
|
- **Dev hors-ligne 1er run :** « cache seul » supprime le fallback in-repo. Au tout premier démarrage sur une machine sans `schema.cache.json` ni réseau, le boot lèvera `RuntimeError`. En conditions normales (URL OK une fois, ou cache déjà présent) c'est transparent. Les tests sont rendus déterministes via `tests/schema.fixture.json` (Task 2).
|
||||||
|
- **CHANGELOG :** penser à ajouter une entrée (résilience bootstrap données/schéma) avant de finaliser la PR #78, si le projet le tient à jour.
|
||||||
|
- **`get_last_modified` reste memoïsé** : non modifié ici ; le cache FileSystem est vidé à chaque boot (`rmtree` dans `app.py`), donc pas de last-modified périmé entre déploiements.
|
||||||
@@ -0,0 +1,725 @@
|
|||||||
|
# Parité API decp.info / tabular-api — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Atteindre la parité de l'API `/api/v1/data` de decp.info avec `tabular-api` (data.gouv.fr) sur les opérateurs manquants, et documenter chaque mot-clé dans le Swagger UI.
|
||||||
|
|
||||||
|
**Architecture:** Le parsing des filtres reste dans `src/api/filters.py` (`build_where`). On y ajoute l'opérateur `differs` et une nouvelle fonction `parse_aggregators` qui détecte les drapeaux d'agrégation et produit des fragments SQL. `src/db.py` gagne `aggregate_marches`. `src/api/routes.py` oriente vers le chemin agrégation ou le chemin normal, renomme le param réservé `count`→`count_results`, et documente les opérateurs.
|
||||||
|
|
||||||
|
**Tech Stack:** Flask + flask-smorest, DuckDB, Polars, pytest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Imports internes toujours préfixés `src.` (ex. `src.api.filters`).
|
||||||
|
- Valeurs de filtre liées par paramètres `?` (jamais interpolées). Noms de colonnes validés contre `schema` avant interpolation.
|
||||||
|
- Spec de référence : `docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md`.
|
||||||
|
- Périmètre : `count_results`, `differs`, agrégation (`groupby`/`count`/`sum`/`avg`/`min`/`max`), doc. **Hors périmètre : `or`.**
|
||||||
|
- Lancer les tests via `rtk pytest` ; l'environnement de test définit `DEVELOPMENT=true` et `DATA_FILE_PARQUET_PATH=tests/test.parquet`.
|
||||||
|
- `tests/test.parquet` contient notamment les colonnes : `uid`, `montant` (Int64), `acheteur_departement_code`, `objet`, `dateNotification` (Date).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1 : Renommer le param réservé `count` → `count_results`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/api/filters.py` (constante `RESERVED_PARAMS`)
|
||||||
|
- Modify: `src/api/routes.py` (lecture du param + doc swagger)
|
||||||
|
- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: rien.
|
||||||
|
- Produces: le param réservé s'appelle désormais `count_results` ; `count` n'est plus réservé (libéré pour l'agrégation en Task 3).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Mettre à jour les tests existants**
|
||||||
|
|
||||||
|
Dans `tests/api/test_filters.py`, remplacer `("count", "false")` par `("count_results", "false")` dans `test_reserved_params_are_ignored` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_reserved_params_are_ignored():
|
||||||
|
where, params, order = build_where(
|
||||||
|
[
|
||||||
|
("page", "2"),
|
||||||
|
("page_size", "100"),
|
||||||
|
("columns", "uid"),
|
||||||
|
("count_results", "false"),
|
||||||
|
("uid__exact", "z"),
|
||||||
|
],
|
||||||
|
SCHEMA,
|
||||||
|
)
|
||||||
|
assert where == '"uid" = ?'
|
||||||
|
assert params == ["z"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `tests/api/test_endpoints_data.py`, renommer le test et l'URL :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_data_count_results_false_omits_total(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data?count_results=false", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "total" not in body["meta"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_count_results_false_omits_total tests/api/test_filters.py::test_reserved_params_are_ignored -v`
|
||||||
|
Expected: FAIL (`count_results` encore traité comme filtre inconnu → 400 / `FilterError`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Modifier `RESERVED_PARAMS`**
|
||||||
|
|
||||||
|
Dans `src/api/filters.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Modifier la lecture dans la route**
|
||||||
|
|
||||||
|
Dans `src/api/routes.py`, fonction `data()`, remplacer :
|
||||||
|
|
||||||
|
```python
|
||||||
|
count = request.args.get("count", "true").lower() != "false"
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
count_results = request.args.get("count_results", "true").lower() != "false"
|
||||||
|
```
|
||||||
|
|
||||||
|
et plus bas remplacer `if count else None` par `if count_results else None`.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Mettre à jour la doc swagger du param**
|
||||||
|
|
||||||
|
Dans `src/api/routes.py`, dans `@bp.doc(parameters=[...])`, remplacer le bloc du paramètre `count` par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "count_results",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "string", "enum": ["true", "false"], "default": "true"},
|
||||||
|
"description": "Inclure le total (`COUNT(*)`) dans `meta`. Mettre `false` pour accélérer la requête. Ignoré en mode agrégation.",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Et dans le docstring de `data()`, remplacer la mention `count (true|false ...)` par `count_results (true|false ; mettre false pour économiser le COUNT(*))`.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Lancer les tests, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_endpoints_data.py tests/api/test_filters.py -v`
|
||||||
|
Expected: PASS (tous).
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/api/filters.py src/api/routes.py tests/api/test_filters.py tests/api/test_endpoints_data.py
|
||||||
|
git commit -m "feat(api): renomme le param réservé count en count_results (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2 : Opérateur de filtre `differs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/api/filters.py` (`OPERATORS`, `build_where`)
|
||||||
|
- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `build_where(args, schema) -> (where_sql, params, order_sql)` (existant).
|
||||||
|
- Produces: `col__differs=val` → fragment SQL `"col" IS DISTINCT FROM ?`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)**
|
||||||
|
|
||||||
|
Dans `tests/api/test_filters.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_differs_filter():
|
||||||
|
where, params, _ = build_where([("uid__differs", "abc")], SCHEMA)
|
||||||
|
assert where == '"uid" IS DISTINCT FROM ?'
|
||||||
|
assert params == ["abc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_differs_filter_on_int():
|
||||||
|
where, params, _ = build_where([("annee__differs", "2020")], SCHEMA)
|
||||||
|
assert where == '"annee" IS DISTINCT FROM ?'
|
||||||
|
assert params == [2020]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_filters.py::test_differs_filter tests/api/test_filters.py::test_differs_filter_on_int -v`
|
||||||
|
Expected: FAIL (`FilterError: Opérateur inconnu : __differs`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Ajouter `differs` à `OPERATORS`**
|
||||||
|
|
||||||
|
Dans `src/api/filters.py`, ajouter `"differs",` dans le set `OPERATORS` (après `"notcontains",`).
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Ajouter la branche dans `build_where`**
|
||||||
|
|
||||||
|
Dans `src/api/filters.py`, dans `build_where`, après le bloc `elif op == "notcontains":` (lignes ~132-134), ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif op == "differs":
|
||||||
|
where_parts.append(f'"{col}" IS DISTINCT FROM ?')
|
||||||
|
params.append(v)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer les tests unitaires, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_filters.py -k differs -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Ajouter un test d'endpoint**
|
||||||
|
|
||||||
|
Dans `tests/api/test_endpoints_data.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_data_differs_excludes_value(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
|
||||||
|
uid = base["data"][0]["uid"]
|
||||||
|
resp = client.get(f"/api/v1/data?uid__differs={uid}", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert all(row["uid"] != uid for row in body["data"])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Lancer, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_differs_excludes_value -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/api/filters.py tests/api/test_filters.py tests/api/test_endpoints_data.py
|
||||||
|
git commit -m "feat(api): ajoute l'opérateur de filtre differs (IS DISTINCT FROM) (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3 : Parsing des agrégateurs (`parse_aggregators`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/api/filters.py` (constantes `AGGREGATORS`/`AGG_SQL`, dataclass `AggregationSpec`, fonction `parse_aggregators`, skip dans `build_where`)
|
||||||
|
- Test: `tests/api/test_filters.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `_split_key`, `FilterError` (existants).
|
||||||
|
- Produces:
|
||||||
|
|
||||||
|
- `AGGREGATORS: set[str]` = `{"groupby","count","sum","avg","min","max"}`.
|
||||||
|
- `@dataclass class AggregationSpec: select_sql: str; group_by_sql: str | None`.
|
||||||
|
- `parse_aggregators(args: list[tuple[str,str]], schema: pl.Schema) -> AggregationSpec | None` — `None` si aucun agrégateur.
|
||||||
|
- `build_where` ignore désormais les clés dont l'opérateur ∈ `AGGREGATORS`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)**
|
||||||
|
|
||||||
|
Dans `tests/api/test_filters.py`, ajouter l'import et les tests :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.api.filters import AggregationSpec, parse_aggregators
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_none_when_absent():
|
||||||
|
assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_groupby_and_count():
|
||||||
|
spec = parse_aggregators(
|
||||||
|
[("annee__groupby", ""), ("uid__count", "")], SCHEMA
|
||||||
|
)
|
||||||
|
assert isinstance(spec, AggregationSpec)
|
||||||
|
assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"'
|
||||||
|
assert spec.group_by_sql == '"annee"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_multiple_aggregates():
|
||||||
|
spec = parse_aggregators(
|
||||||
|
[
|
||||||
|
("annee__groupby", ""),
|
||||||
|
("montant__sum", ""),
|
||||||
|
("montant__avg", ""),
|
||||||
|
("montant__min", ""),
|
||||||
|
("montant__max", ""),
|
||||||
|
],
|
||||||
|
SCHEMA,
|
||||||
|
)
|
||||||
|
assert spec.select_sql == (
|
||||||
|
'"annee", SUM("montant") AS "montant__sum", '
|
||||||
|
'AVG("montant") AS "montant__avg", '
|
||||||
|
'MIN("montant") AS "montant__min", '
|
||||||
|
'MAX("montant") AS "montant__max"'
|
||||||
|
)
|
||||||
|
assert spec.group_by_sql == '"annee"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_global_without_groupby():
|
||||||
|
spec = parse_aggregators([("uid__count", "")], SCHEMA)
|
||||||
|
assert spec.select_sql == 'COUNT("uid") AS "uid__count"'
|
||||||
|
assert spec.group_by_sql is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_unknown_column_raises():
|
||||||
|
with pytest.raises(FilterError):
|
||||||
|
parse_aggregators([("nope__count", "")], SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_where_ignores_aggregator_flags():
|
||||||
|
where, params, _ = build_where(
|
||||||
|
[("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")],
|
||||||
|
SCHEMA,
|
||||||
|
)
|
||||||
|
assert where == '"montant" >= ?'
|
||||||
|
assert params == [100.0]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_filters.py -k aggregator -v`
|
||||||
|
Expected: FAIL (`ImportError: cannot import name 'parse_aggregators'`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Ajouter constantes + dataclass + fonction**
|
||||||
|
|
||||||
|
Dans `src/api/filters.py`, ajouter en haut l'import `from dataclasses import dataclass` (après les imports existants), puis après `RESERVED_PARAMS` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
AGGREGATORS = {"groupby", "count", "sum", "avg", "min", "max"}
|
||||||
|
AGG_SQL = {"count": "COUNT", "sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AggregationSpec:
|
||||||
|
select_sql: str
|
||||||
|
group_by_sql: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_aggregators(
|
||||||
|
args: list[tuple[str, str]], schema: pl.Schema
|
||||||
|
) -> AggregationSpec | None:
|
||||||
|
"""Détecte les drapeaux d'agrégation (`col__groupby`, `col__count`, ...).
|
||||||
|
|
||||||
|
Retourne None si aucun agrégateur. Sinon, construit les fragments SQL
|
||||||
|
`select_sql` et `group_by_sql` (noms de colonnes validés contre le schéma).
|
||||||
|
"""
|
||||||
|
group_cols: list[str] = []
|
||||||
|
aggregates: list[tuple[str, str]] = [] # (operator, column)
|
||||||
|
has_agg = False
|
||||||
|
|
||||||
|
for key, _ in args:
|
||||||
|
parsed = _split_key(key)
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
col, op = parsed
|
||||||
|
if op not in AGGREGATORS:
|
||||||
|
continue
|
||||||
|
has_agg = True
|
||||||
|
if col not in schema:
|
||||||
|
raise FilterError(f"Colonne inconnue : {col!r}", field=key)
|
||||||
|
if op == "groupby":
|
||||||
|
group_cols.append(col)
|
||||||
|
else:
|
||||||
|
aggregates.append((op, col))
|
||||||
|
|
||||||
|
if not has_agg:
|
||||||
|
return None
|
||||||
|
|
||||||
|
select_parts = [f'"{c}"' for c in group_cols]
|
||||||
|
for op, col in aggregates:
|
||||||
|
select_parts.append(f'{AGG_SQL[op]}("{col}") AS "{col}__{op}"')
|
||||||
|
|
||||||
|
group_by_sql = ", ".join(f'"{c}"' for c in group_cols) if group_cols else None
|
||||||
|
return AggregationSpec(select_sql=", ".join(select_parts), group_by_sql=group_by_sql)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Faire ignorer les drapeaux d'agrégation par `build_where`**
|
||||||
|
|
||||||
|
Dans `src/api/filters.py`, dans `build_where`, juste après `col, op = parsed` (et avant `if op not in OPERATORS:`), ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if op in AGGREGATORS:
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer les tests, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_filters.py -v`
|
||||||
|
Expected: PASS (tous, anciens et nouveaux).
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/api/filters.py tests/api/test_filters.py
|
||||||
|
git commit -m "feat(api): parsing des opérateurs d'agrégation (groupby/count/sum/avg/min/max) (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4 : `aggregate_marches` dans la couche DB
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/db.py` (nouvelle fonction `aggregate_marches`)
|
||||||
|
- Test: `tests/api/test_db_aggregate.py` (créer)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `get_cursor()`, `logger` (existants dans `src/db.py`).
|
||||||
|
- Produces: `aggregate_marches(select_sql: str, where_sql: str = "TRUE", params: tuple | list = (), group_by: str | None = None, limit: int | None = None, offset: int | None = None) -> pl.DataFrame`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test (échec attendu)**
|
||||||
|
|
||||||
|
Créer `tests/api/test_db_aggregate.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.db import aggregate_marches
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_groupby_count_returns_named_columns():
|
||||||
|
df = aggregate_marches(
|
||||||
|
select_sql='"acheteur_departement_code", COUNT("uid") AS "uid__count"',
|
||||||
|
group_by='"acheteur_departement_code"',
|
||||||
|
)
|
||||||
|
assert isinstance(df, pl.DataFrame)
|
||||||
|
assert df.columns == ["acheteur_departement_code", "uid__count"]
|
||||||
|
assert df["uid__count"].sum() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_global_without_groupby_returns_one_row():
|
||||||
|
df = aggregate_marches(select_sql='COUNT("uid") AS "uid__count"')
|
||||||
|
assert df.height == 1
|
||||||
|
assert df["uid__count"][0] > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_db_aggregate.py -v`
|
||||||
|
Expected: FAIL (`ImportError: cannot import name 'aggregate_marches'`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Implémenter `aggregate_marches`**
|
||||||
|
|
||||||
|
Dans `src/db.py`, après `count_marches` (vers la ligne 186), ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def aggregate_marches(
|
||||||
|
select_sql: str,
|
||||||
|
where_sql: str = "TRUE",
|
||||||
|
params: tuple | list = (),
|
||||||
|
group_by: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""SELECT agrégé paramétré contre la table decp.
|
||||||
|
|
||||||
|
`select_sql` et `group_by` sont des fragments SQL construits depuis des
|
||||||
|
noms de colonnes validés (jamais de valeur utilisateur libre). Les
|
||||||
|
valeurs de filtre passent par le binding `?` via `params`.
|
||||||
|
"""
|
||||||
|
sql = f"SELECT {select_sql} FROM decp WHERE {where_sql}"
|
||||||
|
if group_by:
|
||||||
|
sql += f" GROUP BY {group_by}"
|
||||||
|
if limit is not None:
|
||||||
|
sql += f" LIMIT {int(limit)}"
|
||||||
|
if offset is not None:
|
||||||
|
sql += f" OFFSET {int(offset)}"
|
||||||
|
|
||||||
|
logger.debug("aggregate_marches: " + sql.replace("?", "{}").format(*params))
|
||||||
|
|
||||||
|
return get_cursor().execute(sql, list(params)).pl()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_db_aggregate.py -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/db.py tests/api/test_db_aggregate.py
|
||||||
|
git commit -m "feat(db): aggregate_marches pour les requêtes GROUP BY (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5 : Orchestration du mode agrégation dans la route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/api/routes.py` (fonction `data()`)
|
||||||
|
- Test: `tests/api/test_endpoints_data.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `parse_aggregators` (Task 3), `aggregate_marches` (Task 4), `build_where` (existant), `AggregationSpec`.
|
||||||
|
- Produces: l'endpoint `/api/v1/data` renvoie des lignes agrégées quand un opérateur d'agrégation est présent ; `meta` sans `total` ; `columns` + agrégation → 400.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests d'endpoint (échec attendu)**
|
||||||
|
|
||||||
|
Dans `tests/api/test_endpoints_data.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_data_aggregation_groupby_count(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?acheteur_departement_code__groupby&uid__count",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["data"], "agrégation vide ?"
|
||||||
|
for row in body["data"]:
|
||||||
|
assert set(row.keys()) == {"acheteur_departement_code", "uid__count"}
|
||||||
|
assert "total" not in body["meta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_aggregation_global_count(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data?uid__count", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert len(body["data"]) == 1
|
||||||
|
assert "uid__count" in body["data"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_aggregation_with_filter(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?acheteur_departement_code__groupby&uid__count&montant__greater=0",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_aggregation_with_columns_returns_400(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?uid__count&columns=uid",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_endpoints_data.py -k aggregation -v`
|
||||||
|
Expected: FAIL (les drapeaux d'agrégation sont ignorés → réponse non agrégée, clés inattendues / pas de 400).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Mettre à jour les imports de la route**
|
||||||
|
|
||||||
|
Dans `src/api/routes.py`, remplacer la ligne d'import des filtres :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.api.filters import FilterError, build_where
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.api.filters import FilterError, build_where, parse_aggregators
|
||||||
|
from src.db import aggregate_marches
|
||||||
|
```
|
||||||
|
|
||||||
|
(et conserver l'import existant `from src.db import count_marches, query_marches`).
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Brancher le chemin agrégation dans `data()`**
|
||||||
|
|
||||||
|
Dans `src/api/routes.py`, fonction `data()`, remplacer le bloc qui va de `try:` (parsing `build_where`) jusqu'au `return {...}` final par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
args = list(request.args.items(multi=True))
|
||||||
|
try:
|
||||||
|
agg = parse_aggregators(args, duckdb_schema)
|
||||||
|
where_sql, params, order_sql = build_where(args, duckdb_schema)
|
||||||
|
except FilterError as e:
|
||||||
|
abort(400, message=str(e), errors={"field": e.field})
|
||||||
|
|
||||||
|
if agg is not None:
|
||||||
|
if columns:
|
||||||
|
abort(
|
||||||
|
400,
|
||||||
|
message="`columns` ne peut pas être combiné avec une agrégation",
|
||||||
|
)
|
||||||
|
df = aggregate_marches(
|
||||||
|
select_sql=agg.select_sql,
|
||||||
|
where_sql=where_sql,
|
||||||
|
params=params,
|
||||||
|
group_by=agg.group_by_sql,
|
||||||
|
limit=page_size,
|
||||||
|
offset=(page - 1) * page_size,
|
||||||
|
)
|
||||||
|
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||||
|
return {
|
||||||
|
"data": df_ready.to_dicts(),
|
||||||
|
"meta": {"page": page, "page_size": page_size},
|
||||||
|
"links": _build_links(page, page_size, None),
|
||||||
|
}
|
||||||
|
|
||||||
|
df = query_marches(
|
||||||
|
where_sql=where_sql,
|
||||||
|
params=params,
|
||||||
|
columns=columns,
|
||||||
|
order_by=order_sql,
|
||||||
|
limit=page_size,
|
||||||
|
offset=(page - 1) * page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON ne sérialise pas date/datetime nativement → cast en string ISO
|
||||||
|
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||||
|
|
||||||
|
total = count_marches(where_sql, params) if count_results else None
|
||||||
|
meta = {"page": page, "page_size": page_size}
|
||||||
|
if total is not None:
|
||||||
|
meta["total"] = total
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": df_ready.to_dicts(),
|
||||||
|
"meta": meta,
|
||||||
|
"links": _build_links(page, page_size, total),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Note : `count_results` provient de la Task 1 ; `columns`, `page`, `page_size` sont déjà calculés plus haut dans la fonction.)
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer les tests d'endpoint, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_endpoints_data.py -v`
|
||||||
|
Expected: PASS (tous).
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/api/routes.py tests/api/test_endpoints_data.py
|
||||||
|
git commit -m "feat(api): mode agrégation sur /data (groupby + agrégats) (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6 : Documentation Swagger des mots-clés
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/api/routes.py` (bloc `@bp.doc` du param dynamique + docstring de `data()`)
|
||||||
|
- Test: `tests/api/test_openapi_doc.py` (créer)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: l'OpenAPI généré, servi sur `/api/v1/openapi.json`.
|
||||||
|
- Produces: la description du paramètre `<colonne>__<opérateur>` liste tous les opérateurs (filtres + agrégation) avec une définition d'une ligne chacun, et décrit le mode agrégation.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test (échec attendu)**
|
||||||
|
|
||||||
|
Créer `tests/api/test_openapi_doc.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_openapi_documents_new_keywords(api_client):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/openapi.json")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
raw = resp.get_data(as_text=True)
|
||||||
|
for keyword in ["count_results", "differs", "groupby", "__sum", "__avg", "__min", "__max"]:
|
||||||
|
assert keyword in raw, f"{keyword} absent de la doc OpenAPI"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_openapi_doc.py -v`
|
||||||
|
Expected: FAIL (`differs`, `groupby`, etc. absents de la description).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Étoffer la description du paramètre dynamique**
|
||||||
|
|
||||||
|
Dans `src/api/routes.py`, remplacer la `description` du paramètre `<colonne>__<opérateur>` par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
"description": (
|
||||||
|
"Filtre ou agrégation dynamique : `<colonne>__<opérateur>` "
|
||||||
|
"(voir les colonnes via `/schema`).\n\n"
|
||||||
|
"**Filtres** (`<colonne>__<op>=<valeur>`) :\n"
|
||||||
|
"- `exact` : égal à la valeur\n"
|
||||||
|
"- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n"
|
||||||
|
"- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n"
|
||||||
|
"- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n"
|
||||||
|
"- `less` / `greater` : ≤ / ≥\n"
|
||||||
|
"- `strictly_less` / `strictly_greater` : < / >\n"
|
||||||
|
"- `isnull` / `isnotnull` : valeur nulle / non nulle (sans valeur)\n"
|
||||||
|
"- `sort` : tri, valeur `asc` ou `desc`\n\n"
|
||||||
|
"**Agrégation** (drapeaux sans valeur, ex. `acheteur_departement_code__groupby&montant__sum`) :\n"
|
||||||
|
"- `groupby` : regroupe sur la colonne\n"
|
||||||
|
"- `count`, `sum`, `avg`, `min`, `max` : agrège la colonne ; "
|
||||||
|
"la colonne de sortie est nommée `colonne__count`, `colonne__sum`, "
|
||||||
|
"`colonne__avg`, `colonne__min`, `colonne__max`\n\n"
|
||||||
|
"En mode agrégation, la réponse contient des lignes groupées, "
|
||||||
|
"`columns` est interdit et `meta` ne contient pas `total`.\n\n"
|
||||||
|
"Exemples : `acheteur_id__contains=VILLE`, `montant__greater=10000`, "
|
||||||
|
"`acheteur_departement_code__groupby&montant__sum`."
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Mettre à jour le docstring de `data()`**
|
||||||
|
|
||||||
|
Dans `src/api/routes.py`, remplacer le docstring de `data()` par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Récupère des marchés publics filtrés, triés ou agrégés.
|
||||||
|
|
||||||
|
Filtres en query string : `<colonne>__<opérateur>=<valeur>`.
|
||||||
|
Opérateurs de filtre : exact, differs, contains, notcontains, in, notin,
|
||||||
|
less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort.
|
||||||
|
|
||||||
|
Agrégation (drapeaux sans valeur) : `<colonne>__groupby`,
|
||||||
|
`<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées
|
||||||
|
`<colonne>__<opérateur>`. `columns` est interdit avec une agrégation et
|
||||||
|
`meta` ne contient alors pas `total`.
|
||||||
|
|
||||||
|
Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000),
|
||||||
|
columns (csv), count_results (true|false ; mettre false pour économiser
|
||||||
|
le COUNT(*)).
|
||||||
|
|
||||||
|
Exemple d'agrégation :
|
||||||
|
`?acheteur_departement_code__groupby&uid__count&montant__sum`
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/test_openapi_doc.py -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Vérifier la non-régression complète de l'API**
|
||||||
|
|
||||||
|
Run: `rtk pytest tests/api/ -v`
|
||||||
|
Expected: PASS (tous).
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/api/routes.py tests/api/test_openapi_doc.py
|
||||||
|
git commit -m "docs(api): documente les opérateurs (filtres + agrégation) dans Swagger (#78)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes de vérification de référence (manuel, hors tests automatisés)
|
||||||
|
|
||||||
|
Après implémentation, vérifier que quelques requêtes d'agrégation renvoient
|
||||||
|
des valeurs cohérentes avec data.gouv.fr sur la même ressource DECP
|
||||||
|
(`22847056-61df-452d-837d-8b8ceadbfc52`), aux différences de fraîcheur près :
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/data?acheteur_departement_code__groupby&uid__count&montant__sum
|
||||||
|
```
|
||||||
|
|
||||||
|
à comparer à :
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/data/?acheteur_departement_code__groupby&uid__count&montant__sum
|
||||||
|
```
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
# Tuile « Considérations sociales et environnementales » — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ajouter dans `/observatoire`, juste après la tuile « Type d'achat », une tuile montrant via deux barres de progression la part des marchés filtrés comportant au moins une considération sociale (rouge) et au moins une considération environnementale (vert).
|
||||||
|
|
||||||
|
**Architecture:** Une fonction pure de calcul (`compute_considerations_stats`) dans `src/figures.py`, testée directement, alimente une fonction de rendu (`get_considerations_card_content`) qui produit un `html.Div` de deux `dbc.Progress`. La tuile est ajoutée dans `_compute_dashboard_children` via le `make_card` existant.
|
||||||
|
|
||||||
|
**Tech Stack:** Polars (LazyFrame), Dash / Dash Bootstrap Components (`dbc.Progress`), pytest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Imports internes toujours préfixés `src.` (ex. `from src.figures import ...`), jamais `figures` ou `utils`.
|
||||||
|
- Dédoublonnage **par `uid`** : un marché compté une fois (première valeur par `uid`).
|
||||||
|
- « Au moins une considération » = la valeur de colonne **contient** (insensible casse) `Clause`, `Critère` ou `Marché réservé`. Regex exacte : `(?i)Clause|Critère|Marché réservé`.
|
||||||
|
- Dénominateur = **tous** les marchés filtrés (uid distincts), y compris `Sans objet` et non renseignés.
|
||||||
|
- `pct = round(100 * numérateur / dénominateur)` ; si dénominateur = 0 → `pct = 0`.
|
||||||
|
- Couleurs issues de `px.colors.qualitative.Safe` : sociales = `rgb(204, 102, 119)` (index 1, rouge) ; environnementales = `rgb(17, 119, 51)` (index 3, vert).
|
||||||
|
- Robustesse : si une colonne `considerations*` est absente du schéma, son `(count, pct)` vaut `(0, 0)` sans exception.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Fonction de calcul `compute_considerations_stats`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/figures.py` (ajouter la fonction après `get_dashboard_summary_table`, ~ ligne 729)
|
||||||
|
- Test: `tests/test_figures.py` (créer)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: rien (fonction pure prenant un `pl.LazyFrame`).
|
||||||
|
- Produces: `compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]` renvoyant `{"sociales": (count, pct), "environnementales": (count, pct)}` où `count` = nombre de marchés (uid distincts) avec au moins une considération et `pct` = pourcentage entier sur le total des uid distincts.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Créer `tests/test_figures.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
|
||||||
|
def _make_lff(rows):
|
||||||
|
return pl.LazyFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_basic():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = _make_lff(
|
||||||
|
[
|
||||||
|
# uid u1 : social oui (Clause), env non (Sans objet)
|
||||||
|
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||||
|
# uid u2 : social non (Sans objet), env oui (Critère)
|
||||||
|
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
|
||||||
|
# uid u3 : social oui (Marché réservé compte), env null
|
||||||
|
{"uid": "u3", "considerationsSociales": "Marché réservé", "considerationsEnvironnementales": None},
|
||||||
|
# uid u4 : aucune considération
|
||||||
|
{"uid": "u4", "considerationsSociales": "Pas de considération sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
# 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%.
|
||||||
|
assert stats["sociales"] == (2, 50)
|
||||||
|
assert stats["environnementales"] == (1, 25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_dedup_per_uid():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = _make_lff(
|
||||||
|
[
|
||||||
|
# uid u1 présent 2 fois (2 titulaires) -> compté une seule fois
|
||||||
|
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||||
|
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||||
|
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Sans objet"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
# 2 marchés distincts. Social : u1 -> 1/2 = 50%.
|
||||||
|
assert stats["sociales"] == (1, 50)
|
||||||
|
assert stats["environnementales"] == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_missing_column():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = _make_lff(
|
||||||
|
[
|
||||||
|
{"uid": "u1", "considerationsSociales": "Clause sociale"},
|
||||||
|
{"uid": "u2", "considerationsSociales": "Sans objet"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
# Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%.
|
||||||
|
assert stats["sociales"] == (1, 50)
|
||||||
|
assert stats["environnementales"] == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_empty():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = pl.LazyFrame(
|
||||||
|
{
|
||||||
|
"uid": pl.Series([], dtype=pl.String),
|
||||||
|
"considerationsSociales": pl.Series([], dtype=pl.String),
|
||||||
|
"considerationsEnvironnementales": pl.Series([], dtype=pl.String),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
assert stats["sociales"] == (0, 0)
|
||||||
|
assert stats["environnementales"] == (0, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_figures.py -v`
|
||||||
|
Expected: FAIL avec `ImportError: cannot import name 'compute_considerations_stats'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Dans `src/figures.py`, ajouter après `get_dashboard_summary_table` (avant `make_card`) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé"
|
||||||
|
CONSIDERATIONS_COLUMNS = {
|
||||||
|
"sociales": "considerationsSociales",
|
||||||
|
"environnementales": "considerationsEnvironnementales",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]:
|
||||||
|
"""Part des marchés (uid distincts) ayant au moins une considération.
|
||||||
|
|
||||||
|
Renvoie {"sociales": (count, pct), "environnementales": (count, pct)}.
|
||||||
|
Dénominateur = tous les uid distincts. Colonne absente -> (0, 0).
|
||||||
|
"""
|
||||||
|
names = lff.collect_schema().names()
|
||||||
|
present = {
|
||||||
|
key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = {key: (0, 0) for key in CONSIDERATIONS_COLUMNS}
|
||||||
|
|
||||||
|
if not present:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
agg = (
|
||||||
|
lff.select(["uid"] + list(present.values()))
|
||||||
|
.group_by("uid")
|
||||||
|
.agg([pl.col(col).first() for col in present.values()])
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
total = agg.height
|
||||||
|
if total == 0:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
for key, col in present.items():
|
||||||
|
count = agg.filter(
|
||||||
|
pl.col(col).str.contains(CONSIDERATIONS_REGEX)
|
||||||
|
).height
|
||||||
|
pct = round(100 * count / total)
|
||||||
|
stats[key] = (count, pct)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_figures.py -v`
|
||||||
|
Expected: 4 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/figures.py tests/test_figures.py
|
||||||
|
git commit -m "feat(observatoire): calcul part marchés avec considération sociale/env
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Rendu de la tuile `get_considerations_card_content`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/figures.py` (ajouter après `compute_considerations_stats`)
|
||||||
|
- Test: `tests/test_figures.py` (ajouter au fichier de la Task 1)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `compute_considerations_stats(lff) -> dict[str, tuple[int, int]]` (Task 1) ; `format_number` (déjà importé en tête de `src/figures.py` : `from src.utils.table import add_links, format_number, setup_table_columns`).
|
||||||
|
- Produces: `get_considerations_card_content(lff: pl.LazyFrame) -> html.Div` : un `html.Div` contenant deux blocs (sociales, environnementales), chacun avec un libellé, un `dbc.Progress` coloré rempli au pourcentage, et le nombre de marchés.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Ajouter à `tests/test_figures.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_get_considerations_card_content_returns_two_progress_bars():
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
from dash import html
|
||||||
|
|
||||||
|
from src.figures import get_considerations_card_content
|
||||||
|
|
||||||
|
lff = pl.LazyFrame(
|
||||||
|
[
|
||||||
|
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||||
|
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
div = get_considerations_card_content(lff)
|
||||||
|
|
||||||
|
assert isinstance(div, html.Div)
|
||||||
|
|
||||||
|
# Récupère récursivement tous les dbc.Progress
|
||||||
|
def find_progress(component, found):
|
||||||
|
children = getattr(component, "children", None)
|
||||||
|
if isinstance(component, dbc.Progress):
|
||||||
|
found.append(component)
|
||||||
|
if isinstance(children, (list, tuple)):
|
||||||
|
for c in children:
|
||||||
|
find_progress(c, found)
|
||||||
|
elif children is not None:
|
||||||
|
find_progress(children, found)
|
||||||
|
return found
|
||||||
|
|
||||||
|
bars = find_progress(div, [])
|
||||||
|
assert len(bars) == 2
|
||||||
|
|
||||||
|
# Sociales (rouge) : u1 -> 50%. Environnementales (vert) : u2 -> 50%.
|
||||||
|
social_bar, env_bar = bars[0], bars[1]
|
||||||
|
assert social_bar.value == 50
|
||||||
|
assert social_bar.label == "50 %"
|
||||||
|
assert social_bar.style["backgroundColor"] == "rgb(204, 102, 119)"
|
||||||
|
assert env_bar.value == 50
|
||||||
|
assert env_bar.label == "50 %"
|
||||||
|
assert env_bar.style["backgroundColor"] == "rgb(17, 119, 51)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_figures.py::test_get_considerations_card_content_returns_two_progress_bars -v`
|
||||||
|
Expected: FAIL avec `ImportError: cannot import name 'get_considerations_card_content'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Dans `src/figures.py`, ajouter après `compute_considerations_stats` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
CONSIDERATIONS_DISPLAY = [
|
||||||
|
# (clé, libellé, couleur Safe)
|
||||||
|
("sociales", "Sociales", "rgb(204, 102, 119)"),
|
||||||
|
("environnementales", "Environnementales", "rgb(17, 119, 51)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div:
|
||||||
|
"""Deux barres de progression : part des marchés avec considération."""
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
for key, label, color in CONSIDERATIONS_DISPLAY:
|
||||||
|
count, pct = stats[key]
|
||||||
|
blocks.append(
|
||||||
|
html.Div(
|
||||||
|
className="mb-3",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="d-flex justify-content-between",
|
||||||
|
children=[
|
||||||
|
html.Span(label),
|
||||||
|
html.Span(
|
||||||
|
f"{format_number(count)} marchés",
|
||||||
|
className="text-muted",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dbc.Progress(
|
||||||
|
value=pct,
|
||||||
|
label=f"{pct} %",
|
||||||
|
style={"backgroundColor": color},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return html.Div(children=blocks)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_figures.py -v`
|
||||||
|
Expected: tous les tests PASS (5 au total).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/figures.py tests/test_figures.py
|
||||||
|
git commit -m "feat(observatoire): tuile considérations en barres de progression
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Intégration dans l'Observatoire
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/pages/observatoire.py` (import ~ lignes 20-31 ; appel dans `_compute_dashboard_children` après le bloc « Type d'achat », ~ ligne 723)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `get_considerations_card_content(lff) -> html.Div` (Task 2) ; `make_card` (déjà importé).
|
||||||
|
- Produces: une nouvelle `dbc.Col` (card) insérée dans la liste `cards` entre « Type d'achat » et « Distance acheteur–titulaire ».
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter l'import**
|
||||||
|
|
||||||
|
Dans `src/pages/observatoire.py`, dans le bloc `from src.figures import (...)` (lignes 20-31), ajouter `get_considerations_card_content` en respectant l'ordre alphabétique existant (après `get_barchart_sources`) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.figures import (
|
||||||
|
DataTable,
|
||||||
|
get_barchart_sources,
|
||||||
|
get_considerations_card_content,
|
||||||
|
get_dashboard_summary_table,
|
||||||
|
get_distance_histogram,
|
||||||
|
get_duplicate_matrix,
|
||||||
|
get_geographic_maps,
|
||||||
|
get_top_org_table,
|
||||||
|
make_card,
|
||||||
|
make_column_picker,
|
||||||
|
make_donut,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insérer la tuile après « Type d'achat »**
|
||||||
|
|
||||||
|
Dans `_compute_dashboard_children`, juste après le `cards.append(...)` du donut « Type d'achat » (qui se termine ligne ~723) et avant `distance_histogram = ...`, insérer :
|
||||||
|
|
||||||
|
```python
|
||||||
|
considerations_content = get_considerations_card_content(lff)
|
||||||
|
cards.append(
|
||||||
|
make_card(
|
||||||
|
title="Considérations sociales et environnementales",
|
||||||
|
subtitle="part des marchés concernés",
|
||||||
|
fig=considerations_content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le bloc résultant doit ressembler à :
|
||||||
|
|
||||||
|
```python
|
||||||
|
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
|
||||||
|
cards.append(
|
||||||
|
make_card(
|
||||||
|
title="Type d'achat",
|
||||||
|
subtitle="en nombre de marchés attribués",
|
||||||
|
fig=donut_marche_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
considerations_content = get_considerations_card_content(lff)
|
||||||
|
cards.append(
|
||||||
|
make_card(
|
||||||
|
title="Considérations sociales et environnementales",
|
||||||
|
subtitle="part des marchés concernés",
|
||||||
|
fig=considerations_content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
distance_histogram = get_distance_histogram(lff)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier que la page se charge (test d'import/rendu)**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_page_loads.py -v`
|
||||||
|
Expected: PASS (aucune régression sur le chargement des pages). Si `tests/test_page_loads.py` ne couvre pas `/observatoire`, lancer en complément :
|
||||||
|
|
||||||
|
Run: `uv run python -c "import src.pages.observatoire"`
|
||||||
|
Expected: aucune erreur d'import.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lancer l'ensemble de la suite figures + observatoire**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/test_figures.py -v`
|
||||||
|
Expected: tous PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/observatoire.py
|
||||||
|
git commit -m "feat(observatoire): afficher la tuile considérations après Type d'achat
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage :**
|
||||||
|
|
||||||
|
- Définition « au moins une considération » (regex incl. `Marché réservé`) → Task 1, `CONSIDERATIONS_REGEX`, tests `basic`.
|
||||||
|
- Dédoublonnage par `uid` → Task 1, test `dedup_per_uid`.
|
||||||
|
- Dénominateur = tous les marchés filtrés → Task 1 (`total = agg.height`), tests.
|
||||||
|
- Colonne absente → 0 % → Task 1, test `missing_column` ; cas vide → test `empty`.
|
||||||
|
- Deux barres `dbc.Progress`, couleurs Safe rouge/vert, labels `XX %` + `N marchés` → Task 2.
|
||||||
|
- Insertion après « Type d'achat », dimensions par défaut `make_card` → Task 3.
|
||||||
|
- Hors périmètre (pas de tooltip, pas de filtre) → respecté, rien d'ajouté.
|
||||||
|
|
||||||
|
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni.
|
||||||
|
|
||||||
|
**Type consistency :** `compute_considerations_stats` renvoie `dict[str, tuple[int, int]]` clés `sociales`/`environnementales`, consommé tel quel par `get_considerations_card_content` (Task 2) ; `get_considerations_card_content` renvoie `html.Div`, passé à `make_card(fig=...)` (Task 3). Cohérent.
|
||||||
@@ -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).
|
||||||
+7
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decp.info"
|
name = "decp.info"
|
||||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||||
version = "2.7.4"
|
version = "2.8.1"
|
||||||
requires-python = ">= 3.10"
|
requires-python = ">= 3.10"
|
||||||
authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }]
|
authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -21,6 +21,9 @@ dependencies = [
|
|||||||
"duckdb",
|
"duckdb",
|
||||||
"flask-caching",
|
"flask-caching",
|
||||||
"pyarrow>=23.0.1",
|
"pyarrow>=23.0.1",
|
||||||
|
"flask-cors>=6.0.2",
|
||||||
|
"flask-smorest>=0.46.0",
|
||||||
|
"marshmallow>=3.20.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -42,5 +45,8 @@ env = [
|
|||||||
"DEVELOPMENT=true",
|
"DEVELOPMENT=true",
|
||||||
"REBUILD_DUCKDB=true",
|
"REBUILD_DUCKDB=true",
|
||||||
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
|
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
|
||||||
|
"USERS_DB_PATH=tests/users.test.sqlite",
|
||||||
|
"MATOMO_TRACKING_ENABLED=false",
|
||||||
|
"DATA_SCHEMA_LOCAL=/home/colin/git/decp-processing/dist/schema.json",
|
||||||
]
|
]
|
||||||
addopts = "-p no:warnings"
|
addopts = "-p no:warnings"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
from src.app import app
|
from src.app import app
|
||||||
|
|
||||||
# To use `gunicorn run:server` (prod)
|
# To use `gunicorn run:server` (prod)
|
||||||
server = app.server
|
server = app.server
|
||||||
|
CORS(server)
|
||||||
|
|
||||||
# To use `python run.py` (dev)
|
# To use `python run.py` (dev)
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from flask_smorest import Api
|
||||||
|
|
||||||
|
from src.api import routes
|
||||||
|
|
||||||
|
|
||||||
|
def init_api(server) -> None:
|
||||||
|
"""Enregistre le blueprint d'API privée sur le serveur Flask."""
|
||||||
|
server.config.setdefault("API_TITLE", "decp.info API")
|
||||||
|
server.config.setdefault("API_VERSION", "v1")
|
||||||
|
server.config.setdefault("OPENAPI_VERSION", "3.0.3")
|
||||||
|
server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1")
|
||||||
|
server.config.setdefault("OPENAPI_JSON_PATH", "openapi.json")
|
||||||
|
server.config.setdefault("OPENAPI_SWAGGER_UI_PATH", "swagger")
|
||||||
|
server.config.setdefault(
|
||||||
|
"OPENAPI_SWAGGER_UI_URL",
|
||||||
|
"https://cdn.jsdelivr.net/npm/swagger-ui-dist/",
|
||||||
|
)
|
||||||
|
server.config.setdefault(
|
||||||
|
"API_SPEC_OPTIONS",
|
||||||
|
{
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {"BearerAuth": {"type": "http", "scheme": "bearer"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
api = Api(server)
|
||||||
|
api.register_blueprint(routes.bp)
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from src.api import tracking
|
||||||
|
|
||||||
|
tracking.start_worker(os.environ["USERS_DB_PATH"])
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import abort, g, jsonify, make_response, request
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
API_AUTH_DISABLED = os.getenv("API_AUTH_DISABLED", "False").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _abort_401(message: str):
|
||||||
|
resp = make_response(jsonify({"message": message}), 401)
|
||||||
|
abort(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def require_token(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not API_AUTH_DISABLED:
|
||||||
|
header = request.headers.get("Authorization", "")
|
||||||
|
if not header.startswith("Bearer "):
|
||||||
|
_abort_401("missing_token")
|
||||||
|
token = header[len("Bearer ") :].strip()
|
||||||
|
if not token:
|
||||||
|
_abort_401("missing_token")
|
||||||
|
db_path = os.environ["USERS_DB_PATH"]
|
||||||
|
row = tokens_db.get_token_by_plaintext(db_path, token)
|
||||||
|
if row is None:
|
||||||
|
_abort_401("invalid_token")
|
||||||
|
if row["revoked_at"] is not None:
|
||||||
|
_abort_401("revoked_token")
|
||||||
|
g.token_id = row["id"]
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
OPERATORS = {
|
||||||
|
"exact",
|
||||||
|
"contains",
|
||||||
|
"notcontains",
|
||||||
|
"differs",
|
||||||
|
"less",
|
||||||
|
"greater",
|
||||||
|
"strictly_less",
|
||||||
|
"strictly_greater",
|
||||||
|
"in",
|
||||||
|
"notin",
|
||||||
|
"isnull",
|
||||||
|
"isnotnull",
|
||||||
|
"sort",
|
||||||
|
}
|
||||||
|
|
||||||
|
RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"}
|
||||||
|
|
||||||
|
AGGREGATORS = {"groupby", "count", "sum", "avg", "min", "max"}
|
||||||
|
AGG_SQL = {"count": "COUNT", "sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AggregationSpec:
|
||||||
|
select_sql: str
|
||||||
|
group_by_sql: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_aggregators(
|
||||||
|
args: list[tuple[str, str]], schema: pl.Schema
|
||||||
|
) -> "AggregationSpec | None":
|
||||||
|
"""Détecte les drapeaux d'agrégation (`col__groupby`, `col__count`, ...).
|
||||||
|
|
||||||
|
Retourne None si aucun agrégateur. Sinon, construit les fragments SQL
|
||||||
|
`select_sql` et `group_by_sql` (noms de colonnes validés contre le schéma).
|
||||||
|
"""
|
||||||
|
group_cols: list[str] = []
|
||||||
|
aggregates: list[tuple[str, str]] = [] # (operator, column)
|
||||||
|
has_agg = False
|
||||||
|
|
||||||
|
for key, _ in args:
|
||||||
|
parsed = _split_key(key)
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
col, op = parsed
|
||||||
|
if op not in AGGREGATORS:
|
||||||
|
continue
|
||||||
|
has_agg = True
|
||||||
|
if col not in schema:
|
||||||
|
raise FilterError(f"Colonne inconnue : {col!r}", field=key)
|
||||||
|
if op == "groupby":
|
||||||
|
group_cols.append(col)
|
||||||
|
else:
|
||||||
|
aggregates.append((op, col))
|
||||||
|
|
||||||
|
if not has_agg:
|
||||||
|
return None
|
||||||
|
|
||||||
|
select_parts = [f'"{c}"' for c in group_cols]
|
||||||
|
for op, col in aggregates:
|
||||||
|
select_parts.append(f'{AGG_SQL[op]}("{col}") AS "{col}__{op}"')
|
||||||
|
|
||||||
|
group_by_sql = ", ".join(f'"{c}"' for c in group_cols) if group_cols else None
|
||||||
|
return AggregationSpec(
|
||||||
|
select_sql=", ".join(select_parts), group_by_sql=group_by_sql
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterError(ValueError):
|
||||||
|
def __init__(self, message: str, field: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(value: str, dtype: pl.DataType, key: str):
|
||||||
|
if dtype == pl.String:
|
||||||
|
return value
|
||||||
|
if dtype.is_integer():
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
raise FilterError(f"Valeur entière attendue, reçu {value!r}", field=key)
|
||||||
|
if dtype.is_float():
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
raise FilterError(f"Valeur décimale attendue, reçu {value!r}", field=key)
|
||||||
|
if dtype == pl.Date:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
raise FilterError(
|
||||||
|
f"Date ISO 8601 attendue (YYYY-MM-DD), reçu {value!r}", field=key
|
||||||
|
)
|
||||||
|
if dtype == pl.Datetime:
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
raise FilterError(f"Datetime ISO 8601 attendu, reçu {value!r}", field=key)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _split_key(key: str) -> tuple[str, str] | None:
|
||||||
|
if "__" not in key:
|
||||||
|
return None
|
||||||
|
col, _, op = key.rpartition("__")
|
||||||
|
if not col or not op:
|
||||||
|
return None
|
||||||
|
return col, op
|
||||||
|
|
||||||
|
|
||||||
|
def build_where(
|
||||||
|
args: list[tuple[str, str]], schema: pl.Schema
|
||||||
|
) -> tuple[str, list, str | None]:
|
||||||
|
"""Parse query params into (where_sql, params, order_by_sql).
|
||||||
|
|
||||||
|
args: list of (key, value) tuples preserving URL order (Werkzeug MultiDict
|
||||||
|
preserves insertion order on `request.args.items(multi=True)`).
|
||||||
|
"""
|
||||||
|
where_parts: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
order_parts: list[str] = []
|
||||||
|
|
||||||
|
for key, value in args:
|
||||||
|
if key in RESERVED_PARAMS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = _split_key(key)
|
||||||
|
if not parsed:
|
||||||
|
raise FilterError(f"Paramètre non reconnu : {key}", field=key)
|
||||||
|
col, op = parsed
|
||||||
|
|
||||||
|
if op in AGGREGATORS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if op not in OPERATORS:
|
||||||
|
raise FilterError(f"Opérateur inconnu : __{op}", field=key)
|
||||||
|
|
||||||
|
if col not in schema:
|
||||||
|
raise FilterError(f"Colonne inconnue : {col!r}", field=key)
|
||||||
|
|
||||||
|
if op == "sort":
|
||||||
|
direction = value.lower()
|
||||||
|
if direction not in ("asc", "desc"):
|
||||||
|
raise FilterError(
|
||||||
|
f"Tri attendu 'asc' ou 'desc', reçu {value!r}", field=key
|
||||||
|
)
|
||||||
|
order_parts.append(f'"{col}" {direction.upper()}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if op in ("isnull", "isnotnull"):
|
||||||
|
sql = "IS NULL" if op == "isnull" else "IS NOT NULL"
|
||||||
|
where_parts.append(f'"{col}" {sql}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
dtype = schema[col]
|
||||||
|
|
||||||
|
if op in ("in", "notin"):
|
||||||
|
values = [_coerce(v.strip(), dtype, key) for v in value.split(",")]
|
||||||
|
placeholders = ",".join(["?"] * len(values))
|
||||||
|
sql_op = "IN" if op == "in" else "NOT IN"
|
||||||
|
where_parts.append(f'"{col}" {sql_op} ({placeholders})')
|
||||||
|
params.extend(values)
|
||||||
|
continue
|
||||||
|
|
||||||
|
v = _coerce(value, dtype, key)
|
||||||
|
|
||||||
|
op_sql = {
|
||||||
|
"exact": "=",
|
||||||
|
"less": "<=",
|
||||||
|
"greater": ">=",
|
||||||
|
"strictly_less": "<",
|
||||||
|
"strictly_greater": ">",
|
||||||
|
}
|
||||||
|
if op in op_sql:
|
||||||
|
where_parts.append(f'"{col}" {op_sql[op]} ?')
|
||||||
|
params.append(v)
|
||||||
|
elif op == "contains":
|
||||||
|
where_parts.append(f'"{col}" LIKE ?')
|
||||||
|
params.append(f"%{v}%")
|
||||||
|
elif op == "notcontains":
|
||||||
|
where_parts.append(f'"{col}" NOT LIKE ?')
|
||||||
|
params.append(f"%{v}%")
|
||||||
|
elif op == "differs":
|
||||||
|
where_parts.append(f'"{col}" IS DISTINCT FROM ?')
|
||||||
|
params.append(v)
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
|
||||||
|
order_sql = ", ".join(order_parts) if order_parts else None
|
||||||
|
return where_sql, params, order_sql
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
from flask import g, request
|
||||||
|
from flask_smorest import Blueprint, abort
|
||||||
|
|
||||||
|
from src.api import tracking
|
||||||
|
from src.api.auth import require_token
|
||||||
|
from src.api.filters import FilterError, build_where, parse_aggregators
|
||||||
|
from src.db import aggregate_marches, count_marches, query_marches
|
||||||
|
from src.db import schema as duckdb_schema
|
||||||
|
from src.utils.data import DATA_SCHEMA
|
||||||
|
|
||||||
|
bp = Blueprint(
|
||||||
|
"api_v1",
|
||||||
|
"api_v1",
|
||||||
|
url_prefix="/api/v1",
|
||||||
|
description="API privée decp.info — accès tabulaire aux marchés publics.",
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_PAGE_SIZE = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pagination():
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page", "1"))
|
||||||
|
page_size = int(request.args.get("page_size", "50"))
|
||||||
|
except ValueError:
|
||||||
|
abort(400, message="page et page_size doivent être des entiers")
|
||||||
|
if page < 1:
|
||||||
|
abort(400, message="page doit être >= 1")
|
||||||
|
if page_size < 1 or page_size > MAX_PAGE_SIZE:
|
||||||
|
abort(
|
||||||
|
400,
|
||||||
|
message=f"page_size doit être dans [1, {MAX_PAGE_SIZE}]",
|
||||||
|
)
|
||||||
|
return page, page_size
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_columns():
|
||||||
|
raw = request.args.get("columns")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
cols = [c.strip() for c in raw.split(",") if c.strip()]
|
||||||
|
unknown = [c for c in cols if c not in duckdb_schema]
|
||||||
|
if unknown:
|
||||||
|
abort(400, message=f"Colonnes inconnues : {unknown}")
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def _build_links(page, page_size, total):
|
||||||
|
base = request.path
|
||||||
|
qs = request.args.to_dict(flat=False)
|
||||||
|
qs.pop("page", None)
|
||||||
|
|
||||||
|
def url_for(p):
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
params = [(k, v) for k, vs in qs.items() for v in vs]
|
||||||
|
params.append(("page", str(p)))
|
||||||
|
return f"{base}?{urlencode(params)}"
|
||||||
|
|
||||||
|
prev_url = url_for(page - 1) if page > 1 else None
|
||||||
|
next_url = None
|
||||||
|
if total is None or page * page_size < total:
|
||||||
|
next_url = url_for(page + 1)
|
||||||
|
return {"prev": prev_url, "next": next_url}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.after_request
|
||||||
|
def _track_consumption(response):
|
||||||
|
token_id = getattr(g, "token_id", None)
|
||||||
|
if token_id is not None:
|
||||||
|
tracking.enqueue_counter_update(token_id)
|
||||||
|
tracking.enqueue_matomo_event(
|
||||||
|
token_id=token_id,
|
||||||
|
path=request.path,
|
||||||
|
query_string=request.query_string.decode("utf-8", errors="replace"),
|
||||||
|
status_code=response.status_code,
|
||||||
|
user_agent=request.headers.get("User-Agent", ""),
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/health")
|
||||||
|
def health():
|
||||||
|
"""Sonde de santé, sans authentification."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/schema")
|
||||||
|
def schema():
|
||||||
|
"""Liste des champs disponibles dans le dataset DECP (format TableSchema)."""
|
||||||
|
return {"fields": list(DATA_SCHEMA.values())}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/data")
|
||||||
|
@bp.doc(
|
||||||
|
security=[{"BearerAuth": []}],
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "integer", "default": 1, "minimum": 1},
|
||||||
|
"description": "Numéro de page (commence à 1).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "integer", "default": 50, "minimum": 1, "maximum": 1000},
|
||||||
|
"description": "Nombre de résultats par page (max 1000).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "columns",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
"description": "Liste de colonnes à retourner, séparées par des virgules (ex: `id,acheteur_id,montant`). Par défaut : toutes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "count_results",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "string", "enum": ["true", "false"], "default": "true"},
|
||||||
|
"description": "Inclure le total (`COUNT(*)`) dans `meta`. Mettre `false` pour accélérer la requête. Ignoré en mode agrégation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "<colonne>__<opérateur>",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
"description": (
|
||||||
|
"Filtre ou agrégation dynamique : `<colonne>__<opérateur>` "
|
||||||
|
"(voir les colonnes via `/schema`).\n\n"
|
||||||
|
"**Filtres** (`<colonne>__<op>=<valeur>`) :\n"
|
||||||
|
"- `exact` : égal à la valeur\n"
|
||||||
|
"- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n"
|
||||||
|
"- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n"
|
||||||
|
"- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n"
|
||||||
|
"- `less` / `greater` : ≤ / ≥\n"
|
||||||
|
"- `strictly_less` / `strictly_greater` : < / >\n"
|
||||||
|
"- `isnull` / `isnotnull` : valeur nulle / non nulle (sans valeur)\n"
|
||||||
|
"- `sort` : tri, valeur `asc` ou `desc`\n\n"
|
||||||
|
"**Agrégation** (drapeaux sans valeur, ex. `acheteur_departement_code__groupby&montant__sum`) :\n"
|
||||||
|
"- `groupby` : regroupe sur la colonne\n"
|
||||||
|
"- `count`, `sum`, `avg`, `min`, `max` : agrège la colonne ; "
|
||||||
|
"la colonne de sortie est nommée `colonne__count`, `colonne__sum`, "
|
||||||
|
"`colonne__avg`, `colonne__min`, `colonne__max`\n\n"
|
||||||
|
"En mode agrégation, la réponse contient des lignes groupées, "
|
||||||
|
"`columns` est interdit et `meta` ne contient pas `total`. "
|
||||||
|
"`sort` peut être appliqué sur une colonne `groupby` (ex. `acheteur_departement_code__sort=asc`) ; "
|
||||||
|
"il n'est pas supporté sur les alias d'agrégats (ex. `uid__count__sort=desc` → 400).\n\n"
|
||||||
|
"Exemples : `acheteur_id__contains=VILLE`, `montant__greater=10000`, "
|
||||||
|
"`acheteur_departement_code__groupby&montant__sum`, "
|
||||||
|
"`acheteur_departement_code__groupby&uid__count&acheteur_departement_code__sort=asc`."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@require_token
|
||||||
|
def data():
|
||||||
|
"""Récupère des marchés publics filtrés, triés ou agrégés.
|
||||||
|
|
||||||
|
Filtres en query string : `<colonne>__<opérateur>=<valeur>`.
|
||||||
|
Opérateurs de filtre : exact, differs, contains, notcontains, in, notin,
|
||||||
|
less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort.
|
||||||
|
|
||||||
|
Agrégation (drapeaux sans valeur) : `<colonne>__groupby`,
|
||||||
|
`<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées
|
||||||
|
`<colonne>__<opérateur>`. `columns` est interdit avec une agrégation et
|
||||||
|
`meta` ne contient alors pas `total`. `sort` est supporté sur les colonnes
|
||||||
|
`groupby` ; non supporté sur les alias d'agrégats (→ 400).
|
||||||
|
|
||||||
|
Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000),
|
||||||
|
columns (csv), count_results (true|false ; mettre false pour économiser
|
||||||
|
le COUNT(*)).
|
||||||
|
|
||||||
|
Exemple d'agrégation :
|
||||||
|
`?acheteur_departement_code__groupby&uid__count&montant__sum`
|
||||||
|
"""
|
||||||
|
import polars as pl
|
||||||
|
import polars.selectors as cs
|
||||||
|
|
||||||
|
page, page_size = _parse_pagination()
|
||||||
|
columns = _parse_columns()
|
||||||
|
count_results = request.args.get("count_results", "true").lower() != "false"
|
||||||
|
|
||||||
|
args = list(request.args.items(multi=True))
|
||||||
|
try:
|
||||||
|
agg = parse_aggregators(args, duckdb_schema)
|
||||||
|
where_sql, params, order_sql = build_where(args, duckdb_schema)
|
||||||
|
except FilterError as e:
|
||||||
|
abort(400, message=str(e), errors={"field": e.field})
|
||||||
|
|
||||||
|
if agg is not None:
|
||||||
|
if columns:
|
||||||
|
abort(
|
||||||
|
400,
|
||||||
|
message="`columns` ne peut pas être combiné avec une agrégation",
|
||||||
|
)
|
||||||
|
df = aggregate_marches(
|
||||||
|
select_sql=agg.select_sql,
|
||||||
|
where_sql=where_sql,
|
||||||
|
params=params,
|
||||||
|
group_by=agg.group_by_sql,
|
||||||
|
order_by=order_sql or None,
|
||||||
|
limit=page_size,
|
||||||
|
offset=(page - 1) * page_size,
|
||||||
|
)
|
||||||
|
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||||
|
# Si la page est partielle, on connaît le total exact ; sinon on ne sait pas.
|
||||||
|
agg_total = (
|
||||||
|
(page - 1) * page_size + df.height if df.height < page_size else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data": df_ready.to_dicts(),
|
||||||
|
"meta": {"page": page, "page_size": page_size},
|
||||||
|
"links": _build_links(page, page_size, agg_total),
|
||||||
|
}
|
||||||
|
|
||||||
|
df = query_marches(
|
||||||
|
where_sql=where_sql,
|
||||||
|
params=params,
|
||||||
|
columns=columns,
|
||||||
|
order_by=order_sql,
|
||||||
|
limit=page_size,
|
||||||
|
offset=(page - 1) * page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON ne sérialise pas date/datetime nativement → cast en string ISO
|
||||||
|
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||||
|
|
||||||
|
total = count_marches(where_sql, params) if count_results else None
|
||||||
|
meta = {"page": page, "page_size": page_size}
|
||||||
|
if total is not None:
|
||||||
|
meta["total"] = total
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": df_ready.to_dicts(),
|
||||||
|
"meta": meta,
|
||||||
|
"links": _build_links(page, page_size, total),
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None, env=None) -> int:
|
||||||
|
load_dotenv()
|
||||||
|
env = env if env is not None else os.environ
|
||||||
|
parser = argparse.ArgumentParser(prog="python -m src.api.tokens_cli")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p_create = sub.add_parser("create", help="Créer un token API")
|
||||||
|
p_create.add_argument("--label", required=True)
|
||||||
|
p_create.add_argument("--user-id", type=int, default=None)
|
||||||
|
|
||||||
|
sub.add_parser("list", help="Lister les tokens")
|
||||||
|
|
||||||
|
p_revoke = sub.add_parser("revoke", help="Révoquer un token")
|
||||||
|
p_revoke.add_argument("token_id", type=int)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
db_path = env["USERS_DB_PATH"]
|
||||||
|
tokens_db.init_schema(db_path)
|
||||||
|
|
||||||
|
if args.cmd == "create":
|
||||||
|
token, token_id = tokens_db.create_token(db_path, args.label, args.user_id)
|
||||||
|
print(f"id={token_id} label={args.label}")
|
||||||
|
print(f"token (à conserver, ne sera plus affiché) : {token}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "list":
|
||||||
|
rows = tokens_db.list_tokens(db_path)
|
||||||
|
if not rows:
|
||||||
|
print("(aucun token)")
|
||||||
|
return 0
|
||||||
|
print(
|
||||||
|
f"{'id':<4} {'label':<40} {'created_at':<26} {'last_used_at':<26} {'count':<7} revoked"
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
print(
|
||||||
|
f"{r['id']:<4} {r['label']:<40} {r['created_at']:<26} "
|
||||||
|
f"{(r['last_used_at'] or '-'):<26} {r['count_total']:<7} "
|
||||||
|
f"{r['revoked_at'] or ''}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "revoke":
|
||||||
|
tokens_db.revoke_token(db_path, args.token_id)
|
||||||
|
print(f"token id={args.token_id} révoqué")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOKEN_PREFIX = "decpinfo_"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connect(db_path):
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema(db_path) -> None:
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]:
|
||||||
|
token = TOKEN_PREFIX + secrets.token_hex(32)
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO api_tokens (token_hash, label, user_id, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(_hash(token), label, user_id, _utcnow_iso()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return token, cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_by_plaintext(db_path, token: str) -> dict | None:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM api_tokens WHERE token_hash = ?",
|
||||||
|
(_hash(token),),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_token(db_path, token_id: int) -> None:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE api_tokens SET revoked_at = ? WHERE id = ?",
|
||||||
|
(_utcnow_iso(), token_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def increment_usage(db_path, token_id: int) -> None:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE api_tokens "
|
||||||
|
"SET count_total = count_total + 1, last_used_at = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(_utcnow_iso(), token_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_tokens(db_path) -> list[dict]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.utils import logger
|
||||||
|
|
||||||
|
_STOP_SENTINEL = object()
|
||||||
|
_queue: Optional[queue.Queue] = None
|
||||||
|
_worker_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_loop(q: queue.Queue, db_path: str) -> None:
|
||||||
|
while True:
|
||||||
|
item = q.get()
|
||||||
|
try:
|
||||||
|
if item is _STOP_SENTINEL:
|
||||||
|
return
|
||||||
|
kind, payload = item
|
||||||
|
if kind == "counter":
|
||||||
|
token_id = payload
|
||||||
|
try:
|
||||||
|
tokens_db.increment_usage(db_path, token_id)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning(
|
||||||
|
"tracking: échec increment_usage token_id=%s",
|
||||||
|
token_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
elif kind == "matomo":
|
||||||
|
try:
|
||||||
|
_post_matomo(**payload)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning("tracking: échec envoi Matomo", exc_info=True)
|
||||||
|
finally:
|
||||||
|
q.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
def _post_matomo(url: str, params: dict) -> None:
|
||||||
|
"""POST fire-and-forget vers la Tracking API Matomo. Mockable en test."""
|
||||||
|
httpx.post(url, data=params, timeout=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_matomo_event(
|
||||||
|
token_id: int,
|
||||||
|
path: str,
|
||||||
|
query_string: str,
|
||||||
|
status_code: int,
|
||||||
|
user_agent: str,
|
||||||
|
) -> None:
|
||||||
|
if _queue is None:
|
||||||
|
return
|
||||||
|
if os.getenv("MATOMO_TRACKING_ENABLED", "false").lower() != "true":
|
||||||
|
return
|
||||||
|
url = os.getenv("MATOMO_URL")
|
||||||
|
site_id = os.getenv("MATOMO_SITE_ID")
|
||||||
|
if not url or not site_id:
|
||||||
|
return
|
||||||
|
full_url = f"https://decp.info{path}"
|
||||||
|
if query_string:
|
||||||
|
full_url += f"?{query_string}"
|
||||||
|
params = {
|
||||||
|
"idsite": site_id,
|
||||||
|
"rec": "1",
|
||||||
|
"url": full_url,
|
||||||
|
"action_name": f"API {path}",
|
||||||
|
"uid": f"token-{token_id}",
|
||||||
|
"dimension1": str(token_id),
|
||||||
|
"dimension2": str(status_code),
|
||||||
|
"ua": user_agent,
|
||||||
|
}
|
||||||
|
_queue.put(("matomo", {"url": url, "params": params}))
|
||||||
|
|
||||||
|
|
||||||
|
def start_worker(db_path: str) -> None:
|
||||||
|
global _queue, _worker_thread
|
||||||
|
if _worker_thread is not None and _worker_thread.is_alive():
|
||||||
|
return
|
||||||
|
_queue = queue.Queue()
|
||||||
|
_worker_thread = threading.Thread(
|
||||||
|
target=_worker_loop, args=(_queue, db_path), daemon=True
|
||||||
|
)
|
||||||
|
_worker_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_worker() -> None:
|
||||||
|
global _worker_thread, _queue
|
||||||
|
if _worker_thread is None:
|
||||||
|
return
|
||||||
|
_queue.put(_STOP_SENTINEL)
|
||||||
|
_worker_thread.join(timeout=2.0)
|
||||||
|
_worker_thread = None
|
||||||
|
_queue = None
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_counter_update(token_id: int) -> None:
|
||||||
|
if _queue is None:
|
||||||
|
return # tracking désactivé (tests par ex.)
|
||||||
|
_queue.put(("counter", token_id))
|
||||||
|
|
||||||
|
|
||||||
|
def flush(timeout: float = 2.0) -> None:
|
||||||
|
"""Attend que la queue soit drainée. Utile en test."""
|
||||||
|
q = _queue
|
||||||
|
if q is None:
|
||||||
|
return
|
||||||
|
q.join()
|
||||||
+26
-9
@@ -6,7 +6,7 @@ import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race acr
|
|||||||
import tomllib
|
import tomllib
|
||||||
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
|
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from flask import Response
|
from flask import Flask, Response
|
||||||
|
|
||||||
from src.utils import DEVELOPMENT
|
from src.utils import DEVELOPMENT
|
||||||
from src.utils.cache import cache
|
from src.utils.cache import cache
|
||||||
@@ -27,12 +27,10 @@ META_TAGS = [
|
|||||||
if DEVELOPMENT:
|
if DEVELOPMENT:
|
||||||
META_TAGS.append({"name": "robots", "content": "noindex"})
|
META_TAGS.append({"name": "robots", "content": "noindex"})
|
||||||
|
|
||||||
app: Dash = Dash(
|
# Le cache doit être initialisé AVANT la construction de Dash : `use_pages=True`
|
||||||
title="decp.info",
|
# importe les modules de pages pendant l'instanciation, et certains appellent des
|
||||||
use_pages=True,
|
# fonctions memoizées (@cache.memoize) dès l'import (ex. tableau.py).
|
||||||
compress=True,
|
server = Flask(__name__)
|
||||||
meta_tags=META_TAGS,
|
|
||||||
)
|
|
||||||
|
|
||||||
cache_dir = os.getenv("CACHE_DIR", "/tmp/decp-cache")
|
cache_dir = os.getenv("CACHE_DIR", "/tmp/decp-cache")
|
||||||
|
|
||||||
@@ -40,7 +38,7 @@ if os.path.exists(cache_dir):
|
|||||||
rmtree(cache_dir)
|
rmtree(cache_dir)
|
||||||
|
|
||||||
cache.init_app(
|
cache.init_app(
|
||||||
app.server,
|
server,
|
||||||
config={
|
config={
|
||||||
"CACHE_TYPE": "FileSystemCache",
|
"CACHE_TYPE": "FileSystemCache",
|
||||||
"CACHE_DIR": cache_dir,
|
"CACHE_DIR": cache_dir,
|
||||||
@@ -51,6 +49,18 @@ cache.init_app(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app: Dash = Dash(
|
||||||
|
server=server,
|
||||||
|
title="decp.info",
|
||||||
|
use_pages=True,
|
||||||
|
compress=True,
|
||||||
|
meta_tags=META_TAGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready first
|
||||||
|
|
||||||
|
init_api(app.server)
|
||||||
|
|
||||||
|
|
||||||
# robots.txt
|
# robots.txt
|
||||||
@app.server.route("/robots.txt")
|
@app.server.route("/robots.txt")
|
||||||
@@ -69,6 +79,7 @@ def sitemap():
|
|||||||
"/observatoire",
|
"/observatoire",
|
||||||
"/tableau",
|
"/tableau",
|
||||||
"/a-propos",
|
"/a-propos",
|
||||||
|
"/etapes",
|
||||||
]
|
]
|
||||||
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
@@ -145,7 +156,13 @@ navbar = dbc.Navbar(
|
|||||||
style={"minWidth": "230px"},
|
style={"minWidth": "230px"},
|
||||||
),
|
),
|
||||||
dbc.Nav(
|
dbc.Nav(
|
||||||
children=[dcc.Markdown(os.getenv("ANNOUNCEMENTS"), id="announcements")],
|
children=[
|
||||||
|
dcc.Markdown(
|
||||||
|
os.getenv("ANNOUNCEMENTS"),
|
||||||
|
id="announcements",
|
||||||
|
dangerously_allow_html=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
style={
|
style={
|
||||||
"maxWidth": "1200px",
|
"maxWidth": "1200px",
|
||||||
"display": "inline-block",
|
"display": "inline-block",
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ p.version > a {
|
|||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#announcements p {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.seeBorder {
|
.seeBorder {
|
||||||
border: dotted 1px green;
|
border: dotted 1px green;
|
||||||
}
|
}
|
||||||
@@ -173,6 +177,10 @@ p.version > a {
|
|||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#search_results td[data-dash-column="Département"] {
|
||||||
|
text-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Dashboard inputs --- */
|
/* --- Dashboard inputs --- */
|
||||||
|
|
||||||
.Select--multi .Select-value {
|
.Select--multi .Select-value {
|
||||||
@@ -542,3 +550,218 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
input[type="number"] {
|
input[type="number"] {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Page /etapes : graphique données par étape et par seuil ===== */
|
||||||
|
|
||||||
|
.etapes-chart-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-chart {
|
||||||
|
min-width: 720px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-corner {
|
||||||
|
border-bottom: 2px solid #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-xhead {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
border-bottom: 2px solid #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-xcell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475467;
|
||||||
|
border-left: 1px dashed #d0d5dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-xcell strong {
|
||||||
|
display: block;
|
||||||
|
color: #101828;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-stage {
|
||||||
|
padding: 14px 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
border-bottom: 1px solid #eaecf0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-stage small {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-lane {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid #eaecf0;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-segs {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-segs > div {
|
||||||
|
border-left: 1px dashed #eaecf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-bar:hover {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-empty {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Vue mobile (liste par étape) : masquée par défaut --- */
|
||||||
|
|
||||||
|
.etapes-mobile {
|
||||||
|
display: none;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-block {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #eaecf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-stage {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #1570ef;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f2f4f7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-item i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-seuil {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-m-empty {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-detail {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etapes-detail:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bascule desktop / mobile au point de rupture 768 px --- */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.etapes-chart-scroll {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.etapes-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,27 +8,31 @@ import polars as pl
|
|||||||
import polars.selectors as cs
|
import polars.selectors as cs
|
||||||
from polars.exceptions import ComputeError
|
from polars.exceptions import ComputeError
|
||||||
|
|
||||||
from src.utils import logger
|
from src.utils import get_last_modified, logger
|
||||||
|
|
||||||
|
|
||||||
def should_rebuild(db_path: Path, parquet_path: Path) -> bool:
|
def should_rebuild(db_path: Path, parquet_path: str) -> bool:
|
||||||
db_path = Path(db_path)
|
db_path = Path(db_path)
|
||||||
parquet_path = Path(parquet_path)
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return True
|
return True
|
||||||
dev = os.getenv("DEVELOPMENT", "False").lower() == "true"
|
dev = os.getenv("DEVELOPMENT", "False").lower() == "true"
|
||||||
force = os.getenv("REBUILD_DUCKDB", "False").lower() == "true"
|
force = os.getenv("REBUILD_DUCKDB", "False").lower() == "true"
|
||||||
if dev and not force:
|
if dev and not force:
|
||||||
return False
|
return False
|
||||||
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
|
last_modified: float = get_last_modified(parquet_path)
|
||||||
|
return last_modified > db_path.stat().st_mtime
|
||||||
|
|
||||||
|
|
||||||
def _load_source_frame(parquet_path: Path) -> pl.DataFrame:
|
def _load_source_frame() -> pl.DataFrame:
|
||||||
"""Read the source parquet and apply the row-level transforms.
|
"""Read the source parquet and apply the row-level transforms.
|
||||||
|
|
||||||
Kept here (not in utils.py) so src.db has no dependency on utils.
|
Kept here (not in utils.py) so src.db has no dependency on utils.
|
||||||
Mirrors the behavior previously in utils.get_decp_data().
|
Mirrors the behavior previously in utils.get_decp_data().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
parquet_path: str = os.getenv("DATA_FILE_PARQUET_PATH", "")
|
||||||
|
if not (parquet_path.startswith("http")):
|
||||||
|
assert os.path.exists(parquet_path)
|
||||||
try:
|
try:
|
||||||
lff: pl.LazyFrame = pl.scan_parquet(str(parquet_path))
|
lff: pl.LazyFrame = pl.scan_parquet(str(parquet_path))
|
||||||
except ComputeError:
|
except ComputeError:
|
||||||
@@ -58,20 +62,21 @@ def _load_source_frame(parquet_path: Path) -> pl.DataFrame:
|
|||||||
return lff.collect()
|
return lff.collect()
|
||||||
|
|
||||||
|
|
||||||
def build_database(db_path: Path, parquet_path: Path) -> None:
|
def build_database(db_path: Path) -> None:
|
||||||
"""Build the DuckDB database atomically under an exclusive lock.
|
"""Build the DuckDB database atomically under an exclusive lock.
|
||||||
|
|
||||||
Caller MUST hold the fcntl.flock on the .lock file.
|
Caller MUST hold the fcntl.flock on the .lock file.
|
||||||
"""
|
"""
|
||||||
db_path = Path(db_path)
|
db_path = Path(db_path)
|
||||||
parquet_path = Path(parquet_path)
|
|
||||||
tmp_path = db_path.with_suffix(".duckdb.tmp")
|
tmp_path = db_path.with_suffix(".duckdb.tmp")
|
||||||
staging_parquet = db_path.with_suffix(".staging.parquet")
|
staging_parquet = db_path.with_suffix(".staging.parquet")
|
||||||
if tmp_path.exists():
|
if tmp_path.exists():
|
||||||
tmp_path.unlink()
|
tmp_path.unlink()
|
||||||
|
|
||||||
logger.info(f"Construction de la base DuckDB à partir de {parquet_path}...")
|
logger.info(
|
||||||
frame = _load_source_frame(parquet_path)
|
f"Construction de la base DuckDB à partir de {os.getenv('DATA_FILE_PARQUET_PATH', '')}..."
|
||||||
|
)
|
||||||
|
frame = _load_source_frame()
|
||||||
|
|
||||||
# Write transformed frame as parquet so DuckDB can read it natively
|
# Write transformed frame as parquet so DuckDB can read it natively
|
||||||
# (avoids pyarrow dependency for the Polars→DuckDB handoff)
|
# (avoids pyarrow dependency for the Polars→DuckDB handoff)
|
||||||
@@ -111,15 +116,26 @@ def build_database(db_path: Path, parquet_path: Path) -> None:
|
|||||||
|
|
||||||
def _ensure_database() -> Path:
|
def _ensure_database() -> Path:
|
||||||
db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb"))
|
db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb"))
|
||||||
parquet_path = Path(os.getenv("DATA_FILE_PARQUET_PATH"))
|
parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "")
|
||||||
lock_path = db_path.with_suffix(".duckdb.lock")
|
lock_path = db_path.with_suffix(".duckdb.lock")
|
||||||
|
db_exists = db_path.exists()
|
||||||
|
|
||||||
with open(lock_path, "w") as lock_fd:
|
with open(lock_path, "w") as lock_fd:
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
if should_rebuild(db_path, parquet_path):
|
if should_rebuild(db_path, parquet_path):
|
||||||
build_database(db_path, parquet_path)
|
build_database(db_path)
|
||||||
else:
|
else:
|
||||||
logger.debug("Base de données déjà disponible et à jour.")
|
logger.debug("Base de données déjà disponible et à jour.")
|
||||||
|
except Exception as e:
|
||||||
|
if db_exists and db_path.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
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
@@ -155,12 +171,16 @@ def query_marches(
|
|||||||
sql += f" LIMIT {int(limit)}"
|
sql += f" LIMIT {int(limit)}"
|
||||||
if offset is not None:
|
if offset is not None:
|
||||||
sql += f" OFFSET {int(offset)}"
|
sql += f" OFFSET {int(offset)}"
|
||||||
|
|
||||||
|
logger.debug("query_marches: " + sql.replace("?", "{}").format(*params))
|
||||||
|
|
||||||
return get_cursor().execute(sql, list(params)).pl()
|
return get_cursor().execute(sql, list(params)).pl()
|
||||||
|
|
||||||
|
|
||||||
def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
|
def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
|
||||||
"""Retourne le nombre de lignes correspondant à where_sql."""
|
"""Retourne le nombre de lignes correspondant à where_sql."""
|
||||||
sql = f"SELECT COUNT(*) FROM decp WHERE {where_sql}"
|
sql = f"SELECT COUNT(*) FROM decp WHERE {where_sql}"
|
||||||
|
logger.debug("count_marches: " + sql.replace("?", "{}").format(*params))
|
||||||
result = get_cursor().execute(sql, list(params)).fetchone()
|
result = get_cursor().execute(sql, list(params)).fetchone()
|
||||||
return int(result[0]) if result else 0
|
return int(result[0]) if result else 0
|
||||||
|
|
||||||
@@ -168,5 +188,36 @@ def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
|
|||||||
def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
|
def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
|
||||||
"""Retourne le nombre de uid distincts correspondant à where_sql."""
|
"""Retourne le nombre de uid distincts correspondant à where_sql."""
|
||||||
sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}"
|
sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}"
|
||||||
|
logger.debug("count_unique_marches: " + sql.replace("?", "{}").format(*params))
|
||||||
result = get_cursor().execute(sql, list(params)).fetchone()
|
result = get_cursor().execute(sql, list(params)).fetchone()
|
||||||
return int(result[0]) if result else 0
|
return int(result[0]) if result else 0
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_marches(
|
||||||
|
select_sql: str,
|
||||||
|
where_sql: str = "TRUE",
|
||||||
|
params: tuple | list = (),
|
||||||
|
group_by: str | None = None,
|
||||||
|
order_by: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""SELECT agrégé paramétré contre la table decp.
|
||||||
|
|
||||||
|
`select_sql`, `group_by` et `order_by` sont des fragments SQL construits
|
||||||
|
depuis des noms de colonnes validés (jamais de valeur utilisateur libre).
|
||||||
|
Les valeurs de filtre passent par le binding `?` via `params`.
|
||||||
|
"""
|
||||||
|
sql = f"SELECT {select_sql} FROM decp WHERE {where_sql}"
|
||||||
|
if group_by:
|
||||||
|
sql += f" GROUP BY {group_by}"
|
||||||
|
if order_by:
|
||||||
|
sql += f" ORDER BY {order_by}"
|
||||||
|
if limit is not None:
|
||||||
|
sql += f" LIMIT {int(limit)}"
|
||||||
|
if offset is not None:
|
||||||
|
sql += f" OFFSET {int(offset)}"
|
||||||
|
|
||||||
|
logger.debug("aggregate_marches: " + sql.replace("?", "{}").format(*params))
|
||||||
|
|
||||||
|
return get_cursor().execute(sql, list(params)).pl()
|
||||||
|
|||||||
+182
-23
@@ -1,6 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
import dash_leaflet as dl
|
import dash_leaflet as dl
|
||||||
@@ -11,8 +10,10 @@ import plotly.graph_objects as go
|
|||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import dash_table, dcc, html
|
from dash import dash_table, dcc, html
|
||||||
from dash_extensions.javascript import Namespace
|
from dash_extensions.javascript import Namespace
|
||||||
|
from polars.exceptions import ColumnNotFoundError
|
||||||
|
|
||||||
from src.db import schema
|
from src.db import schema
|
||||||
|
from src.utils import logger
|
||||||
from src.utils.data import DATA_SCHEMA, DEPARTEMENTS_GEOJSON
|
from src.utils.data import DATA_SCHEMA, DEPARTEMENTS_GEOJSON
|
||||||
from src.utils.table import add_links, format_number, setup_table_columns
|
from src.utils.table import add_links, format_number, setup_table_columns
|
||||||
|
|
||||||
@@ -118,9 +119,12 @@ def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
|
|||||||
|
|
||||||
def get_sources_tables(source_path) -> html.Div:
|
def get_sources_tables(source_path) -> html.Div:
|
||||||
try:
|
try:
|
||||||
|
if not source_path:
|
||||||
|
raise ValueError("SOURCE_STATS_CSV_PATH non défini")
|
||||||
dff = pl.read_csv(source_path)
|
dff = pl.read_csv(source_path)
|
||||||
except (URLError, HTTPError):
|
except Exception as e:
|
||||||
return html.Div("Erreur de connexion")
|
logger.warning(f"Sources de données indisponibles ({e})")
|
||||||
|
return html.Div("Sources de données momentanément indisponibles.")
|
||||||
dff = dff.with_columns(
|
dff = dff.with_columns(
|
||||||
(
|
(
|
||||||
pl.lit('<a href = "')
|
pl.lit('<a href = "')
|
||||||
@@ -173,39 +177,78 @@ def get_sources_tables(source_path) -> html.Div:
|
|||||||
return html.Div(children=datatable)
|
return html.Div(children=datatable)
|
||||||
|
|
||||||
|
|
||||||
def point_on_map(lat, lon):
|
def point_on_map(lat, lon, departement_code=None):
|
||||||
|
"""Fonction améliorée utilisant les codes départementaux pour la détection de région.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lat: Coordonnée de latitude
|
||||||
|
lon: Coordonnée de longitude
|
||||||
|
departement_code: Code du département (ex: '75', '971', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
html.Div contenant la carte, ou div vide si invalide
|
||||||
|
"""
|
||||||
|
# Validation des coordonnées
|
||||||
|
try:
|
||||||
lat = float(lat)
|
lat = float(lat)
|
||||||
lon = float(lon)
|
lon = float(lon)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return html.Div() # Div vide pour les coordonnées invalides
|
||||||
|
|
||||||
# Create a scatter mapbox or choropleth map
|
# Vérification que les coordonnées sont valides
|
||||||
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||||
|
return html.Div()
|
||||||
|
|
||||||
|
# Si aucun code département n'est fourni, retourner une div vide
|
||||||
|
if not departement_code:
|
||||||
|
return html.Div()
|
||||||
|
|
||||||
|
# Détermination de la région en utilisant le code département
|
||||||
|
# Logique identique à get_geographic_maps
|
||||||
|
if departement_code in ["971", "972", "973", "974", "976"]:
|
||||||
|
region_key = departement_code # Département d'outre-mer
|
||||||
|
elif len(departement_code) == 2: # Département métropolitain
|
||||||
|
region_key = "Hexagone"
|
||||||
|
else:
|
||||||
|
return html.Div() # Format de code département invalide
|
||||||
|
|
||||||
|
# Paramètres de carte par région (réutilisés de get_geographic_maps)
|
||||||
|
regions = {
|
||||||
|
"Hexagone": {"center": [46.6, 2.2], "zoom": 5},
|
||||||
|
"971": {"center": [16.23, -61.55], "zoom": 9}, # Guadeloupe
|
||||||
|
"972": {"center": [14.64, -61.02], "zoom": 10}, # Martinique
|
||||||
|
"973": {"center": [3.93, -53.12], "zoom": 7}, # Guyane
|
||||||
|
"974": {"center": [-21.11, 55.53], "zoom": 9}, # La Réunion
|
||||||
|
"976": {"center": [-12.82, 45.16], "zoom": 10}, # Mayotte
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = regions.get(region_key, regions["Hexagone"])
|
||||||
|
|
||||||
|
# Création de la carte
|
||||||
fig = px.scatter_map(
|
fig = px.scatter_map(
|
||||||
lat=[lat], lon=[lon], height=300, width=400, color=[1], size=[1]
|
lat=[lat],
|
||||||
|
lon=[lon],
|
||||||
|
height=300,
|
||||||
|
# width=400,
|
||||||
|
color=[1],
|
||||||
|
zoom=settings["zoom"],
|
||||||
)
|
)
|
||||||
|
|
||||||
fig.update_coloraxes(showscale=False)
|
fig.update_traces(marker=dict(size=10))
|
||||||
|
|
||||||
# Set map style (you can use 'open-street-map', 'carto-positron', etc.)
|
# Configuration de la carte (interactive - zoomable)
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
mapbox_style="light", # Light, clean background
|
map_style="light", # Fond de carte clair
|
||||||
margin={"r": 0, "t": 0, "l": 0, "b": 0},
|
margin={"r": 0, "t": 0, "l": 0, "b": 0},
|
||||||
|
mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]},
|
||||||
|
mapbox_zoom=settings["zoom"],
|
||||||
|
coloraxis_showscale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optionally, center the map on France
|
return html.Div(
|
||||||
fig.update_geos(
|
dcc.Graph(figure=fig, config={"displayModeBar": False}),
|
||||||
center=dict(lat=46.603354, lon=1.888334), # Center of France
|
|
||||||
lataxis_range=[41, 51.5], # Latitude range for France
|
|
||||||
lonaxis_range=[-5, 10], # Longitude range for France
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# But scatter_mapbox doesn't use geos, so better to control via zoom/center manually
|
|
||||||
# Let's reset and use proper centering in scatter_mapbox instead:
|
|
||||||
|
|
||||||
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
|
|
||||||
|
|
||||||
graph = dcc.Graph(id="map", figure=fig)
|
|
||||||
graph = html.Div(style={"width": "400px"})
|
|
||||||
return graph
|
|
||||||
|
|
||||||
|
|
||||||
class DataTable(dash_table.DataTable):
|
class DataTable(dash_table.DataTable):
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -685,6 +728,118 @@ def get_dashboard_summary_table(dff, dff_per_uid, nb_marches):
|
|||||||
return summary_table
|
return summary_table
|
||||||
|
|
||||||
|
|
||||||
|
CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé"
|
||||||
|
CONSIDERATIONS_COLUMNS = {
|
||||||
|
"sociales": "considerationsSociales",
|
||||||
|
"environnementales": "considerationsEnvironnementales",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int, int]]:
|
||||||
|
"""Statistiques considérations pour l'observatoire.
|
||||||
|
|
||||||
|
Clés renvoyées :
|
||||||
|
- "champs_renseignes" : (count_ren_sociales, pct / total)
|
||||||
|
- "{key}_renseignees" : (count_ren, pct positifs parmi renseignés)
|
||||||
|
Colonne absente -> (0, 0).
|
||||||
|
"""
|
||||||
|
names = lff.collect_schema().names()
|
||||||
|
present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names}
|
||||||
|
|
||||||
|
stats: dict[str, tuple[int, int, int]] = {"champs_renseignes": (0, 0)}
|
||||||
|
for key in CONSIDERATIONS_COLUMNS:
|
||||||
|
stats[f"{key}_renseignees"] = (0, 0)
|
||||||
|
|
||||||
|
if not present:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
agg = (
|
||||||
|
lff.select(["uid"] + list(present.values()))
|
||||||
|
.group_by("uid")
|
||||||
|
.agg([pl.col(col).first() for col in present.values()])
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
total = agg.height
|
||||||
|
if total == 0:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
for key, col in present.items():
|
||||||
|
count_ren = agg.filter(pl.col(col).is_not_null()).height
|
||||||
|
count_pos_ren = agg.filter(
|
||||||
|
pl.col(col).is_not_null() & (pl.col(col) != "Sans objet")
|
||||||
|
).height
|
||||||
|
pct_pos_ren = round(100 * count_pos_ren / count_ren) if count_ren > 0 else 0
|
||||||
|
stats[f"{key}_renseignees"] = (count_ren, count_pos_ren, pct_pos_ren)
|
||||||
|
if key == "sociales":
|
||||||
|
stats["champs_renseignes"] = (count_ren, round(100 * count_ren / total))
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# (clé stats, libellé, couleur)
|
||||||
|
CONSIDERATIONS_RENSEIGNEES = [
|
||||||
|
("sociales_renseignees", "Considérations sociales", "#CC6677"),
|
||||||
|
("environnementales_renseignees", "Considérations environnementales", "#117733"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_bar(pct: int, color: str) -> dbc.Progress:
|
||||||
|
return dbc.Progress(
|
||||||
|
dbc.Progress(
|
||||||
|
value=pct, label=f"{pct} %", bar=True, color=color, style={"color": "white"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div:
|
||||||
|
"""Trois barres : champs renseignés + part positive pour sociales et environnementales."""
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
count_ren, pct_ren = stats["champs_renseignes"]
|
||||||
|
blocks = [
|
||||||
|
html.Div(
|
||||||
|
className="mb-3",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="d-flex justify-content-between",
|
||||||
|
children=[
|
||||||
|
html.Span("Champs considérations renseignés"),
|
||||||
|
html.Span(
|
||||||
|
f"{format_number(count_ren)} marchés",
|
||||||
|
className="text-muted",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_progress_bar(pct_ren, "#6c757d"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for key, label, color in CONSIDERATIONS_RENSEIGNEES:
|
||||||
|
total_count, count, pct = stats[key]
|
||||||
|
blocks.append(
|
||||||
|
html.Div(
|
||||||
|
className="mb-3",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="d-flex justify-content-between",
|
||||||
|
children=[
|
||||||
|
html.Span(label),
|
||||||
|
html.Span(
|
||||||
|
f"dans {format_number(count)} marchés",
|
||||||
|
className="text-muted",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_progress_bar(pct, color),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return html.Div(children=blocks)
|
||||||
|
|
||||||
|
|
||||||
def make_card(
|
def make_card(
|
||||||
title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4
|
title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4
|
||||||
) -> dbc.Col:
|
) -> dbc.Col:
|
||||||
@@ -833,7 +988,11 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
|
|||||||
lff = lff.cast(pl.String)
|
lff = lff.cast(pl.String)
|
||||||
lff = lff.fill_null("")
|
lff = lff.fill_null("")
|
||||||
|
|
||||||
|
try:
|
||||||
dff: pl.DataFrame = lff.collect(engine="streaming")
|
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||||
|
except ColumnNotFoundError:
|
||||||
|
logger.warning(f"get_top_org_table: column not found. {lff.collect_schema()}")
|
||||||
|
return html.Div()
|
||||||
|
|
||||||
if dff.height == 0:
|
if dff.height == 0:
|
||||||
return html.Div()
|
return html.Div()
|
||||||
|
|||||||
+16
-8
@@ -49,6 +49,16 @@ Vous pouvez consommer les données qui alimentent decp.info
|
|||||||
|
|
||||||
- en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données
|
- en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données
|
||||||
- en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest)
|
- en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H4("API privée", id="api-privee"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
Une API HTTP est disponible pour accéder aux mêmes données par programme.
|
||||||
|
Documentation interactive : [Swagger UI](/api/v1/swagger).
|
||||||
|
|
||||||
|
L'accès se fait sur token. Pour en obtenir un, contactez
|
||||||
|
[colin@maudry.com](mailto:colin@maudry.com).
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
html.H4("Contact", id="contact"),
|
html.H4("Contact", id="contact"),
|
||||||
@@ -87,14 +97,7 @@ Vous pouvez consommer les données qui alimentent decp.info
|
|||||||
dcc.Markdown(
|
dcc.Markdown(
|
||||||
"""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
|
"""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
|
||||||
|
|
||||||
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [ci-dessous](/a-propos#sources). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
|
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [ci-dessous](/bin.usr-is-merged/)). Je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""
|
||||||
|
|
||||||
- klekoon.fr (ils y travaillent)
|
|
||||||
- safetender.com (Omnikles)
|
|
||||||
|
|
||||||
**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics.
|
|
||||||
|
|
||||||
Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""
|
|
||||||
),
|
),
|
||||||
html.H4("Sources de données ", id="sources"),
|
html.H4("Sources de données ", id="sources"),
|
||||||
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
||||||
@@ -156,6 +159,11 @@ J'enregistre également les données suivantes, de manière anonyme, afin de mie
|
|||||||
href="#donnees-brutes",
|
href="#donnees-brutes",
|
||||||
className="toc-link",
|
className="toc-link",
|
||||||
),
|
),
|
||||||
|
html.A(
|
||||||
|
"API privée",
|
||||||
|
href="#api-privee",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
html.A(
|
html.A(
|
||||||
"Contact", href="#contact", className="toc-link"
|
"Contact", href="#contact", className="toc-link"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -257,8 +257,15 @@ def update_acheteur_infos(url):
|
|||||||
if data_etablissement:
|
if data_etablissement:
|
||||||
data_etablissement = data_etablissement[0]
|
data_etablissement = data_etablissement[0]
|
||||||
|
|
||||||
|
# Extraction du code département à partir du code postal
|
||||||
|
code_postal = data_etablissement.get("code_postal", "")
|
||||||
|
departement_code = code_postal[:2] if code_postal else None
|
||||||
|
|
||||||
|
# Création de la carte avec le code département pour un centrage approprié
|
||||||
acheteur_map = point_on_map(
|
acheteur_map = point_on_map(
|
||||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
data_etablissement["latitude"],
|
||||||
|
data_etablissement["longitude"],
|
||||||
|
departement_code,
|
||||||
)
|
)
|
||||||
code_departement, nom_departement, nom_region = get_departement_region(
|
code_departement, nom_departement, nom_region = get_departement_region(
|
||||||
data_etablissement["code_postal"]
|
data_etablissement["code_postal"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import polars as pl
|
|||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
|
|
||||||
from src.db import get_cursor
|
from src.db import get_cursor
|
||||||
|
from src.utils import logger
|
||||||
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES
|
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES
|
||||||
|
|
||||||
NAME = "Liste des marchés publics"
|
NAME = "Liste des marchés publics"
|
||||||
@@ -27,9 +28,12 @@ def make_org_nom_verbe(org_type, org_id) -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
def get_title(code, org_type, org_id):
|
def get_title(code, org_type, org_id):
|
||||||
|
if org_type:
|
||||||
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
|
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||||
|
|
||||||
return f"Marchés publics {verbe} par {org_nom} | decp.info"
|
return f"Marchés publics {verbe} par {org_nom} | decp.info"
|
||||||
|
else:
|
||||||
|
logger.warning(f"Pas de org_type pour org_id: {org_id}")
|
||||||
|
return "Marchés publics | decp.info"
|
||||||
|
|
||||||
|
|
||||||
def get_description(code, org_type, org_id):
|
def get_description(code, org_type, org_id):
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
from dash import Input, Output, State, callback, ctx, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils.seo import META_CONTENT
|
||||||
|
|
||||||
|
NAME = "Quelles données pour quelles étapes et quels seuils dans les marchés publics ?"
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/etapes",
|
||||||
|
title=f"{NAME} | 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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Contenu des fiches — à rédiger en Markdown.
|
||||||
|
# Clés barres : "bar-approch", "bar-jal", "bar-boamp", "bar-joue-marche",
|
||||||
|
# "bar-decp", "bar-joue-attribution"
|
||||||
|
# Clés étapes (mobile) : "stage-programmation", "stage-publicite",
|
||||||
|
# "stage-attribution", "stage-contrat", "stage-paiement"
|
||||||
|
ALL_CONTENT: dict[str, str | None] = {
|
||||||
|
"bar-approch": None,
|
||||||
|
"bar-jal": None,
|
||||||
|
"bar-boamp": None,
|
||||||
|
"bar-joue-marche": None,
|
||||||
|
"bar-decp": None,
|
||||||
|
"bar-joue-attribution": None,
|
||||||
|
"stage-programmation": None,
|
||||||
|
"stage-publicite": None,
|
||||||
|
"stage-attribution": None,
|
||||||
|
"stage-contrat": None,
|
||||||
|
"stage-paiement": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
_BAR_IDS = [
|
||||||
|
"bar-approch",
|
||||||
|
"bar-jal",
|
||||||
|
"bar-boamp",
|
||||||
|
"bar-joue-marche",
|
||||||
|
"bar-decp",
|
||||||
|
"bar-joue-attribution",
|
||||||
|
]
|
||||||
|
_STAGE_IDS = [
|
||||||
|
"stage-programmation",
|
||||||
|
"stage-publicite",
|
||||||
|
"stage-attribution",
|
||||||
|
"stage-contrat",
|
||||||
|
"stage-paiement",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _lane(*bars):
|
||||||
|
"""Une ligne d'étape : fond segmenté en 5 + barres positionnées."""
|
||||||
|
return html.Div(
|
||||||
|
className="etapes-lane",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="etapes-segs",
|
||||||
|
children=[html.Div() for _ in range(5)],
|
||||||
|
),
|
||||||
|
*bars,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(label, color, style, bar_id=None):
|
||||||
|
base = {"backgroundColor": color}
|
||||||
|
base.update(style)
|
||||||
|
props = {"className": "etapes-bar", "style": base}
|
||||||
|
if bar_id is not None:
|
||||||
|
props["id"] = bar_id
|
||||||
|
props["n_clicks"] = 0
|
||||||
|
return html.Div(label, **props)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chart():
|
||||||
|
return html.Div(
|
||||||
|
className="etapes-chart-scroll",
|
||||||
|
children=html.Div(
|
||||||
|
className="etapes-chart",
|
||||||
|
children=[
|
||||||
|
# En-tête : coin vide + 5 marqueurs de seuils
|
||||||
|
html.Div(className="etapes-corner"),
|
||||||
|
html.Div(
|
||||||
|
className="etapes-xhead",
|
||||||
|
children=[
|
||||||
|
html.Div("0 €", className="etapes-xcell"),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("40 000 €"), "seuil DECP"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("90 000 €"), "publicité"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("140 k€ / 216 k€"), "seuils formalisés (UE)"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[html.Strong("5,404 M€"), "travaux (UE)"],
|
||||||
|
className="etapes-xcell",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# Programmation
|
||||||
|
html.Div("Programmation", className="etapes-stage"),
|
||||||
|
_lane(
|
||||||
|
_bar(
|
||||||
|
"Approch — sourcing / préinformation (non réglementaire)",
|
||||||
|
"#7c5cff",
|
||||||
|
{"left": "2%", "right": "2%"},
|
||||||
|
bar_id="bar-approch",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Publicité (appel d'offres)
|
||||||
|
html.Div(["Publicité"], className="etapes-stage"),
|
||||||
|
_lane(
|
||||||
|
_bar(
|
||||||
|
"JAL",
|
||||||
|
"#f79009",
|
||||||
|
{"left": "40%", "right": "40%", "top": "6px", "height": "20px"},
|
||||||
|
bar_id="bar-jal",
|
||||||
|
),
|
||||||
|
_bar(
|
||||||
|
"BOAMP",
|
||||||
|
"#1570ef",
|
||||||
|
{"left": "40%", "right": "2%", "top": "28px", "height": "20px"},
|
||||||
|
bar_id="bar-boamp",
|
||||||
|
),
|
||||||
|
_bar(
|
||||||
|
"JOUE — avis de marché",
|
||||||
|
"#0e9384",
|
||||||
|
{"left": "60%", "right": "2%", "top": "6px", "height": "20px"},
|
||||||
|
bar_id="bar-joue-marche",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Attribution
|
||||||
|
html.Div("Attribution", className="etapes-stage"),
|
||||||
|
_lane(
|
||||||
|
_bar(
|
||||||
|
"DECP — données essentielles",
|
||||||
|
"#12b76a",
|
||||||
|
{"left": "20%", "right": "2%", "top": "6px", "height": "20px"},
|
||||||
|
bar_id="bar-decp",
|
||||||
|
),
|
||||||
|
_bar(
|
||||||
|
"JOUE — avis d'attribution",
|
||||||
|
"#0e9384",
|
||||||
|
{"left": "60%", "right": "2%", "top": "28px", "height": "20px"},
|
||||||
|
bar_id="bar-joue-attribution",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Contrat (vide)
|
||||||
|
html.Div("Contrat", className="etapes-stage"),
|
||||||
|
html.Div(
|
||||||
|
"— aucune donnée publiée aujourd'hui —",
|
||||||
|
className="etapes-lane etapes-empty",
|
||||||
|
),
|
||||||
|
# Paiement (vide)
|
||||||
|
html.Div("Paiement", className="etapes-stage"),
|
||||||
|
html.Div(
|
||||||
|
"— aucune donnée publiée aujourd'hui —",
|
||||||
|
className="etapes-lane etapes-empty",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Données par étape, partagées par la vue mobile.
|
||||||
|
# Chaque tuple : (libellé étape, id CSS, [(libellé, couleur, plage seuils)]).
|
||||||
|
STAGES_MOBILE = [
|
||||||
|
(
|
||||||
|
"Programmation",
|
||||||
|
"stage-programmation",
|
||||||
|
[
|
||||||
|
("Approch", "#7c5cff", "tous montants — publication non réglementaire"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Publicité (appel d'offres)",
|
||||||
|
"stage-publicite",
|
||||||
|
[
|
||||||
|
("JAL", "#f79009", "de 90 000 € au seuil formalisé"),
|
||||||
|
("BOAMP", "#1570ef", "à partir de 90 000 €"),
|
||||||
|
(
|
||||||
|
"JOUE — avis de marché",
|
||||||
|
"#0e9384",
|
||||||
|
"à partir des seuils formalisés (140 k€ / 216 k€)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Attribution",
|
||||||
|
"stage-attribution",
|
||||||
|
[
|
||||||
|
("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"),
|
||||||
|
("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("Contrat", "stage-contrat", []),
|
||||||
|
("Paiement", "stage-paiement", []),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_mobile():
|
||||||
|
blocks = []
|
||||||
|
for stage, stage_id, items in STAGES_MOBILE:
|
||||||
|
if items:
|
||||||
|
children = [
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.I(style={"backgroundColor": color}),
|
||||||
|
html.Span(label, className="etapes-m-label"),
|
||||||
|
html.Span(seuil, className="etapes-m-seuil"),
|
||||||
|
],
|
||||||
|
className="etapes-m-item",
|
||||||
|
)
|
||||||
|
for label, color, seuil in items
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
children = [
|
||||||
|
html.Div(
|
||||||
|
"aucune donnée publiée aujourd'hui",
|
||||||
|
className="etapes-m-item etapes-m-empty",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
blocks.append(
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.H4(stage, className="etapes-m-stage"),
|
||||||
|
html.Button(
|
||||||
|
"Voir fiche →",
|
||||||
|
id=stage_id,
|
||||||
|
n_clicks=0,
|
||||||
|
className="etapes-m-link",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
className="etapes-m-header",
|
||||||
|
),
|
||||||
|
*children,
|
||||||
|
],
|
||||||
|
className="etapes-m-block",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return html.Div(blocks, className="etapes-mobile")
|
||||||
|
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.H2(NAME),
|
||||||
|
dcc.Markdown(
|
||||||
|
"Un marché public passe par plusieurs étapes. À chacune, des "
|
||||||
|
"données peuvent être publiées — selon le montant du marché et "
|
||||||
|
"des obligations réglementaires. Ce graphique situe les "
|
||||||
|
"principales publications de données par **étape** (de haut en "
|
||||||
|
"bas) et par **seuil** (de gauche à droite, en euros hors taxes)."
|
||||||
|
),
|
||||||
|
build_chart(),
|
||||||
|
build_mobile(),
|
||||||
|
dcc.Store(id="etapes-selected", data=None),
|
||||||
|
html.Div(id="etapes-detail", className="etapes-detail"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"**À noter :** l'axe horizontal n'est pas linéaire — les seuils "
|
||||||
|
"sont espacés régulièrement pour rester lisibles. Les étapes "
|
||||||
|
"*Contrat* et *Paiement* n'ont aujourd'hui aucune donnée publiée "
|
||||||
|
"en open data.",
|
||||||
|
className="etapes-note",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("etapes-detail", "children"),
|
||||||
|
Output("etapes-selected", "data"),
|
||||||
|
[Input(id_, "n_clicks") for id_ in _BAR_IDS + _STAGE_IDS],
|
||||||
|
State("etapes-selected", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def _show_detail(*args):
|
||||||
|
current = args[-1]
|
||||||
|
triggered = ctx.triggered_id
|
||||||
|
if triggered == current:
|
||||||
|
return None, None
|
||||||
|
content = ALL_CONTENT.get(triggered)
|
||||||
|
if content is None:
|
||||||
|
return dcc.Markdown(f"*Fiche en cours de rédaction.* {triggered}"), triggered
|
||||||
|
return dcc.Markdown(content), triggered
|
||||||
+3
-2
@@ -109,7 +109,7 @@ def update_marche_info(marche, titulaires):
|
|||||||
column_object = DATA_SCHEMA.get(col)
|
column_object = DATA_SCHEMA.get(col)
|
||||||
column_name = column_object.get("title") if column_object else col
|
column_name = column_object.get("title") if column_object else col
|
||||||
|
|
||||||
if marche[col]:
|
if marche and col in marche:
|
||||||
if col == "acheteur_nom":
|
if col == "acheteur_nom":
|
||||||
value = html.A(
|
value = html.A(
|
||||||
href=f"/acheteurs/{marche['acheteur_id']}",
|
href=f"/acheteurs/{marche['acheteur_id']}",
|
||||||
@@ -134,6 +134,7 @@ def update_marche_info(marche, titulaires):
|
|||||||
"considerationsSociales",
|
"considerationsSociales",
|
||||||
"considerationsEnvironnementales",
|
"considerationsEnvironnementales",
|
||||||
]
|
]
|
||||||
|
and col in marche
|
||||||
and "," in marche[col]
|
and "," in marche[col]
|
||||||
):
|
):
|
||||||
col_values = marche[col].split(", ")
|
col_values = marche[col].split(", ")
|
||||||
@@ -243,7 +244,7 @@ def get_marche_jsonld(marche, titulaires) -> str:
|
|||||||
titulaire.get("titulaire_id"),
|
titulaire.get("titulaire_id"),
|
||||||
org_name=titulaire.get("titulaire_nom"),
|
org_name=titulaire.get("titulaire_nom"),
|
||||||
org_type="titulaire",
|
org_type="titulaire",
|
||||||
type_org_id=titulaire.get("titulaire_typeIdentifiant"),
|
type_org_id=titulaire.get("titulaire_typeIdentifiant", "SIRET"),
|
||||||
),
|
),
|
||||||
"orderedItem": {
|
"orderedItem": {
|
||||||
"@type": type_order,
|
"@type": type_order,
|
||||||
|
|||||||
+31
-11
@@ -16,10 +16,11 @@ from dash import (
|
|||||||
register_page,
|
register_page,
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.db import query_marches, schema
|
from src.db import schema
|
||||||
from src.figures import (
|
from src.figures import (
|
||||||
DataTable,
|
DataTable,
|
||||||
get_barchart_sources,
|
get_barchart_sources,
|
||||||
|
get_considerations_card_content,
|
||||||
get_dashboard_summary_table,
|
get_dashboard_summary_table,
|
||||||
get_distance_histogram,
|
get_distance_histogram,
|
||||||
get_duplicate_matrix,
|
get_duplicate_matrix,
|
||||||
@@ -508,6 +509,11 @@ Alors, on fait comment ?
|
|||||||
size="xl",
|
size="xl",
|
||||||
),
|
),
|
||||||
# DataTable
|
# DataTable
|
||||||
|
dcc.Loading(
|
||||||
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
|
id="loading-statistques",
|
||||||
|
type="default",
|
||||||
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
className="marches_table",
|
className="marches_table",
|
||||||
children=DataTable(
|
children=DataTable(
|
||||||
@@ -517,8 +523,12 @@ Alors, on fait comment ?
|
|||||||
sort_action="custom",
|
sort_action="custom",
|
||||||
filter_action="custom",
|
filter_action="custom",
|
||||||
hidden_columns=[],
|
hidden_columns=[],
|
||||||
columns=[{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS],
|
columns=[
|
||||||
|
{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -664,10 +674,8 @@ def _compute_dashboard_children(filter_params_normalized: tuple):
|
|||||||
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
|
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
lff: pl.LazyFrame = query_marches().lazy()
|
dff = prepare_dashboard_data(**filter_params)
|
||||||
lff = prepare_dashboard_data(lff=lff, **filter_params)
|
lff = dff.lazy()
|
||||||
|
|
||||||
dff = lff.collect(engine="streaming")
|
|
||||||
|
|
||||||
df_per_uid = (
|
df_per_uid = (
|
||||||
dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first())
|
dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first())
|
||||||
@@ -715,6 +723,15 @@ def _compute_dashboard_children(filter_params_normalized: tuple):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
considerations_content = get_considerations_card_content(lff)
|
||||||
|
cards.append(
|
||||||
|
make_card(
|
||||||
|
title="Considérations sociales et environnementales",
|
||||||
|
subtitle="part des marchés concernés",
|
||||||
|
fig=considerations_content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
distance_histogram = get_distance_histogram(lff)
|
distance_histogram = get_distance_histogram(lff)
|
||||||
cards.append(
|
cards.append(
|
||||||
make_card(
|
make_card(
|
||||||
@@ -788,13 +805,13 @@ def update_dashboard_cards(*filter_values):
|
|||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def download_observatoire(_n_clicks, filter_params, hidden_columns):
|
def download_observatoire(_n_clicks, filter_params, hidden_columns):
|
||||||
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||||
|
|
||||||
if hidden_columns:
|
if hidden_columns:
|
||||||
lff = lff.drop(hidden_columns)
|
dff = dff.drop(hidden_columns)
|
||||||
|
|
||||||
def to_bytes(buffer):
|
def to_bytes(buffer):
|
||||||
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
|
dff.write_excel(buffer, worksheet="DECP")
|
||||||
|
|
||||||
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
|
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
|
||||||
@@ -817,6 +834,9 @@ def toggle_montant_modal(n_triggers, _close):
|
|||||||
prevent_initial_call=False,
|
prevent_initial_call=False,
|
||||||
)
|
)
|
||||||
def add_organization_name_in_title(acheteur_id, titulaire_id):
|
def add_organization_name_in_title(acheteur_id, titulaire_id):
|
||||||
|
acheteur_id = acheteur_id.replace(" ", "") if acheteur_id else None
|
||||||
|
titulaire_id = titulaire_id.replace(" ", "") if titulaire_id else None
|
||||||
|
|
||||||
def lookup_nom(df_org, id_col, nom_col, org_id):
|
def lookup_nom(df_org, id_col, nom_col, org_id):
|
||||||
match = df_org.filter(pl.col(id_col) == org_id)
|
match = df_org.filter(pl.col(id_col) == org_id)
|
||||||
return match[nom_col].item(0) if match.height >= 1 else None
|
return match[nom_col].item(0) if match.height >= 1 else None
|
||||||
@@ -879,10 +899,10 @@ def populate_preview_table(
|
|||||||
if not is_open:
|
if not is_open:
|
||||||
return (no_update,) * 9
|
return (no_update,) * 9
|
||||||
|
|
||||||
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||||
|
|
||||||
return prepare_table_data(
|
return prepare_table_data(
|
||||||
lff,
|
dff.lazy(),
|
||||||
data_timestamp,
|
data_timestamp,
|
||||||
filter_query,
|
filter_query,
|
||||||
page_current,
|
page_current,
|
||||||
|
|||||||
+24
-12
@@ -21,7 +21,7 @@ from dash import (
|
|||||||
|
|
||||||
from src.db import query_marches, schema
|
from src.db import query_marches, schema
|
||||||
from src.figures import DataTable, make_column_picker
|
from src.figures import DataTable, make_column_picker
|
||||||
from src.utils import logger
|
from src.utils import get_data_update_timestamp, logger
|
||||||
from src.utils.seo import META_CONTENT
|
from src.utils.seo import META_CONTENT
|
||||||
from src.utils.table import (
|
from src.utils.table import (
|
||||||
COLUMNS,
|
COLUMNS,
|
||||||
@@ -33,9 +33,16 @@ from src.utils.table import (
|
|||||||
)
|
)
|
||||||
from src.utils.tracking import track_search
|
from src.utils.tracking import track_search
|
||||||
|
|
||||||
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
update_date_timestamp = get_data_update_timestamp(
|
||||||
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
os.getenv("DATA_FILE_PARQUET_PATH", ""),
|
||||||
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
os.getenv("DUCKDB_PATH", "./decp.duckdb"),
|
||||||
|
)
|
||||||
|
if update_date_timestamp is not None:
|
||||||
|
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||||
|
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||||
|
else:
|
||||||
|
update_date = "date inconnue"
|
||||||
|
update_date_iso = ""
|
||||||
|
|
||||||
|
|
||||||
NAME = "Tableau"
|
NAME = "Tableau"
|
||||||
@@ -117,7 +124,11 @@ layout = [
|
|||||||
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432",
|
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}",
|
**(
|
||||||
|
{"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}"}
|
||||||
|
if update_date_iso
|
||||||
|
else {}
|
||||||
|
),
|
||||||
"spatialCoverage": {
|
"spatialCoverage": {
|
||||||
"@type": "Place",
|
"@type": "Place",
|
||||||
"address": {"countryCode": "FR"},
|
"address": {"countryCode": "FR"},
|
||||||
@@ -163,18 +174,19 @@ layout = [
|
|||||||
|
|
||||||
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
||||||
|
|
||||||
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules).
|
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est pas sensible à la casse (majuscules/minuscules) et est sensbible à l'accentuation.
|
||||||
- Exemple : `rennes` retourne "RENNES METROPOLE".
|
- `rennes` => le texte contient "rennes"
|
||||||
|
- `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole"
|
||||||
|
- `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte
|
||||||
|
- `metropole+rennes` => le texte contient "metropole rennes", collé et dans cet ordre
|
||||||
|
- `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri"
|
||||||
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
|
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
|
||||||
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
|
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
|
||||||
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
|
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
|
||||||
- soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
- soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
||||||
- Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples :
|
- Champs date (Date de notification, ...) :
|
||||||
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
||||||
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022".
|
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022"
|
||||||
- Pour les champs textuels et les champs dates :
|
|
||||||
- pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`)
|
|
||||||
- pour chercher du texte qui **finit par** votre texte, entrez `*texte`
|
|
||||||
|
|
||||||
Vous pouvez filtrer plusieurs colonnes à la fois.
|
Vous pouvez filtrer plusieurs colonnes à la fois.
|
||||||
|
|
||||||
|
|||||||
@@ -263,8 +263,15 @@ def update_titulaire_infos(url):
|
|||||||
if data_etablissement:
|
if data_etablissement:
|
||||||
data_etablissement = data_etablissement[0]
|
data_etablissement = data_etablissement[0]
|
||||||
|
|
||||||
|
# Extraction du code département à partir du code postal
|
||||||
|
code_postal = data_etablissement.get("code_postal", "")
|
||||||
|
departement_code = code_postal[:2] if code_postal else None
|
||||||
|
|
||||||
|
# Création de la carte avec le code département pour un centrage approprié
|
||||||
titulaire_map = point_on_map(
|
titulaire_map = point_on_map(
|
||||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
data_etablissement["latitude"],
|
||||||
|
data_etablissement["longitude"],
|
||||||
|
departement_code,
|
||||||
)
|
)
|
||||||
code_departement, nom_departement, nom_region = get_departement_region(
|
code_departement, nom_departement, nom_region = get_departement_region(
|
||||||
data_etablissement["code_postal"]
|
data_etablissement["code_postal"]
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.utils.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
@cache.memoize()
|
||||||
|
def get_last_modified(parquet_path: str) -> float:
|
||||||
|
logger.info("Récupération de la date de modification des données...")
|
||||||
|
logging.getLogger("httpx").setLevel("WARNING")
|
||||||
|
if parquet_path.startswith("http"):
|
||||||
|
last_modified = httpx.head(
|
||||||
|
url=parquet_path,
|
||||||
|
follow_redirects=True,
|
||||||
|
).headers["last-modified"]
|
||||||
|
last_modified = datetime.strptime(last_modified, "%a, %d %b %Y %X %Z").strftime(
|
||||||
|
"%s"
|
||||||
|
)
|
||||||
|
return float(last_modified)
|
||||||
|
parquet_local_path = Path(parquet_path)
|
||||||
|
return parquet_local_path.stat().st_mtime
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
@@ -17,3 +41,19 @@ DOMAIN_NAME = (
|
|||||||
if os.getenv("DEVELOPMENT", "False").lower() == "true"
|
if os.getenv("DEVELOPMENT", "False").lower() == "true"
|
||||||
else "decp.info"
|
else "decp.info"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
+74
-128
@@ -2,18 +2,18 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
|
import httpx
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from httpx import HTTPError, get
|
from httpx import HTTPError, get
|
||||||
|
|
||||||
from src.db import get_cursor, schema
|
from src.db import get_cursor, query_marches, schema
|
||||||
from src.utils import logger
|
from src.utils import logger
|
||||||
|
|
||||||
logging.getLogger("httpx").setLevel("WARNING")
|
logging.getLogger("httpx").setLevel("WARNING")
|
||||||
|
|
||||||
|
|
||||||
def get_annuaire_data(siret: str) -> dict:
|
def get_annuaire_data(siret: str) -> dict | None:
|
||||||
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||||
try:
|
try:
|
||||||
response = get(url).raise_for_status()
|
response = get(url).raise_for_status()
|
||||||
@@ -52,7 +52,8 @@ def get_departements_geojson() -> dict:
|
|||||||
return geojson
|
return geojson
|
||||||
|
|
||||||
|
|
||||||
def get_departement_region(code_postal):
|
def get_departement_region(code_postal: str | None):
|
||||||
|
if code_postal:
|
||||||
if code_postal > "97000":
|
if code_postal > "97000":
|
||||||
code_departement = code_postal[:3]
|
code_departement = code_postal[:3]
|
||||||
else:
|
else:
|
||||||
@@ -60,138 +61,83 @@ def get_departement_region(code_postal):
|
|||||||
nom_departement = DEPARTEMENTS[code_departement]["departement"]
|
nom_departement = DEPARTEMENTS[code_departement]["departement"]
|
||||||
nom_region = DEPARTEMENTS[code_departement]["region"]
|
nom_region = DEPARTEMENTS[code_departement]["region"]
|
||||||
return code_departement, nom_departement, nom_region
|
return code_departement, nom_departement, nom_region
|
||||||
|
return "", "", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_schema(raw) -> dict | None:
|
||||||
|
if (
|
||||||
|
isinstance(raw, dict)
|
||||||
|
and isinstance(raw.get("fields"), list)
|
||||||
|
and raw["fields"]
|
||||||
|
and all(isinstance(c, dict) and "name" in c for c in raw["fields"])
|
||||||
|
):
|
||||||
|
return raw
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_remote_schema(url: str | None) -> dict | None:
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = get(url, follow_redirects=True).raise_for_status().json()
|
||||||
|
except (
|
||||||
|
httpx.HTTPError,
|
||||||
|
httpx.TransportError,
|
||||||
|
httpx.TimeoutException,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
) as e:
|
||||||
|
logger.error(f"Schéma distant indisponible ({url}) : {e}")
|
||||||
|
return None
|
||||||
|
return _validate_schema(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_schema_file(path: str) -> dict | None:
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.error(f"Schéma local illisible ({path}) : {e}")
|
||||||
|
return None
|
||||||
|
return _validate_schema(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_schema_cache(raw: dict, path: str) -> None:
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(raw, f)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_data_schema() -> dict:
|
def get_data_schema() -> dict:
|
||||||
# Récupération du schéma des données tabulaires
|
cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
|
||||||
path = os.getenv("DATA_SCHEMA_PATH")
|
raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH"))
|
||||||
if path.startswith("http"):
|
if raw is not None:
|
||||||
original_schema: dict = get(
|
_persist_schema_cache(raw, cache_path)
|
||||||
os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True
|
|
||||||
).json()
|
|
||||||
elif os.path.exists(path):
|
|
||||||
with open(path) as f:
|
|
||||||
original_schema: dict = json.load(f)
|
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Chemin vers le schéma invalide: {path}")
|
raw = _load_schema_file(cache_path)
|
||||||
|
if raw is None:
|
||||||
new_schema = OrderedDict()
|
raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
|
||||||
|
return OrderedDict((c["name"], c) for c in raw["fields"])
|
||||||
for col in original_schema["fields"]:
|
|
||||||
new_schema[col["name"]] = col
|
|
||||||
|
|
||||||
return new_schema
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_dashboard_data(
|
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
|
||||||
lff: pl.LazyFrame,
|
"""Exécute la requête DuckDB filtrée pour le tableau de bord.
|
||||||
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,
|
|
||||||
) -> pl.LazyFrame:
|
|
||||||
if dashboard_year:
|
|
||||||
lff = lff.filter(pl.col("dateNotification").dt.year() == int(dashboard_year))
|
|
||||||
else:
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("dateNotification") > (datetime.now() - timedelta(days=365))
|
|
||||||
)
|
|
||||||
|
|
||||||
if dashboard_acheteur_id:
|
Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble
|
||||||
lff = lff.filter(pl.col("acheteur_id").str.contains(dashboard_acheteur_id))
|
correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame
|
||||||
else:
|
appellent `.lazy()` sur le résultat.
|
||||||
if dashboard_acheteur_categorie:
|
"""
|
||||||
lff = lff.filter(
|
from src.utils.table_sql import dashboard_filters_to_sql
|
||||||
pl.col("acheteur_categorie") == dashboard_acheteur_categorie
|
|
||||||
)
|
|
||||||
if dashboard_acheteur_departement_code:
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("acheteur_departement_code").is_in(
|
|
||||||
dashboard_acheteur_departement_code
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if dashboard_titulaire_id:
|
where_sql, params = dashboard_filters_to_sql(**filter_params)
|
||||||
lff = lff.filter(pl.col("titulaire_id").str.contains(dashboard_titulaire_id))
|
return query_marches(where_sql=where_sql, params=params)
|
||||||
else:
|
|
||||||
if dashboard_titulaire_categorie:
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("titulaire_categorie") == dashboard_titulaire_categorie
|
|
||||||
)
|
|
||||||
if dashboard_titulaire_departement_code:
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("titulaire_departement_code").is_in(
|
|
||||||
dashboard_titulaire_departement_code
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if dashboard_marche_type:
|
|
||||||
lff = lff.filter(pl.col("type") == dashboard_marche_type)
|
|
||||||
|
|
||||||
if dashboard_marche_objet:
|
|
||||||
lff = lff.filter(pl.col("objet").str.contains(f"(?i){dashboard_marche_objet}"))
|
|
||||||
|
|
||||||
if dashboard_marche_code_cpv:
|
|
||||||
lff = lff.filter(pl.col("codeCPV").str.starts_with(dashboard_marche_code_cpv))
|
|
||||||
|
|
||||||
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
|
|
||||||
lff = lff.filter(pl.col("marcheInnovant") == dashboard_marche_innovant)
|
|
||||||
|
|
||||||
if (
|
|
||||||
dashboard_marche_sous_traitance_declaree
|
|
||||||
and dashboard_marche_sous_traitance_declaree != "all"
|
|
||||||
):
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("sousTraitanceDeclaree") == dashboard_marche_sous_traitance_declaree
|
|
||||||
)
|
|
||||||
|
|
||||||
if dashboard_marche_techniques:
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("techniques")
|
|
||||||
.str.split(", ")
|
|
||||||
.list.set_intersection(dashboard_marche_techniques)
|
|
||||||
.list.len()
|
|
||||||
> 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if dashboard_marche_considerations_sociales:
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("considerationsSociales")
|
|
||||||
.str.split(", ")
|
|
||||||
.list.set_intersection(dashboard_marche_considerations_sociales)
|
|
||||||
.list.len()
|
|
||||||
> 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if dashboard_marche_considerations_environnementales:
|
|
||||||
lff = lff.filter(
|
|
||||||
pl.col("considerationsEnvironnementales")
|
|
||||||
.str.split(", ")
|
|
||||||
.list.set_intersection(dashboard_marche_considerations_environnementales)
|
|
||||||
.list.len()
|
|
||||||
> 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if dashboard_montant_min is not None:
|
|
||||||
lff = lff.filter(pl.col("montant") >= dashboard_montant_min)
|
|
||||||
|
|
||||||
if dashboard_montant_max is not None:
|
|
||||||
lff = lff.filter(pl.col("montant") <= dashboard_montant_max)
|
|
||||||
|
|
||||||
return lff
|
|
||||||
|
|
||||||
|
|
||||||
def build_org_frame(org_type: str) -> pl.DataFrame:
|
def build_org_frame(org_type: str) -> pl.DataFrame:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic
|
|||||||
address = None
|
address = None
|
||||||
if type_org_id.lower() == "siret" and len(org_id) == 14:
|
if type_org_id.lower() == "siret" and len(org_id) == 14:
|
||||||
annuaire_data = get_annuaire_data(org_id)
|
annuaire_data = get_annuaire_data(org_id)
|
||||||
|
if not annuaire_data:
|
||||||
|
return {}
|
||||||
annuaire_address = annuaire_data["matching_etablissements"][0]
|
annuaire_address = annuaire_data["matching_etablissements"][0]
|
||||||
code_postal = annuaire_address["code_postal"]
|
code_postal = annuaire_address["code_postal"]
|
||||||
commune = annuaire_address["libelle_commune"]
|
commune = annuaire_address["libelle_commune"]
|
||||||
|
|||||||
+27
-5
@@ -4,6 +4,7 @@ import uuid
|
|||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import no_update
|
from dash import no_update
|
||||||
from polars import selectors as cs
|
from polars import selectors as cs
|
||||||
|
from unidecode import unidecode
|
||||||
|
|
||||||
from src.db import count_marches, count_unique_marches, query_marches, schema
|
from src.db import count_marches, count_unique_marches, query_marches, schema
|
||||||
from src.utils import logger
|
from src.utils import logger
|
||||||
@@ -154,6 +155,8 @@ def normalize_sort_by(sort_by) -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
def format_number(number) -> str:
|
def format_number(number) -> str:
|
||||||
|
if not number:
|
||||||
|
return ""
|
||||||
number = "{:,}".format(number).replace(",", " ")
|
number = "{:,}".format(number).replace(",", " ")
|
||||||
return number
|
return number
|
||||||
|
|
||||||
@@ -213,6 +216,25 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
|||||||
return dff
|
return dff
|
||||||
|
|
||||||
|
|
||||||
|
_ACCENT_REPLACEMENTS = [
|
||||||
|
("[éèêëÉÈÊË]", "e"),
|
||||||
|
("[àâäÀÂÄ]", "a"),
|
||||||
|
("[ùûüÙÛÜ]", "u"),
|
||||||
|
("[îïÎÏ]", "i"),
|
||||||
|
("[ôöÔÖ]", "o"),
|
||||||
|
("[çÇ]", "c"),
|
||||||
|
("[ñÑ]", "n"),
|
||||||
|
("[æÆ]", "ae"),
|
||||||
|
("[œŒ]", "oe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _deaccent_col(expr: pl.Expr) -> pl.Expr:
|
||||||
|
for pattern, replacement in _ACCENT_REPLACEMENTS:
|
||||||
|
expr = expr.str.replace_all(pattern, replacement)
|
||||||
|
return expr
|
||||||
|
|
||||||
|
|
||||||
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
|
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
|
||||||
_schema = lff.collect_schema()
|
_schema = lff.collect_schema()
|
||||||
filtering_expressions = filter_query.split(" && ")
|
filtering_expressions = filter_query.split(" && ")
|
||||||
@@ -254,17 +276,17 @@ def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
|
|||||||
elif operator == "contains":
|
elif operator == "contains":
|
||||||
if col_type in ["String", "Date"] and isinstance(filter_value, str):
|
if col_type in ["String", "Date"] and isinstance(filter_value, str):
|
||||||
filter_value = filter_value.strip('"')
|
filter_value = filter_value.strip('"')
|
||||||
|
normalized_value = unidecode(filter_value)
|
||||||
|
col_expr = _deaccent_col(pl.col(col_name))
|
||||||
if filter_value.endswith("*"):
|
if filter_value.endswith("*"):
|
||||||
lff = lff.filter(
|
lff = lff.filter(
|
||||||
pl.col(col_name).str.starts_with(filter_value[:-1])
|
col_expr.str.starts_with(normalized_value[:-1])
|
||||||
)
|
)
|
||||||
elif filter_value.startswith("*"):
|
elif filter_value.startswith("*"):
|
||||||
lff = lff.filter(
|
lff = lff.filter(col_expr.str.ends_with(normalized_value[1:]))
|
||||||
pl.col(col_name).str.ends_with(filter_value[1:])
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
lff = lff.filter(
|
lff = lff.filter(
|
||||||
pl.col(col_name).str.contains("(?i)" + filter_value)
|
col_expr.str.contains("(?i)" + normalized_value)
|
||||||
)
|
)
|
||||||
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
lff = lff.filter(pl.col(col_name) == filter_value)
|
lff = lff.filter(pl.col(col_name) == filter_value)
|
||||||
|
|||||||
+149
-10
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from src.utils import logger
|
from src.utils import logger
|
||||||
@@ -55,17 +57,19 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list
|
|||||||
value = raw_value.strip('"')
|
value = raw_value.strip('"')
|
||||||
|
|
||||||
if operator == "contains":
|
if operator == "contains":
|
||||||
if value.endswith("*") and not value.startswith("*"):
|
if col_is_date:
|
||||||
like = value[:-1] + "%"
|
target = f"CAST({quoted_col} AS VARCHAR)"
|
||||||
elif value.startswith("*") and not value.endswith("*"):
|
|
||||||
like = "%" + value[1:]
|
if col_name in ("acheteur_id", "titulaire_id"):
|
||||||
else:
|
value = value.replace(" ", "")
|
||||||
like = "%" + value + "%"
|
where_clause, param_list = tokenize_text_filter(
|
||||||
target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col
|
col_name, value, col_is_date
|
||||||
clauses.append(
|
|
||||||
f"{quoted_col} IS NOT NULL AND {target} <> '' AND {target} ILIKE ?"
|
|
||||||
)
|
)
|
||||||
params.append(like)
|
clauses.append(where_clause)
|
||||||
|
params.extend(param_list)
|
||||||
|
logger.debug(params)
|
||||||
|
continue
|
||||||
|
|
||||||
elif operator in (">", "<"):
|
elif operator in (">", "<"):
|
||||||
target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col
|
target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col
|
||||||
clauses.append(f"{quoted_col} IS NOT NULL AND {target} {operator} ?")
|
clauses.append(f"{quoted_col} IS NOT NULL AND {target} {operator} ?")
|
||||||
@@ -100,3 +104,138 @@ def sort_by_to_sql(sort_by: list[dict] | None, schema: pl.Schema) -> str:
|
|||||||
fragments.append(f'"{col}" {direction.upper()} NULLS LAST')
|
fragments.append(f'"{col}" {direction.upper()} NULLS LAST')
|
||||||
|
|
||||||
return ", ".join(fragments)
|
return ", ".join(fragments)
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if dashboard_year:
|
||||||
|
clauses.append('YEAR("dateNotification") = ?')
|
||||||
|
params.append(int(dashboard_year))
|
||||||
|
else:
|
||||||
|
clauses.append('"dateNotification" > ?')
|
||||||
|
params.append(datetime.now() - timedelta(days=365))
|
||||||
|
|
||||||
|
if dashboard_acheteur_id:
|
||||||
|
dashboard_acheteur_id = dashboard_acheteur_id.replace(" ", "")
|
||||||
|
clauses.append('"acheteur_id" LIKE ?')
|
||||||
|
params.append(f"%{dashboard_acheteur_id}%")
|
||||||
|
else:
|
||||||
|
if dashboard_acheteur_categorie:
|
||||||
|
clauses.append('"acheteur_categorie" = ?')
|
||||||
|
params.append(dashboard_acheteur_categorie)
|
||||||
|
if dashboard_acheteur_departement_code:
|
||||||
|
placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code))
|
||||||
|
clauses.append(f'"acheteur_departement_code" IN ({placeholders})')
|
||||||
|
params.extend(dashboard_acheteur_departement_code)
|
||||||
|
|
||||||
|
if dashboard_titulaire_id:
|
||||||
|
dashboard_titulaire_id = dashboard_titulaire_id.replace(" ", "")
|
||||||
|
clauses.append('"titulaire_id" LIKE ?')
|
||||||
|
params.append(f"%{dashboard_titulaire_id}%")
|
||||||
|
else:
|
||||||
|
if dashboard_titulaire_categorie:
|
||||||
|
clauses.append('"titulaire_categorie" = ?')
|
||||||
|
params.append(dashboard_titulaire_categorie)
|
||||||
|
if dashboard_titulaire_departement_code:
|
||||||
|
placeholders = ", ".join(["?"] * len(dashboard_titulaire_departement_code))
|
||||||
|
clauses.append(f'"titulaire_departement_code" IN ({placeholders})')
|
||||||
|
params.extend(dashboard_titulaire_departement_code)
|
||||||
|
|
||||||
|
if dashboard_marche_type:
|
||||||
|
clauses.append('"type" = ?')
|
||||||
|
params.append(dashboard_marche_type)
|
||||||
|
|
||||||
|
if dashboard_marche_objet:
|
||||||
|
where_clause, param_list = tokenize_text_filter("objet", dashboard_marche_objet)
|
||||||
|
clauses.append(where_clause)
|
||||||
|
params.extend(param_list)
|
||||||
|
|
||||||
|
if dashboard_marche_code_cpv:
|
||||||
|
clauses.append('"codeCPV" LIKE ?')
|
||||||
|
params.append(f"{dashboard_marche_code_cpv}%")
|
||||||
|
|
||||||
|
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
|
||||||
|
clauses.append('"marcheInnovant" = ?')
|
||||||
|
params.append(dashboard_marche_innovant)
|
||||||
|
|
||||||
|
if (
|
||||||
|
dashboard_marche_sous_traitance_declaree
|
||||||
|
and dashboard_marche_sous_traitance_declaree != "all"
|
||||||
|
):
|
||||||
|
clauses.append('"sousTraitanceDeclaree" = ?')
|
||||||
|
params.append(dashboard_marche_sous_traitance_declaree)
|
||||||
|
|
||||||
|
if dashboard_marche_techniques:
|
||||||
|
clauses.append("list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])")
|
||||||
|
params.append(list(dashboard_marche_techniques))
|
||||||
|
|
||||||
|
if dashboard_marche_considerations_sociales:
|
||||||
|
clauses.append(
|
||||||
|
"list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
params.append(list(dashboard_marche_considerations_sociales))
|
||||||
|
|
||||||
|
if dashboard_marche_considerations_environnementales:
|
||||||
|
clauses.append(
|
||||||
|
"list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
params.append(list(dashboard_marche_considerations_environnementales))
|
||||||
|
|
||||||
|
if dashboard_montant_min is not None:
|
||||||
|
clauses.append('"montant" >= ?')
|
||||||
|
params.append(dashboard_montant_min)
|
||||||
|
|
||||||
|
if dashboard_montant_max is not None:
|
||||||
|
clauses.append('"montant" <= ?')
|
||||||
|
params.append(dashboard_montant_max)
|
||||||
|
|
||||||
|
return " AND ".join(clauses), params
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize_text_filter(
|
||||||
|
column: str, text: str, col_is_date: bool = False
|
||||||
|
) -> tuple[str, list]:
|
||||||
|
terms = text.split()
|
||||||
|
# si col_is_date alors le deuxième doit être casté en VARCHAR
|
||||||
|
if col_is_date:
|
||||||
|
quoted_col = f'CAST("{column}" AS VARCHAR)'
|
||||||
|
else:
|
||||||
|
quoted_col = f'"{column}"'
|
||||||
|
|
||||||
|
conditions = [f'"{column}" IS NOT NULL', f"{quoted_col} <> ''"]
|
||||||
|
|
||||||
|
params = []
|
||||||
|
|
||||||
|
for term in terms:
|
||||||
|
conditions.append(f"{quoted_col} ILIKE ?")
|
||||||
|
|
||||||
|
if term.startswith("*") or term.endswith("*"):
|
||||||
|
params.append(term.replace("*", "%"))
|
||||||
|
elif "+" in term:
|
||||||
|
params.append(f"%{term.replace('+', ' ')}%")
|
||||||
|
else:
|
||||||
|
params.append(f"%{term}%")
|
||||||
|
|
||||||
|
where_clause = " AND ".join(conditions)
|
||||||
|
return where_clause, params
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Benchmark comparatif de l'endpoint /data : decp.info vs data.gouv.fr.
|
||||||
|
|
||||||
|
Les deux APIs partagent le même schéma de requête (mêmes opérateurs), donc
|
||||||
|
chaque scénario est envoyé à l'identique aux deux et les temps de réponse
|
||||||
|
sont comparés côte à côte.
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python tests/api/benchmark.py --token decpinfo_xxx
|
||||||
|
python tests/api/benchmark.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx
|
||||||
|
python tests/api/benchmark.py --decp-only --token decpinfo_xxx --runs 20
|
||||||
|
|
||||||
|
Par défaut, --url pointe vers la production decp.info ; data.gouv.fr est
|
||||||
|
interrogé sans authentification.
|
||||||
|
|
||||||
|
AVERTISSEMENT : les deux bases n'ont pas le même volume (data.gouv.fr ~3M
|
||||||
|
lignes, decp.info ~1,5M). C'est une comparaison d'implémentation, pas à
|
||||||
|
volume égal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DECP_DEFAULT_URL = "https://decp.info/api/v1/data"
|
||||||
|
DATAGOUV_DEFAULT_URL = (
|
||||||
|
"https://tabular-api.data.gouv.fr/api/resources/"
|
||||||
|
"22847056-61df-452d-837d-8b8ceadbfc52/data/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chaque scénario : liste de (clé, valeur). valeur=None → drapeau nu (sans `=`),
|
||||||
|
# requis par data.gouv.fr pour les opérateurs d'agrégation et isnull.
|
||||||
|
SCENARIOS: list[dict] = [
|
||||||
|
{
|
||||||
|
"name": "sans filtre (page 1, 50 résultats)",
|
||||||
|
"params": [("page", "1"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtre __exact sur département",
|
||||||
|
"params": [("acheteur_departement_code__exact", "44"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtre __differs sur département",
|
||||||
|
"params": [("acheteur_departement_code__differs", "44"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtre __contains sur objet",
|
||||||
|
"params": [("objet__contains", "informatique"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtre __greater sur date",
|
||||||
|
"params": [("dateNotification__greater", "2024-01-01"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtre __strictly_greater sur montant",
|
||||||
|
"params": [("montant__strictly_greater", "100000"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtre __in (CPV multiples)",
|
||||||
|
"params": [("codeCPV__in", "72000000,72200000"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtre __isnull sur montant",
|
||||||
|
"params": [("montant__isnull", None), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tri desc + colonnes sélectionnées",
|
||||||
|
"params": [
|
||||||
|
("dateNotification__sort", "desc"),
|
||||||
|
("columns", "uid,objet,montant,dateNotification"),
|
||||||
|
("page_size", "50"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filtres combinés",
|
||||||
|
"params": [
|
||||||
|
("acheteur_departement_code__exact", "75"),
|
||||||
|
("dateNotification__greater", "2023-01-01"),
|
||||||
|
("montant__strictly_greater", "50000"),
|
||||||
|
("dateNotification__sort", "desc"),
|
||||||
|
("page_size", "50"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agrégation groupby + count",
|
||||||
|
"params": [
|
||||||
|
("acheteur_departement_code__groupby", None),
|
||||||
|
("uid__count", None),
|
||||||
|
("page_size", "100"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agrégation groupby + sum + avg",
|
||||||
|
"params": [
|
||||||
|
("acheteur_departement_code__groupby", None),
|
||||||
|
("montant__sum", None),
|
||||||
|
("montant__avg", None),
|
||||||
|
("page_size", "100"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page 2",
|
||||||
|
"params": [("page", "2"), ("page_size", "50")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "count_results=false (optim COUNT(*))",
|
||||||
|
"params": [("page_size", "50"), ("count_results", "false")],
|
||||||
|
"decp_only": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
COL_NAME = 40
|
||||||
|
COL_STAT = 9
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Target:
|
||||||
|
label: str
|
||||||
|
base_url: str
|
||||||
|
headers: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def build_query(params: list[tuple[str, str | None]]) -> str:
|
||||||
|
"""Construit la query string. valeur=None → clé nue (sans `=`)."""
|
||||||
|
parts = []
|
||||||
|
for key, value in params:
|
||||||
|
if value is None:
|
||||||
|
parts.append(key)
|
||||||
|
else:
|
||||||
|
parts.append(f"{key}={quote(str(value), safe=',:')}")
|
||||||
|
return "&".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(data: list[float], p: float) -> float:
|
||||||
|
if not data:
|
||||||
|
return float("nan")
|
||||||
|
sorted_data = sorted(data)
|
||||||
|
k = (len(sorted_data) - 1) * p / 100
|
||||||
|
lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1)
|
||||||
|
return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(target: Target, query: str, runs: int) -> dict | None:
|
||||||
|
"""Chauffe (1 requête non mesurée) puis chronomètre `runs` requêtes."""
|
||||||
|
url = f"{target.base_url}?{query}"
|
||||||
|
try:
|
||||||
|
warm = httpx.get(url, headers=target.headers, timeout=30)
|
||||||
|
last_status = warm.status_code
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
return {"error": str(exc), "status": 0}
|
||||||
|
|
||||||
|
timings: list[float] = []
|
||||||
|
for _ in range(runs):
|
||||||
|
try:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
resp = httpx.get(url, headers=target.headers, timeout=30)
|
||||||
|
timings.append((time.perf_counter() - t0) * 1000)
|
||||||
|
last_status = resp.status_code
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
return {"error": str(exc), "status": 0}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": last_status,
|
||||||
|
"median": percentile(timings, 50),
|
||||||
|
"p95": percentile(timings, 95),
|
||||||
|
"min": min(timings),
|
||||||
|
"max": max(timings),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_benchmark(targets: list[Target], decp_label: str, runs: int) -> None:
|
||||||
|
print("\nAVERTISSEMENT : volumes de données différents entre les deux APIs.")
|
||||||
|
print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n")
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for scenario in SCENARIOS:
|
||||||
|
query = build_query(scenario["params"])
|
||||||
|
decp_only = scenario.get("decp_only", False)
|
||||||
|
active = [t for t in targets if not (decp_only and t.label != decp_label)]
|
||||||
|
|
||||||
|
measures = {t.label: measure(t, query, runs) for t in active}
|
||||||
|
rows.append({"name": scenario["name"], "measures": measures})
|
||||||
|
|
||||||
|
bits = []
|
||||||
|
for t in active:
|
||||||
|
m = measures[t.label]
|
||||||
|
if "error" in m:
|
||||||
|
bits.append(f"{t.label}: ERREUR")
|
||||||
|
else:
|
||||||
|
bits.append(f"{t.label}: méd {m['median']:.0f}ms [{m['status']}]")
|
||||||
|
print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} " + " | ".join(bits))
|
||||||
|
|
||||||
|
_print_summary(rows, targets, decp_label)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(m: dict | None, key: str) -> str:
|
||||||
|
if m is None:
|
||||||
|
return "—"
|
||||||
|
if "error" in m:
|
||||||
|
return "ERR"
|
||||||
|
return f"{m[key]:.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(rows: list[dict], targets: list[Target], decp_label: str) -> None:
|
||||||
|
dg = next((t.label for t in targets if t.label != decp_label), None)
|
||||||
|
|
||||||
|
header = (
|
||||||
|
f"{'Scénario':<{COL_NAME}}"
|
||||||
|
f" {'DG méd':>{COL_STAT}} {'DG p95':>{COL_STAT}}"
|
||||||
|
f" {'decp méd':>{COL_STAT}} {'decp p95':>{COL_STAT}}"
|
||||||
|
f" {'ratio':>7}"
|
||||||
|
)
|
||||||
|
sep = "-" * len(header)
|
||||||
|
print(
|
||||||
|
f"\n{'=' * len(header)}\nRÉSUMÉ (ratio = decp / data.gouv.fr, <1 = decp plus rapide)"
|
||||||
|
)
|
||||||
|
print(f"{'=' * len(header)}\n{header}\n{sep}")
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
m_decp = row["measures"].get(decp_label)
|
||||||
|
m_dg = row["measures"].get(dg) if dg else None
|
||||||
|
|
||||||
|
ratio = "—"
|
||||||
|
if m_decp and m_dg and "error" not in m_decp and "error" not in m_dg:
|
||||||
|
if m_dg["median"] > 0:
|
||||||
|
ratio = f"{m_decp['median'] / m_dg['median']:.2f}"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{row['name'][:COL_NAME]:<{COL_NAME}}"
|
||||||
|
f" {_fmt(m_dg, 'median'):>{COL_STAT}} {_fmt(m_dg, 'p95'):>{COL_STAT}}"
|
||||||
|
f" {_fmt(m_decp, 'median'):>{COL_STAT}} {_fmt(m_decp, 'p95'):>{COL_STAT}}"
|
||||||
|
f" {ratio:>7}"
|
||||||
|
)
|
||||||
|
print(sep)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Benchmark comparatif decp.info vs data.gouv.fr (/data)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--url",
|
||||||
|
default=DECP_DEFAULT_URL,
|
||||||
|
help=f"Endpoint /data de decp.info (défaut : {DECP_DEFAULT_URL})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--datagouv-url",
|
||||||
|
default=DATAGOUV_DEFAULT_URL,
|
||||||
|
help="Endpoint /data/ de la ressource data.gouv.fr",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token",
|
||||||
|
default=None,
|
||||||
|
help="Token Bearer decp.info (format decpinfo_xxx)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--runs",
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help="Répétitions chronométrées par scénario (défaut : 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--decp-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Ne benchmarker que decp.info (saute data.gouv.fr)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.runs < 1:
|
||||||
|
print("--runs doit être ≥ 1", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
decp_label = "decp.info"
|
||||||
|
decp_headers = {"Authorization": f"Bearer {args.token}"} if args.token else {}
|
||||||
|
decp = Target(label=decp_label, base_url=args.url, headers=decp_headers)
|
||||||
|
|
||||||
|
targets = [decp]
|
||||||
|
if not args.decp_only:
|
||||||
|
# data.gouv.fr d'abord pour l'affichage côte à côte
|
||||||
|
targets.insert(0, Target(label="data.gouv.fr", base_url=args.datagouv_url))
|
||||||
|
|
||||||
|
run_benchmark(targets, decp_label, args.runs)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db(tmp_path, monkeypatch):
|
||||||
|
"""Une SQLite éphémère pour les tests qui modifient la DB."""
|
||||||
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
tokens_db.init_schema(db_path)
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client(monkeypatch, tmp_path):
|
||||||
|
"""Client Flask test avec USERS_DB_PATH éphémère et blueprint API monté."""
|
||||||
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from src.api import init_api, tokens_db, tracking
|
||||||
|
|
||||||
|
tokens_db.init_schema(db_path)
|
||||||
|
server = Flask(__name__)
|
||||||
|
init_api(server)
|
||||||
|
yield server.test_client(), db_path
|
||||||
|
tracking.stop_worker()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_token_header(api_client):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
_, db_path = api_client
|
||||||
|
token, _ = tokens_db.create_token(db_path, "test-token")
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from flask import Flask, g, jsonify
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.api.auth import require_token
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/protected")
|
||||||
|
@require_token
|
||||||
|
def protected():
|
||||||
|
return jsonify({"token_id": g.token_id})
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_header_returns_401(temp_db):
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get("/protected")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["message"] == "missing_token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_without_value_returns_401(temp_db):
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get("/protected", headers={"Authorization": "Bearer "})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["message"] == "missing_token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(temp_db):
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get(
|
||||||
|
"/protected", headers={"Authorization": "Bearer decpinfo_unknown"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["message"] == "invalid_token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoked_token_returns_401(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
tokens_db.revoke_token(temp_db, token_id)
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get(
|
||||||
|
"/protected", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["message"] == "revoked_token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_token_sets_g_and_calls_view(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get(
|
||||||
|
"/protected", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["token_id"] == token_id
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.db import aggregate_marches
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_groupby_count_returns_named_columns():
|
||||||
|
df = aggregate_marches(
|
||||||
|
select_sql='"acheteur_departement_code", COUNT("uid") AS "uid__count"',
|
||||||
|
group_by='"acheteur_departement_code"',
|
||||||
|
)
|
||||||
|
assert isinstance(df, pl.DataFrame)
|
||||||
|
assert df.columns == ["acheteur_departement_code", "uid__count"]
|
||||||
|
assert df["uid__count"].sum() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_global_without_groupby_returns_one_row():
|
||||||
|
df = aggregate_marches(select_sql='COUNT("uid") AS "uid__count"')
|
||||||
|
assert df.height == 1
|
||||||
|
assert df["uid__count"][0] > 0
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
def test_data_without_token_returns_401(api_client):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_default_pagination(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert set(body.keys()) >= {"data", "meta", "links"}
|
||||||
|
assert isinstance(body["data"], list)
|
||||||
|
assert len(body["data"]) <= 50 # default page_size
|
||||||
|
assert body["meta"]["page"] == 1
|
||||||
|
assert body["meta"]["page_size"] == 50
|
||||||
|
assert "total" in body["meta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_count_results_false_omits_total(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data?count_results=false", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "total" not in body["meta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_page_size_max_enforced(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data?page_size=5000", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_page_size_below_min_rejected(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data?page_size=0", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_pagination_links(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data?page=1&page_size=1", headers=valid_token_header)
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["links"]["prev"] is None
|
||||||
|
if body["meta"]["total"] > 1:
|
||||||
|
assert body["links"]["next"] is not None
|
||||||
|
assert "page=2" in body["links"]["next"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_filter_exact_string(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
# On choisit une valeur qui existe dans test.parquet : récupère via la 1re ligne
|
||||||
|
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
|
||||||
|
assert base["data"], "test.parquet vide ?"
|
||||||
|
uid = base["data"][0]["uid"]
|
||||||
|
|
||||||
|
resp = client.get(f"/api/v1/data?uid__exact={uid}", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert all(row["uid"] == uid for row in body["data"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_unknown_column_filter_returns_400(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?colonne_inexistante__exact=x",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_columns_selection(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?columns=uid,objet&page_size=3",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
for row in body["data"]:
|
||||||
|
assert set(row.keys()) == {"uid", "objet"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_columns_unknown_returns_400(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?columns=uid,foobar",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_sort_desc(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?dateNotification__sort=desc&page_size=5",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
dates = [
|
||||||
|
row["dateNotification"] for row in body["data"] if row.get("dateNotification")
|
||||||
|
]
|
||||||
|
assert dates == sorted(dates, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_differs_excludes_value(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
|
||||||
|
uid = base["data"][0]["uid"]
|
||||||
|
resp = client.get(f"/api/v1/data?uid__differs={uid}", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert all(row["uid"] != uid for row in body["data"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_aggregation_groupby_count(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?acheteur_departement_code__groupby&uid__count",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["data"], "agrégation vide ?"
|
||||||
|
for row in body["data"]:
|
||||||
|
assert set(row.keys()) == {"acheteur_departement_code", "uid__count"}
|
||||||
|
assert "total" not in body["meta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_aggregation_global_count(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/data?uid__count", headers=valid_token_header)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert len(body["data"]) == 1
|
||||||
|
assert "uid__count" in body["data"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_aggregation_with_filter(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?acheteur_departement_code__groupby&uid__count&montant__greater=0",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_aggregation_with_columns_returns_400(api_client, valid_token_header):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/data?uid__count&columns=uid",
|
||||||
|
headers=valid_token_header,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
def test_schema_accessible_without_token(api_client):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/schema")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_returns_fields(api_client):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/schema")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "fields" in data
|
||||||
|
assert isinstance(data["fields"], list)
|
||||||
|
assert len(data["fields"]) > 0
|
||||||
|
first = data["fields"][0]
|
||||||
|
assert set(first.keys()) >= {"name", "type", "title", "description"}
|
||||||
|
names = [f["name"] for f in data["fields"]]
|
||||||
|
assert "uid" in names
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.filters import AggregationSpec, FilterError, build_where, parse_aggregators
|
||||||
|
|
||||||
|
SCHEMA = pl.Schema(
|
||||||
|
{
|
||||||
|
"uid": pl.String,
|
||||||
|
"objet": pl.String,
|
||||||
|
"montant": pl.Float64,
|
||||||
|
"annee": pl.Int64,
|
||||||
|
"dateNotification": pl.Date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_filters_returns_true():
|
||||||
|
where, params, order = build_where([], SCHEMA)
|
||||||
|
assert where == "TRUE"
|
||||||
|
assert params == []
|
||||||
|
assert order is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_filter():
|
||||||
|
where, params, _ = build_where([("uid__exact", "abc")], SCHEMA)
|
||||||
|
assert where == '"uid" = ?'
|
||||||
|
assert params == ["abc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contains_filter_uses_like_wildcards():
|
||||||
|
where, params, _ = build_where([("objet__contains", "informatique")], SCHEMA)
|
||||||
|
assert where == '"objet" LIKE ?'
|
||||||
|
assert params == ["%informatique%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notcontains_filter():
|
||||||
|
where, params, _ = build_where([("objet__notcontains", "x")], SCHEMA)
|
||||||
|
assert where == '"objet" NOT LIKE ?'
|
||||||
|
assert params == ["%x%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparison_operators_on_int():
|
||||||
|
where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA)
|
||||||
|
assert where == '"annee" > ?'
|
||||||
|
assert params == [2024 - 1] # int coercion: 2023
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_filter_splits_on_commas():
|
||||||
|
where, params, _ = build_where([("annee__in", "2022,2023,2024")], SCHEMA)
|
||||||
|
assert where == '"annee" IN (?,?,?)'
|
||||||
|
assert params == [2022, 2023, 2024]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notin_filter():
|
||||||
|
where, params, _ = build_where([("annee__notin", "2020,2021")], SCHEMA)
|
||||||
|
assert where == '"annee" NOT IN (?,?)'
|
||||||
|
assert params == [2020, 2021]
|
||||||
|
|
||||||
|
|
||||||
|
def test_isnull_filter_ignores_value():
|
||||||
|
where, params, _ = build_where([("dateNotification__isnull", "anything")], SCHEMA)
|
||||||
|
assert where == '"dateNotification" IS NULL'
|
||||||
|
assert params == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_isnotnull_filter():
|
||||||
|
where, params, _ = build_where([("dateNotification__isnotnull", "")], SCHEMA)
|
||||||
|
assert where == '"dateNotification" IS NOT NULL'
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_filters_joined_by_and():
|
||||||
|
where, params, _ = build_where(
|
||||||
|
[("uid__exact", "a"), ("annee__greater", "2020")], SCHEMA
|
||||||
|
)
|
||||||
|
assert where == '"uid" = ? AND "annee" >= ?'
|
||||||
|
assert params == ["a", 2020]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_column_raises():
|
||||||
|
with pytest.raises(FilterError) as exc:
|
||||||
|
build_where([("foo__exact", "bar")], SCHEMA)
|
||||||
|
assert "foo" in str(exc.value)
|
||||||
|
assert exc.value.field == "foo__exact"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_operator_raises():
|
||||||
|
with pytest.raises(FilterError) as exc:
|
||||||
|
build_where([("uid__weird", "x")], SCHEMA)
|
||||||
|
assert "weird" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_int_value_raises():
|
||||||
|
with pytest.raises(FilterError):
|
||||||
|
build_where([("annee__exact", "notanint")], SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_date_value_raises():
|
||||||
|
with pytest.raises(FilterError):
|
||||||
|
build_where([("dateNotification__exact", "notadate")], SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_iso_coercion():
|
||||||
|
where, params, _ = build_where(
|
||||||
|
[("dateNotification__greater", "2024-01-01")], SCHEMA
|
||||||
|
)
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
assert params == [date(2024, 1, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_params_are_ignored():
|
||||||
|
where, params, order = build_where(
|
||||||
|
[
|
||||||
|
("page", "2"),
|
||||||
|
("page_size", "100"),
|
||||||
|
("columns", "uid"),
|
||||||
|
("count_results", "false"),
|
||||||
|
("uid__exact", "z"),
|
||||||
|
],
|
||||||
|
SCHEMA,
|
||||||
|
)
|
||||||
|
assert where == '"uid" = ?'
|
||||||
|
assert params == ["z"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_returns_order_by():
|
||||||
|
where, params, order = build_where(
|
||||||
|
[("annee__sort", "desc"), ("uid__sort", "asc")], SCHEMA
|
||||||
|
)
|
||||||
|
assert where == "TRUE"
|
||||||
|
assert order == '"annee" DESC, "uid" ASC'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_invalid_direction_raises():
|
||||||
|
with pytest.raises(FilterError):
|
||||||
|
build_where([("uid__sort", "sideways")], SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def test_param_without_operator_raises():
|
||||||
|
with pytest.raises(FilterError):
|
||||||
|
build_where([("uidexact", "x")], SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def test_differs_filter():
|
||||||
|
where, params, _ = build_where([("uid__differs", "abc")], SCHEMA)
|
||||||
|
assert where == '"uid" IS DISTINCT FROM ?'
|
||||||
|
assert params == ["abc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_differs_filter_on_int():
|
||||||
|
where, params, _ = build_where([("annee__differs", "2020")], SCHEMA)
|
||||||
|
assert where == '"annee" IS DISTINCT FROM ?'
|
||||||
|
assert params == [2020]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_none_when_absent():
|
||||||
|
assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_groupby_and_count():
|
||||||
|
spec = parse_aggregators([("annee__groupby", ""), ("uid__count", "")], SCHEMA)
|
||||||
|
assert isinstance(spec, AggregationSpec)
|
||||||
|
assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"'
|
||||||
|
assert spec.group_by_sql == '"annee"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_multiple_aggregates():
|
||||||
|
spec = parse_aggregators(
|
||||||
|
[
|
||||||
|
("annee__groupby", ""),
|
||||||
|
("montant__sum", ""),
|
||||||
|
("montant__avg", ""),
|
||||||
|
("montant__min", ""),
|
||||||
|
("montant__max", ""),
|
||||||
|
],
|
||||||
|
SCHEMA,
|
||||||
|
)
|
||||||
|
assert spec.select_sql == (
|
||||||
|
'"annee", SUM("montant") AS "montant__sum", '
|
||||||
|
'AVG("montant") AS "montant__avg", '
|
||||||
|
'MIN("montant") AS "montant__min", '
|
||||||
|
'MAX("montant") AS "montant__max"'
|
||||||
|
)
|
||||||
|
assert spec.group_by_sql == '"annee"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_global_without_groupby():
|
||||||
|
spec = parse_aggregators([("uid__count", "")], SCHEMA)
|
||||||
|
assert spec.select_sql == 'COUNT("uid") AS "uid__count"'
|
||||||
|
assert spec.group_by_sql is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aggregators_unknown_column_raises():
|
||||||
|
with pytest.raises(FilterError):
|
||||||
|
parse_aggregators([("nope__count", "")], SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_where_ignores_aggregator_flags():
|
||||||
|
where, params, _ = build_where(
|
||||||
|
[("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")],
|
||||||
|
SCHEMA,
|
||||||
|
)
|
||||||
|
assert where == '"montant" >= ?'
|
||||||
|
assert params == [100.0]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from src.api import init_api
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
init_api(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_returns_ok_without_auth():
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get("/api/v1/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_via_real_app():
|
||||||
|
"""Vérifie que init_api est bien branché dans src.app."""
|
||||||
|
from src.app import app as dash_app
|
||||||
|
|
||||||
|
resp = dash_app.server.test_client().get("/api/v1/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
def test_openapi_documents_new_keywords(api_client):
|
||||||
|
client, _ = api_client
|
||||||
|
resp = client.get("/api/v1/openapi.json")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
raw = resp.get_data(as_text=True)
|
||||||
|
for keyword in [
|
||||||
|
"count_results",
|
||||||
|
"differs",
|
||||||
|
"groupby",
|
||||||
|
"__sum",
|
||||||
|
"__avg",
|
||||||
|
"__min",
|
||||||
|
"__max",
|
||||||
|
]:
|
||||||
|
assert keyword in raw, f"{keyword} absent de la doc OpenAPI"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from src.api import tokens_cli, tokens_db
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args, env):
|
||||||
|
return tokens_cli.main(args, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_prints_plaintext_token_once(temp_db, capsys):
|
||||||
|
rc = _run(["create", "--label", "alice"], env={"USERS_DB_PATH": str(temp_db)})
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert rc == 0
|
||||||
|
assert "decpinfo_" in out
|
||||||
|
tokens = tokens_db.list_tokens(temp_db)
|
||||||
|
assert len(tokens) == 1
|
||||||
|
assert tokens[0]["label"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_shows_tokens(temp_db, capsys):
|
||||||
|
tokens_db.create_token(temp_db, "alice")
|
||||||
|
tokens_db.create_token(temp_db, "bob")
|
||||||
|
rc = _run(["list"], env={"USERS_DB_PATH": str(temp_db)})
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert rc == 0
|
||||||
|
assert "alice" in out
|
||||||
|
assert "bob" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_sets_revoked_at(temp_db, capsys):
|
||||||
|
_, token_id = tokens_db.create_token(temp_db, "alice")
|
||||||
|
rc = _run(["revoke", str(token_id)], env={"USERS_DB_PATH": str(temp_db)})
|
||||||
|
assert rc == 0
|
||||||
|
tokens = tokens_db.list_tokens(temp_db)
|
||||||
|
assert tokens[0]["revoked_at"] is not None
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_schema_creates_table(temp_db):
|
||||||
|
with sqlite3.connect(str(temp_db)) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'"
|
||||||
|
).fetchall()
|
||||||
|
assert rows == [("api_tokens",)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_token_returns_plaintext_and_stores_hash(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "test-label")
|
||||||
|
assert token.startswith("decpinfo_")
|
||||||
|
assert len(token) == len("decpinfo_") + 64 # 32 octets hex = 64 chars
|
||||||
|
assert token_id >= 1
|
||||||
|
|
||||||
|
with sqlite3.connect(str(temp_db)) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT token_hash, label, count_total FROM api_tokens WHERE id = ?",
|
||||||
|
(token_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row[1] == "test-label"
|
||||||
|
assert row[2] == 0
|
||||||
|
assert row[0] != token # stocké en clair impossible
|
||||||
|
assert len(row[0]) == 64 # sha256 hex
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_token_by_plaintext_returns_row(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
||||||
|
assert row is not None
|
||||||
|
assert row["id"] == token_id
|
||||||
|
assert row["label"] == "x"
|
||||||
|
assert row["revoked_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_token_unknown_returns_none(temp_db):
|
||||||
|
assert tokens_db.get_token_by_plaintext(temp_db, "decpinfo_zzz") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_token_sets_revoked_at(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
tokens_db.revoke_token(temp_db, token_id)
|
||||||
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
||||||
|
assert row["revoked_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_increment_usage_updates_counter_and_timestamp(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
tokens_db.increment_usage(temp_db, token_id)
|
||||||
|
tokens_db.increment_usage(temp_db, token_id)
|
||||||
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
||||||
|
assert row["count_total"] == 2
|
||||||
|
assert row["last_used_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tokens_returns_all(temp_db):
|
||||||
|
tokens_db.create_token(temp_db, "a")
|
||||||
|
tokens_db.create_token(temp_db, "b")
|
||||||
|
rows = tokens_db.list_tokens(temp_db)
|
||||||
|
assert [r["label"] for r in rows] == ["a", "b"]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from src.api import tokens_db, tracking
|
||||||
|
|
||||||
|
|
||||||
|
def test_counter_worker_increments_count(temp_db):
|
||||||
|
_, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
|
||||||
|
tracking.stop_worker() # reset any worker left by earlier tests
|
||||||
|
tracking.start_worker(str(temp_db))
|
||||||
|
try:
|
||||||
|
tracking.enqueue_counter_update(token_id)
|
||||||
|
tracking.enqueue_counter_update(token_id)
|
||||||
|
# Laisser le worker drainer la queue
|
||||||
|
tracking.flush(timeout=2.0)
|
||||||
|
finally:
|
||||||
|
tracking.stop_worker()
|
||||||
|
|
||||||
|
rows = tokens_db.list_tokens(temp_db)
|
||||||
|
assert rows[0]["count_total"] == 2
|
||||||
|
assert rows[0]["last_used_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_after_request_hook_increments_counter_async(api_client, valid_token_header):
|
||||||
|
client, db_path = api_client
|
||||||
|
# Récupérer le token_id du token créé par la fixture
|
||||||
|
from src.api import tokens_db, tracking
|
||||||
|
|
||||||
|
rows = tokens_db.list_tokens(db_path)
|
||||||
|
assert len(rows) == 1 # vérification du token créé par la fixture
|
||||||
|
|
||||||
|
# Faire une requête (qui doit déclencher l'incrément)
|
||||||
|
client.get("/api/v1/health") # pas authentifiée → ne compte pas
|
||||||
|
client.get("/api/v1/data", headers=valid_token_header) # /schema est public
|
||||||
|
|
||||||
|
tracking.flush(timeout=2.0)
|
||||||
|
|
||||||
|
rows = tokens_db.list_tokens(db_path)
|
||||||
|
assert rows[0]["count_total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header):
|
||||||
|
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false")
|
||||||
|
client, _ = api_client
|
||||||
|
|
||||||
|
from src.api import tracking
|
||||||
|
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tracking,
|
||||||
|
"_post_matomo",
|
||||||
|
lambda **kw: called.append(kw),
|
||||||
|
)
|
||||||
|
client.get("/api/v1/data", headers=valid_token_header) # authentifiée → hook actif
|
||||||
|
tracking.flush(timeout=2.0)
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_matomo_enabled_posts_event(monkeypatch, api_client, valid_token_header):
|
||||||
|
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true")
|
||||||
|
monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php")
|
||||||
|
monkeypatch.setenv("MATOMO_SITE_ID", "42")
|
||||||
|
|
||||||
|
from src.api import tracking
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tracking,
|
||||||
|
"_post_matomo",
|
||||||
|
lambda **kw: captured.append(kw),
|
||||||
|
)
|
||||||
|
|
||||||
|
client, _ = api_client
|
||||||
|
client.get("/api/v1/data", headers=valid_token_header) # /schema est public
|
||||||
|
tracking.flush(timeout=2.0)
|
||||||
|
|
||||||
|
assert len(captured) == 1
|
||||||
|
call = captured[0]
|
||||||
|
assert call["params"]["idsite"] == "42"
|
||||||
|
assert call["params"]["rec"] == "1"
|
||||||
|
assert "token-" in call["params"]["uid"]
|
||||||
|
assert call["params"]["dimension2"] == "200"
|
||||||
+8
-1
@@ -42,6 +42,12 @@ _TEST_DATA = [
|
|||||||
_PARQUET_PATH = Path(os.path.abspath("tests/test.parquet"))
|
_PARQUET_PATH = Path(os.path.abspath("tests/test.parquet"))
|
||||||
_DB_PATH = Path(os.path.abspath("decp.duckdb"))
|
_DB_PATH = Path(os.path.abspath("decp.duckdb"))
|
||||||
|
|
||||||
|
# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un
|
||||||
|
# fixture commité et on désactive la récupération distante.
|
||||||
|
_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json"))
|
||||||
|
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE)
|
||||||
|
os.environ.pop("DATA_SCHEMA_PATH", None)
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_db_artifacts() -> None:
|
def _cleanup_db_artifacts() -> None:
|
||||||
for artifact in (
|
for artifact in (
|
||||||
@@ -68,7 +74,8 @@ def test_data():
|
|||||||
_cleanup_db_artifacts()
|
_cleanup_db_artifacts()
|
||||||
|
|
||||||
|
|
||||||
def pytest_setup_options():
|
@pytest.fixture(scope="session")
|
||||||
|
def chrome_options():
|
||||||
options = Options()
|
options = Options()
|
||||||
options.add_argument("--window-size=1200,1200 ")
|
options.add_argument("--window-size=1200,1200 ")
|
||||||
options.add_experimental_option(
|
options.add_experimental_option(
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "acheteur_categorie",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Catégorie de l'acheteur",
|
||||||
|
"description": "Catégorie de l'acheteur selon son code juridique INSEE.",
|
||||||
|
"short_title": "Catégorie acheteur",
|
||||||
|
"enum": [
|
||||||
|
"Commune",
|
||||||
|
"Groupement de communes",
|
||||||
|
"Département",
|
||||||
|
"Département outre-mer",
|
||||||
|
"Région",
|
||||||
|
"État",
|
||||||
|
"Établissement hospitalier",
|
||||||
|
"EPIC",
|
||||||
|
"Syndicat mixte"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_commune_code",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Commune de l'acheteur (code)",
|
||||||
|
"description": "Code de la commune où se trouve l'acheteur.",
|
||||||
|
"short_title": "Commune ach. (code)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_commune_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Commune de l'acheteur",
|
||||||
|
"description": "Nom de la commune où se trouve l'acheteur.",
|
||||||
|
"short_title": "Commune acheteur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_departement_code",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Département de l'acheteur (code)",
|
||||||
|
"description": "Code du département où se trouve l'acheteur.",
|
||||||
|
"short_title": "Département ach. (code)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_departement_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Département de l'acheteur",
|
||||||
|
"description": "Nom du département où se trouve l'acheteur.",
|
||||||
|
"short_title": "Département acheteur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_id",
|
||||||
|
"type": "integer",
|
||||||
|
"title": "SIRET acheteur",
|
||||||
|
"description": "Identifiant de l'établissement de l'acheteur (SIRET), référencé dans la base SIRENE de l'INSEE.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_latitude",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Latitude de l'acheteur",
|
||||||
|
"description": "Latitude des coordonnées géographiques de l'acheteur.",
|
||||||
|
"short_title": "Latitude acheteur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_longitude",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Longitude de l'acheteur",
|
||||||
|
"description": "Longitude des coordonnées géographiques de l'acheteur.",
|
||||||
|
"short_title": "Longitude acheteur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Nom acheteur",
|
||||||
|
"description": "Nom de l'acheteur tel que renseigné dans la base SIRENE de l'INSEE.",
|
||||||
|
"short_title": "Acheteur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_region_code",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Région de l'acheteur (code)",
|
||||||
|
"description": "Code de la région où se trouve l'acheteur.",
|
||||||
|
"short_title": "Région ach. (code)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acheteur_region_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Région de l'acheteur",
|
||||||
|
"description": "Nom de la région où se trouve l'acheteur.",
|
||||||
|
"short_title": "Région acheteur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attributionAvance",
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Attribution avance",
|
||||||
|
"description": "Si une avance sur le montant du marché public a été attribuée aux titulaires.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ccag",
|
||||||
|
"type": "string",
|
||||||
|
"title": "CCAG",
|
||||||
|
"description": "Cahier des clauses administratives générales et techniques (CCAG) utilisé pour le marché public.",
|
||||||
|
"short_title": null,
|
||||||
|
"enum": [
|
||||||
|
"Travaux",
|
||||||
|
"Maitrise d'œuvre",
|
||||||
|
"Fournitures courantes et services",
|
||||||
|
"Marchés industriels",
|
||||||
|
"Prestations intellectuelles",
|
||||||
|
"Techniques de l'information et de la communication"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "codeCPV",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Code CPV",
|
||||||
|
"description": "Catégorie de bien, service ou travaux achetés, selon le Vocabulaire commun pour les marchés publics (CPV).",
|
||||||
|
"short_title": "CPV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "considerationsEnvironnementales",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Considérations environnementales",
|
||||||
|
"description": "Les considérations environnementales prévues dans le marché public.",
|
||||||
|
"short_title": "Cons. environnementales",
|
||||||
|
"enum": ["Clause environnementale", "Critère environnemental"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "considerationsSociales",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Considérations sociales",
|
||||||
|
"description": "Les considérations sociales prévues dans le marché public.",
|
||||||
|
"short_title": "Cons. sociales",
|
||||||
|
"enum": ["Clause sociale", "Critère social", "Marché réservé"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dateNotification",
|
||||||
|
"type": "date",
|
||||||
|
"title": "Date notification",
|
||||||
|
"description": "Date à laquelle le marché public ou de la modification a été notifiée aux titulaires du marché public.",
|
||||||
|
"short_title": null,
|
||||||
|
"format": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "datePublicationDonnees",
|
||||||
|
"type": "date",
|
||||||
|
"title": "Date publication données",
|
||||||
|
"description": "Date à laquelle les données du marché public ou de la modification ont été publiées sur data.gouv.fr.",
|
||||||
|
"short_title": "Date pub. données",
|
||||||
|
"format": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "donneesActuelles",
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Données actuelles",
|
||||||
|
"description": "Si les données de cette ligne sont les données actuelles du marché public, une fois les éventuelles modifications prises en compte.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dureeMois",
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Durée (mois)",
|
||||||
|
"description": "Durée en mois du marché attribué.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dureeRestanteMois",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Durée restante (mois)",
|
||||||
|
"description": "Durée approximative en mois restante dans le marché, en tenant compte de la date de notification et de la durée du marché. Ce nombre ne peut être inférieur à 0.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "formePrix",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Forme prix",
|
||||||
|
"description": "La forme du prix du marché public. Unitaire, Forfaitaire ou Mixte.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Identifiant interne",
|
||||||
|
"description": "Identifiant attribué par l'acheteur, censé être unique au sein de ses marchés.",
|
||||||
|
"short_title": "Id. interne"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idAccordCadre",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Identifiant accord-cadre",
|
||||||
|
"description": "Pour un marché subséquent, l'identifiant interne du marché public relevant de la technique d'achat accord-cadre auquel il est lié.",
|
||||||
|
"short_title": "Id. accord-cadre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lieuExecution_code",
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Code lieu exécution",
|
||||||
|
"description": "Code du lieu d'exécution du marché public. Le type de code est renseigné par 'Type code lieu exécution'.",
|
||||||
|
"short_title": "Lieu exécution"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lieuExecution_typeCode",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Type code lieu exécution",
|
||||||
|
"description": "Type du code du lieu d'exécution.",
|
||||||
|
"short_title": "Type lieu exécution",
|
||||||
|
"enum": [
|
||||||
|
"Code postal",
|
||||||
|
"Code commune",
|
||||||
|
"Code arrondissement",
|
||||||
|
" Code canton",
|
||||||
|
"Code département",
|
||||||
|
"Code région",
|
||||||
|
"Code pays"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "marcheInnovant",
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Marché innovant",
|
||||||
|
"description": "Si le marché comporte des travaux, services ou fournitures innovantes.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "modalitesExecution",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Modalités exécution",
|
||||||
|
"description": "Les modalités d'exécution du marché public.",
|
||||||
|
"short_title": null,
|
||||||
|
"enum": ["Tranches", "Bons de commande", "Marchés subséquents"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "modification_id",
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Identifiant modification",
|
||||||
|
"description": "Identifiant de la modification. 0 = données initiales du marché public, 1 = première modification, etc.",
|
||||||
|
"short_title": "Id. modification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "montant",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Montant attribué",
|
||||||
|
"description": "Montant forfaitaire ou montant maximum estimé hors-taxes, en euros. Ce montant est le montant attribué. Le montant final payé aux titulaires peut évoluer lors de la signature du contrat et de l'exécution du marché.",
|
||||||
|
"short_title": "Montant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nature",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Nature",
|
||||||
|
"description": "Marché, Marché de partenariat ou Marché de sécurité.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "objet",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Objet",
|
||||||
|
"description": "Objet du marché public. Potentiellement coupé à 256 ou 1 000 caractères par le producteur de données.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "offresRecues",
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Offres reçues",
|
||||||
|
"description": "Le nombre d'offres reçues pendant la phase d'appel d'offres. Comprend aussi les offres irrégulières, inacceptables, inappropriées et anormalement basses.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "origineFrance",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Origine France",
|
||||||
|
"description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits français avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits sont français. Cette valeur ne peut pas être supérieure à la valeur de origineUE.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "origineUE",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Origine UE",
|
||||||
|
"description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits issus de l'Union européenne avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits provient de l'Union européenne. Cette valeur ne peut pas être inférieure à la valeur de origineFrance.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "procedure",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Procédure",
|
||||||
|
"description": "Le type de procédure utilisé pour le marché public.",
|
||||||
|
"short_title": null,
|
||||||
|
"enum": [
|
||||||
|
"Procédure négociée ouverte",
|
||||||
|
"Procédure non négociée ouverte",
|
||||||
|
"Procédure négociée restreinte",
|
||||||
|
"Procédure non négociée restreinte"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sourceDataset",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Source dataset",
|
||||||
|
"description": "Code du jeu de données dont proviennent les données de ce marché public.",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sourceFile",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Source fichier",
|
||||||
|
"description": "Lien vers le fichier de données ouvertes dont proviennent les données de ce marché public.",
|
||||||
|
"short_title": null,
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sousTraitanceDeclaree",
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Sous-traitance déclarée",
|
||||||
|
"description": "Au moment de la notification du marché, les titulaires du marché ont déclaré s'appuyer sur un ou plusieurs sous-traitants pour ce marché public.",
|
||||||
|
"short_title": "Sous-traitance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tauxAvance",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Taux avance",
|
||||||
|
"description": "Taux de l'avance attribuée au titulaire principal du marché public par rapport au montant du marché (O.1 = 10 % du montant du marché). En fonction de la valeur de attributionAvance, une valeur égale à 0 signifie qu'il y a une avance mais que le taux n'est pas connu (attributionAvance=true).",
|
||||||
|
"short_title": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "techniques",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Techniques",
|
||||||
|
"description": "Les techniques d'achat utilisées pour le marché public.",
|
||||||
|
"short_title": null,
|
||||||
|
"enum": [
|
||||||
|
"Accord-cadre",
|
||||||
|
"Concours",
|
||||||
|
"Système de qualification",
|
||||||
|
"Système d'acquisition dynamique",
|
||||||
|
"Catalogue électronique",
|
||||||
|
"Enchère électronique"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_categorie",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Catégorie du titulaire",
|
||||||
|
"description": "Catégorie de l'entreprise titulaire selon la classification de l'INSEE.",
|
||||||
|
"short_title": "Catégorie titulaire",
|
||||||
|
"enum": ["PME", "ETI", "GE"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_commune_code",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Commune du titulaire (code)",
|
||||||
|
"description": "Code de la commune où se trouve le titulaire.",
|
||||||
|
"short_title": "Commune tit. (code)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_commune_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Commune du titulaire",
|
||||||
|
"description": "Nom de la commune où se trouve le titulaire.",
|
||||||
|
"short_title": "Commune titulaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_departement_code",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Département du titulaire (code)",
|
||||||
|
"description": "Code du département où se trouve le titulaire.",
|
||||||
|
"short_title": "Département tit. (code)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_departement_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Département du titulaire",
|
||||||
|
"description": "Nom du département où se trouve le titulaire.",
|
||||||
|
"short_title": "Département titulaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_distance",
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Distance acheteur-titulaire",
|
||||||
|
"description": "Distance en kilomètres entre l'adresse de l'acheteur et celle du titulaire.",
|
||||||
|
"short_title": "Distance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_id",
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Identifiant titulaire",
|
||||||
|
"description": "Identifiant du titulaire du marché. Voir 'Type identifiant' pour le référentiel utilisé",
|
||||||
|
"short_title": "Id. titulaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_latitude",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Latitude du titulaire",
|
||||||
|
"description": "Latitude des coordonnées géographiques du titulaire.",
|
||||||
|
"short_title": "Latitude titulaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_longitude",
|
||||||
|
"type": "number",
|
||||||
|
"title": "Longitude du titulaire",
|
||||||
|
"description": "Longitude des coordonnées géographiques du titulaire.",
|
||||||
|
"short_title": "Longitude titulaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Nom titulaire",
|
||||||
|
"description": "Nom du titulaire. Nom tel que renseigné dans la base SIRENE de l'INSEE si c'est un SIRET.",
|
||||||
|
"short_title": "Titulaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_region_code",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Région du titulaire (code)",
|
||||||
|
"description": "Code de la région où se trouve le titulaire.",
|
||||||
|
"short_title": "Région tit. (code)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_region_nom",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Région du titulaire",
|
||||||
|
"description": "Nom de la région où se trouve le titulaire.",
|
||||||
|
"short_title": "Région titulaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "titulaire_typeIdentifiant",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Type identifiant",
|
||||||
|
"description": "Référentiel utilisé pour l'identifiant du titulaire.",
|
||||||
|
"short_title": "Type id.",
|
||||||
|
"enum": ["SIRET", "TVA", "TAHITI", "RIDET", "FRWF", "IREP", "HORS-UE"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Type",
|
||||||
|
"description": "Type de marché public : fournitures, services ou travaux (dérivé du code CPV).",
|
||||||
|
"short_title": "Type",
|
||||||
|
"enum": ["Fournitures", "Services", "Travaux"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "typeGroupementOperateurs",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Type groupement",
|
||||||
|
"description": "Le type de groupement d'entreprises ou d'opérateurs économiques.",
|
||||||
|
"short_title": "Groupement",
|
||||||
|
"enum": ["Conjoint", "Solidaire"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "typesPrix",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Types prix",
|
||||||
|
"description": "Les types de prix du marché public.",
|
||||||
|
"short_title": null,
|
||||||
|
"enum": [
|
||||||
|
"Définitif ferme",
|
||||||
|
"Définitif actualisable",
|
||||||
|
"Définitif révisable",
|
||||||
|
"Provisoire"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uid",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Identifiant unique",
|
||||||
|
"description": "Concaténation du SIRET de l'acheteur (acheteur_id) et de l'identifiant interne de l'acheteur (id). Utilisé comme identifiant de marché unique au niveau national.",
|
||||||
|
"short_title": "Id. unique"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from src.utils.table_sql import dashboard_filters_to_sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_filters_uses_default_365_day_window():
|
||||||
|
where_sql, params = dashboard_filters_to_sql()
|
||||||
|
assert where_sql == '"dateNotification" > ?'
|
||||||
|
assert len(params) == 1
|
||||||
|
assert isinstance(params[0], datetime)
|
||||||
|
expected = datetime.now() - timedelta(days=365)
|
||||||
|
assert abs((params[0] - expected).total_seconds()) < 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_year_filter_overrides_default_window():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(dashboard_year="2025")
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ?'
|
||||||
|
assert params == [2025]
|
||||||
|
|
||||||
|
|
||||||
|
def test_marche_type_equality():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_type="Marché",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?'
|
||||||
|
assert params == [2025, "Marché"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_innovant_value_all_is_skipped():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_innovant="all",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ?'
|
||||||
|
assert params == [2025]
|
||||||
|
|
||||||
|
|
||||||
|
def test_innovant_value_oui_adds_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_innovant="oui",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?'
|
||||||
|
assert params == [2025, "oui"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sous_traitance_value_non_adds_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_sous_traitance_declaree="non",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?'
|
||||||
|
assert params == [2025, "non"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acheteur_id_uses_like_wildcards():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_id="12345678900010",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
|
||||||
|
assert params == [2025, "%12345678900010%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titulaire_id_uses_like_wildcards():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_titulaire_id="999",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
|
||||||
|
assert params == [2025, "%999%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_marche_objet_uses_case_insensitive_ilike():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_objet="travaux",
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
where_sql
|
||||||
|
== 'YEAR("dateNotification") = ? AND "objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?'
|
||||||
|
)
|
||||||
|
assert params == [2025, "%travaux%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_cpv_uses_prefix_like():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_code_cpv="4521",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?'
|
||||||
|
assert params == [2025, "4521%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acheteur_departement_multiple_uses_in_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_departement_code=["75", "92", "93"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? AND "acheteur_departement_code" IN (?, ?, ?)'
|
||||||
|
)
|
||||||
|
assert params == [2025, "75", "92", "93"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acheteur_categorie_adds_clause():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_categorie="Commune",
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?'
|
||||||
|
assert params == [2025, "Commune"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titulaire_categorie_and_departement():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_titulaire_categorie="PME",
|
||||||
|
dashboard_titulaire_departement_code=["35"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
'AND "titulaire_categorie" = ? '
|
||||||
|
'AND "titulaire_departement_code" IN (?)'
|
||||||
|
)
|
||||||
|
assert params == [2025, "PME", "35"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acheteur_id_present_skips_categorie_and_departement():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_acheteur_id="123",
|
||||||
|
dashboard_acheteur_categorie="Commune",
|
||||||
|
dashboard_acheteur_departement_code=["75"],
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
|
||||||
|
assert params == [2025, "%123%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titulaire_id_present_skips_categorie_and_departement():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_titulaire_id="999",
|
||||||
|
dashboard_titulaire_categorie="PME",
|
||||||
|
dashboard_titulaire_departement_code=["35"],
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
|
||||||
|
assert params == [2025, "%999%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_marche_techniques_uses_list_has_any():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_techniques=["Enchère", "Accord-cadre"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
"AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
assert params == [2025, ["Enchère", "Accord-cadre"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_considerations_sociales_uses_list_has_any():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_considerations_sociales=["Clause sociale"],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
"AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
assert params == [2025, ["Clause sociale"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_considerations_environnementales_uses_list_has_any():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_marche_considerations_environnementales=["Clause env."],
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? '
|
||||||
|
"AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
|
||||||
|
)
|
||||||
|
assert params == [2025, ["Clause env."]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_min_only():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_min=1000,
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
|
||||||
|
assert params == [2025, 1000]
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_max_only():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_max=500,
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?'
|
||||||
|
assert params == [2025, 500]
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_zero_is_a_valid_lower_bound():
|
||||||
|
# 0 est falsy mais reste un filtre valide (distinct de None)
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_min=0,
|
||||||
|
)
|
||||||
|
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
|
||||||
|
assert params == [2025, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_montant_min_and_max_combined():
|
||||||
|
where_sql, params = dashboard_filters_to_sql(
|
||||||
|
dashboard_year="2025",
|
||||||
|
dashboard_montant_min=100,
|
||||||
|
dashboard_montant_max=1000,
|
||||||
|
)
|
||||||
|
assert where_sql == (
|
||||||
|
'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?'
|
||||||
|
)
|
||||||
|
assert params == [2025, 100, 1000]
|
||||||
+76
-5
@@ -31,6 +31,7 @@ def test_should_rebuild_prod_when_parquet_newer(parquet_and_db, monkeypatch):
|
|||||||
os.utime(db, (now, now))
|
os.utime(db, (now, now))
|
||||||
os.utime(parquet, (now + 10, now + 10))
|
os.utime(parquet, (now + 10, now + 10))
|
||||||
monkeypatch.setenv("DEVELOPMENT", "false")
|
monkeypatch.setenv("DEVELOPMENT", "false")
|
||||||
|
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
|
||||||
assert should_rebuild(db, parquet) is True
|
assert should_rebuild(db, parquet) is True
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ def test_should_not_rebuild_prod_when_parquet_older(parquet_and_db, monkeypatch)
|
|||||||
os.utime(parquet, (now, now))
|
os.utime(parquet, (now, now))
|
||||||
os.utime(db, (now + 10, now + 10))
|
os.utime(db, (now + 10, now + 10))
|
||||||
monkeypatch.setenv("DEVELOPMENT", "false")
|
monkeypatch.setenv("DEVELOPMENT", "false")
|
||||||
|
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
|
||||||
assert should_rebuild(db, parquet) is False
|
assert should_rebuild(db, parquet) is False
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ def test_should_rebuild_dev_when_rebuild_forced(parquet_and_db, monkeypatch):
|
|||||||
os.utime(parquet, (now + 10, now + 10))
|
os.utime(parquet, (now + 10, now + 10))
|
||||||
monkeypatch.setenv("DEVELOPMENT", "true")
|
monkeypatch.setenv("DEVELOPMENT", "true")
|
||||||
monkeypatch.setenv("REBUILD_DUCKDB", "true")
|
monkeypatch.setenv("REBUILD_DUCKDB", "true")
|
||||||
|
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
|
||||||
assert should_rebuild(db, parquet) is True
|
assert should_rebuild(db, parquet) is True
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +148,7 @@ def built_db(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
from src.db import build_database
|
from src.db import build_database
|
||||||
|
|
||||||
build_database(db_path, parquet_path)
|
build_database(db_path)
|
||||||
return db_path
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
@@ -190,8 +193,15 @@ def test_build_creates_derived_tables(built_db):
|
|||||||
|
|
||||||
|
|
||||||
def test_query_marches_returns_polars_frame(built_db, monkeypatch):
|
def test_query_marches_returns_polars_frame(built_db, monkeypatch):
|
||||||
monkeypatch.setenv(
|
parquet_path = built_db.parent / "source.parquet"
|
||||||
"DATA_FILE_PARQUET_PATH", str(built_db.parent / "source.parquet")
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
|
||||||
|
# Patch on both the source module and dst namespace: the reload re-imports
|
||||||
|
# get_last_modified from src.utils, so src.utils must be patched to survive.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.utils.get_last_modified", lambda p: parquet_path.stat().st_mtime
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime
|
||||||
)
|
)
|
||||||
# Force src.db to load pointing at this test DB.
|
# Force src.db to load pointing at this test DB.
|
||||||
import importlib
|
import importlib
|
||||||
@@ -239,7 +249,7 @@ def test_query_marches_with_offset():
|
|||||||
assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list()))
|
assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list()))
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_build_serialized(tmp_path):
|
def test_concurrent_build_serialized(tmp_path, monkeypatch):
|
||||||
"""Multiple threads calling _ensure_database must serialize via flock.
|
"""Multiple threads calling _ensure_database must serialize via flock.
|
||||||
|
|
||||||
Only one should actually build; others wait, see the fresh DB, and skip.
|
Only one should actually build; others wait, see the fresh DB, and skip.
|
||||||
@@ -269,6 +279,10 @@ def test_concurrent_build_serialized(tmp_path):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
df.write_parquet(parquet_path)
|
df.write_parquet(parquet_path)
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime
|
||||||
|
)
|
||||||
|
|
||||||
db_path = tmp_path / "decp.duckdb"
|
db_path = tmp_path / "decp.duckdb"
|
||||||
lock_path = db_path.with_suffix(".duckdb.lock")
|
lock_path = db_path.with_suffix(".duckdb.lock")
|
||||||
@@ -283,7 +297,7 @@ def test_concurrent_build_serialized(tmp_path):
|
|||||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||||
try:
|
try:
|
||||||
if db.should_rebuild(db_path, parquet_path):
|
if db.should_rebuild(db_path, parquet_path):
|
||||||
db.build_database(db_path, parquet_path)
|
db.build_database(db_path)
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
@@ -298,3 +312,60 @@ def test_concurrent_build_serialized(tmp_path):
|
|||||||
assert errors == []
|
assert errors == []
|
||||||
assert db_path.exists()
|
assert db_path.exists()
|
||||||
assert not tmp_path_artifact.exists()
|
assert not tmp_path_artifact.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb"
|
||||||
|
dbf.write_bytes(b"existing")
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", _raise)
|
||||||
|
result = db._ensure_database() # ne doit pas lever
|
||||||
|
assert result == dbf
|
||||||
|
assert dbf.read_bytes() == b"existing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb"
|
||||||
|
dbf.write_bytes(b"existing")
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(db, "build_database", _raise)
|
||||||
|
result = db._ensure_database() # ne doit pas lever
|
||||||
|
assert result == dbf
|
||||||
|
assert dbf.read_bytes() == b"existing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb" # n'existe pas
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(db, "build_database", _raise)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
db._ensure_database()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_database_builds_when_needed(tmp_path, monkeypatch):
|
||||||
|
import src.db as db
|
||||||
|
|
||||||
|
dbf = tmp_path / "decp.duckdb"
|
||||||
|
dbf.write_bytes(b"old")
|
||||||
|
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||||
|
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x")
|
||||||
|
called = {}
|
||||||
|
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p))
|
||||||
|
db._ensure_database()
|
||||||
|
assert called.get("built") == dbf
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import polars as pl
|
||||||
|
|
||||||
|
|
||||||
|
def _make_lff(rows):
|
||||||
|
return pl.LazyFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_basic():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = _make_lff(
|
||||||
|
[
|
||||||
|
# u1 : social oui (Clause), env non (Sans objet)
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"considerationsSociales": "Clause sociale",
|
||||||
|
"considerationsEnvironnementales": "Sans objet",
|
||||||
|
},
|
||||||
|
# u2 : social non (Sans objet), env oui (Critère)
|
||||||
|
{
|
||||||
|
"uid": "u2",
|
||||||
|
"considerationsSociales": "Sans objet",
|
||||||
|
"considerationsEnvironnementales": "Critère environnemental",
|
||||||
|
},
|
||||||
|
# u3 : social oui (Marché réservé), env null
|
||||||
|
{
|
||||||
|
"uid": "u3",
|
||||||
|
"considerationsSociales": "Marché réservé",
|
||||||
|
"considerationsEnvironnementales": None,
|
||||||
|
},
|
||||||
|
# u4 : social autre valeur (pas "Sans objet"), env null
|
||||||
|
{
|
||||||
|
"uid": "u4",
|
||||||
|
"considerationsSociales": "Pas de considération sociale",
|
||||||
|
"considerationsEnvironnementales": "Sans objet",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
# champs_renseignes : basé sur sociales. 4 non-null / 4 total -> (4, 100%).
|
||||||
|
assert stats["champs_renseignes"] == (4, 100)
|
||||||
|
# Sociales renseignées : dén=4 non-null, num=3 != "Sans objet" (u1/u3/u4) -> (4, 75%).
|
||||||
|
assert stats["sociales_renseignees"] == (4, 75)
|
||||||
|
# Env renseignées : dén=3 non-null (u1/u2/u4), num=1 != "Sans objet" (u2) -> (3, 33%).
|
||||||
|
assert stats["environnementales_renseignees"] == (3, 33)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_dedup_per_uid():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = _make_lff(
|
||||||
|
[
|
||||||
|
# u1 présent 2 fois (2 titulaires) -> compté une seule fois
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"considerationsSociales": "Clause sociale",
|
||||||
|
"considerationsEnvironnementales": "Sans objet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"considerationsSociales": "Clause sociale",
|
||||||
|
"considerationsEnvironnementales": "Sans objet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uid": "u2",
|
||||||
|
"considerationsSociales": "Sans objet",
|
||||||
|
"considerationsEnvironnementales": "Sans objet",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
# 2 uid distincts. Social 2 non-null, 1 != "Sans objet" (u1) -> (2, 50%).
|
||||||
|
assert stats["champs_renseignes"] == (2, 100)
|
||||||
|
assert stats["sociales_renseignees"] == (2, 50)
|
||||||
|
# Env 2 non-null, 0 != "Sans objet" -> (2, 0%).
|
||||||
|
assert stats["environnementales_renseignees"] == (2, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_missing_column():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = _make_lff(
|
||||||
|
[
|
||||||
|
{"uid": "u1", "considerationsSociales": "Clause sociale"},
|
||||||
|
{"uid": "u2", "considerationsSociales": "Sans objet"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
# Colonne env absente -> (0, 0). Social : 2 non-null, 1 != "Sans objet" -> (2, 50%).
|
||||||
|
assert stats["champs_renseignes"] == (2, 100)
|
||||||
|
assert stats["sociales_renseignees"] == (2, 50)
|
||||||
|
assert stats["environnementales_renseignees"] == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_considerations_stats_empty():
|
||||||
|
from src.figures import compute_considerations_stats
|
||||||
|
|
||||||
|
lff = pl.LazyFrame(
|
||||||
|
{
|
||||||
|
"uid": pl.Series([], dtype=pl.String),
|
||||||
|
"considerationsSociales": pl.Series([], dtype=pl.String),
|
||||||
|
"considerationsEnvironnementales": pl.Series([], dtype=pl.String),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = compute_considerations_stats(lff)
|
||||||
|
|
||||||
|
assert stats["champs_renseignes"] == (0, 0)
|
||||||
|
assert stats["sociales_renseignees"] == (0, 0)
|
||||||
|
assert stats["environnementales_renseignees"] == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_considerations_card_content_returns_three_progress_bars():
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
from dash import html
|
||||||
|
|
||||||
|
from src.figures import get_considerations_card_content
|
||||||
|
|
||||||
|
lff = pl.LazyFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"considerationsSociales": "Clause sociale",
|
||||||
|
"considerationsEnvironnementales": "Sans objet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uid": "u2",
|
||||||
|
"considerationsSociales": "Sans objet",
|
||||||
|
"considerationsEnvironnementales": "Critère environnemental",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
div = get_considerations_card_content(lff)
|
||||||
|
|
||||||
|
assert isinstance(div, html.Div)
|
||||||
|
|
||||||
|
def find_progress(component, found):
|
||||||
|
if isinstance(component, dbc.Progress):
|
||||||
|
found.append(component)
|
||||||
|
children = getattr(component, "children", None)
|
||||||
|
if isinstance(children, (list, tuple)):
|
||||||
|
for c in children:
|
||||||
|
find_progress(c, found)
|
||||||
|
elif children is not None and not isinstance(children, str):
|
||||||
|
find_progress(children, found)
|
||||||
|
return found
|
||||||
|
|
||||||
|
inner_bars = [b for b in find_progress(div, []) if getattr(b, "bar", False)]
|
||||||
|
assert len(inner_bars) == 3
|
||||||
|
|
||||||
|
bar_ren, bar_social, bar_env = inner_bars
|
||||||
|
# Bar 1 : champs renseignés (2/2 = 100%, gris)
|
||||||
|
assert bar_ren.value == 100
|
||||||
|
assert bar_ren.color == "#6c757d"
|
||||||
|
# Bar 2 : sociales parmi renseignés (u1 != "Sans objet" -> 1/2 = 50%, rose)
|
||||||
|
assert bar_social.value == 50
|
||||||
|
assert bar_social.color == "#CC6677"
|
||||||
|
# Bar 3 : env parmi renseignés (u2 != "Sans objet" -> 1/2 = 50%, vert)
|
||||||
|
assert bar_env.value == 50
|
||||||
|
assert bar_env.color == "#117733"
|
||||||
+22
-44
@@ -215,47 +215,6 @@ def test_008_search_to_observatoire(dash_duo: DashComposite):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_010_observatoire_montant_filter():
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from src.utils.data import prepare_dashboard_data
|
|
||||||
|
|
||||||
data = pl.DataFrame(
|
|
||||||
{
|
|
||||||
"uid": ["1", "2", "3"],
|
|
||||||
"montant": [100.0, 500.0, 1000.0],
|
|
||||||
"dateNotification": [datetime.date(2025, 1, 1)] * 3,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def apply(min_val=None, max_val=None):
|
|
||||||
return prepare_dashboard_data(
|
|
||||||
data.lazy(),
|
|
||||||
dashboard_year="2025",
|
|
||||||
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=min_val,
|
|
||||||
dashboard_montant_max=max_val,
|
|
||||||
).collect()
|
|
||||||
|
|
||||||
assert apply().height == 3
|
|
||||||
assert apply(min_val=400).height == 2 # 500, 1000
|
|
||||||
assert apply(max_val=500).height == 2 # 100, 500
|
|
||||||
assert apply(min_val=200, max_val=600).height == 1 # 500 only
|
|
||||||
|
|
||||||
|
|
||||||
def test_009_observatoire_filter_persistence(dash_duo: DashComposite):
|
def test_009_observatoire_filter_persistence(dash_duo: DashComposite):
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -333,7 +292,7 @@ def test_011_observatoire_multi_param_url(dash_duo: DashComposite):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_distance_histogram_returns_graph():
|
def test_012_get_distance_histogram_returns_graph():
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import dcc
|
from dash import dcc
|
||||||
|
|
||||||
@@ -344,7 +303,7 @@ def test_get_distance_histogram_returns_graph():
|
|||||||
assert isinstance(result, dcc.Graph)
|
assert isinstance(result, dcc.Graph)
|
||||||
|
|
||||||
|
|
||||||
def test_get_distance_histogram_handles_nulls():
|
def test_013_get_distance_histogram_handles_nulls():
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import dcc
|
from dash import dcc
|
||||||
|
|
||||||
@@ -355,7 +314,7 @@ def test_get_distance_histogram_handles_nulls():
|
|||||||
assert isinstance(result, dcc.Graph)
|
assert isinstance(result, dcc.Graph)
|
||||||
|
|
||||||
|
|
||||||
def test_get_distance_histogram_all_nulls():
|
def test_014_get_distance_histogram_all_nulls():
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import dcc
|
from dash import dcc
|
||||||
|
|
||||||
@@ -363,4 +322,23 @@ def test_get_distance_histogram_all_nulls():
|
|||||||
|
|
||||||
lff = pl.LazyFrame({"titulaire_distance": pl.Series([], dtype=pl.Int64)})
|
lff = pl.LazyFrame({"titulaire_distance": pl.Series([], dtype=pl.Int64)})
|
||||||
result = get_distance_histogram(lff)
|
result = get_distance_histogram(lff)
|
||||||
|
|
||||||
assert isinstance(result, dcc.Graph)
|
assert isinstance(result, dcc.Graph)
|
||||||
|
|
||||||
|
|
||||||
|
def test_015_tableau_filter_date(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
for page in ["tableau", "acheteurs/123", "titulaires/345"]:
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
|
||||||
|
filter_input = '.marches_table th[data-dash-column="dateNotification"] input'
|
||||||
|
filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p'
|
||||||
|
dash_duo.wait_for_element(filter_input, timeout=2)
|
||||||
|
_filter_input: WebElement = dash_duo.find_element(filter_input)
|
||||||
|
_filter_input.send_keys("3333") # a dateNotification that doesn't exist
|
||||||
|
_filter_input.send_keys(Keys.ENTER)
|
||||||
|
_filter_result: list[WebElement] = dash_duo.find_elements(filter_cell_result)
|
||||||
|
assert len(_filter_result) == 0, f"Page : {page}"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch):
|
||||||
|
import src.utils as u
|
||||||
|
from src.utils import get_data_update_timestamp
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("net down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(u, "get_last_modified", boom)
|
||||||
|
fb = tmp_path / "decp.duckdb"
|
||||||
|
fb.write_bytes(b"x")
|
||||||
|
assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb))
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_timestamp_none_when_all_fail(monkeypatch):
|
||||||
|
import src.utils as u
|
||||||
|
from src.utils import get_data_update_timestamp
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("net down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(u, "get_last_modified", boom)
|
||||||
|
assert get_data_update_timestamp("http://x", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_timestamp_nominal(monkeypatch):
|
||||||
|
import src.utils as u
|
||||||
|
from src.utils import get_data_update_timestamp
|
||||||
|
|
||||||
|
monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0)
|
||||||
|
assert get_data_update_timestamp("http://x", None) == 123.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_tables_none_path():
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
|
||||||
|
div = get_sources_tables(None)
|
||||||
|
assert "indisponible" in str(div.children).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_tables_missing_file():
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
|
||||||
|
div = get_sources_tables("/does/not/exist.csv")
|
||||||
|
assert "indisponible" in str(div.children).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_tables_valid_csv(tmp_path):
|
||||||
|
from dash import dash_table
|
||||||
|
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
|
||||||
|
csv = tmp_path / "s.csv"
|
||||||
|
csv.write_text(
|
||||||
|
"nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n"
|
||||||
|
"Source A,Org A,5,2,XA,http://a,1\n"
|
||||||
|
)
|
||||||
|
div = get_sources_tables(str(csv))
|
||||||
|
assert isinstance(div.children, dash_table.DataTable)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.utils import data as data_mod
|
||||||
|
|
||||||
|
VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
def __init__(self, payload, ok=True, bad_json=False):
|
||||||
|
self._payload = payload
|
||||||
|
self._ok = ok
|
||||||
|
self._bad_json = bad_json
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if not self._ok:
|
||||||
|
raise httpx.HTTPError("boom")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if self._bad_json:
|
||||||
|
raise json.JSONDecodeError("bad", "", 0)
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
|
||||||
|
result = data_mod.get_data_schema()
|
||||||
|
assert "uid" in result
|
||||||
|
assert json.loads(cache.read_text())["fields"][0]["name"] == "uid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
cache.write_text(json.dumps(VALID))
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
cache.write_text(json.dumps(VALID))
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1}))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_url_uses_cache(tmp_path, monkeypatch):
|
||||||
|
cache = tmp_path / "schema.cache.json"
|
||||||
|
cache.write_text(json.dumps(VALID))
|
||||||
|
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_source_raises(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json"))
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
data_mod.get_data_schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch):
|
||||||
|
# parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé
|
||||||
|
cache = tmp_path / "nodir" / "schema.cache.json"
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||||
|
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||||
|
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
|
||||||
|
assert "uid" in data_mod.get_data_schema()
|
||||||
@@ -44,6 +44,33 @@ def test_filter_table_data_does_not_call_track_search(monkeypatch, sample_lff):
|
|||||||
assert result.height == 1
|
assert result.height == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_table_data_accent_insensitive():
|
||||||
|
"""Chercher sans accent doit trouver des valeurs accentuées, et vice versa."""
|
||||||
|
from src.utils.table import filter_table_data
|
||||||
|
|
||||||
|
lff = pl.LazyFrame(
|
||||||
|
[
|
||||||
|
{"uid": "1", "acheteur_nom": "Mairie de Nîmes", "objet": "Voirie"},
|
||||||
|
{"uid": "2", "acheteur_nom": "Commune de Reims", "objet": "Éclairage"},
|
||||||
|
{"uid": "3", "acheteur_nom": "Ville de Paris", "objet": "Travaux"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sans accent → trouve la valeur accentuée
|
||||||
|
result = filter_table_data(lff, "{acheteur_nom} icontains Nimes").collect()
|
||||||
|
assert result.height == 1
|
||||||
|
assert result["uid"][0] == "1"
|
||||||
|
|
||||||
|
# Avec accent → trouve la valeur accentuée
|
||||||
|
result = filter_table_data(lff, "{acheteur_nom} icontains Nîmes").collect()
|
||||||
|
assert result.height == 1
|
||||||
|
|
||||||
|
# Sans accent → trouve la valeur avec accent initial (É)
|
||||||
|
result = filter_table_data(lff, "{objet} icontains eclairage").collect()
|
||||||
|
assert result.height == 1
|
||||||
|
assert result["uid"][0] == "2"
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_sort_by_handles_empty():
|
def test_normalize_sort_by_handles_empty():
|
||||||
from src.utils.table import normalize_sort_by
|
from src.utils.table import normalize_sort_by
|
||||||
|
|
||||||
|
|||||||
@@ -106,16 +106,6 @@ def test_unknown_column_is_skipped():
|
|||||||
assert params == []
|
assert params == []
|
||||||
|
|
||||||
|
|
||||||
def test_escapes_identifier_with_quotes_not_concatenation():
|
|
||||||
from src.utils.table_sql import filter_query_to_sql
|
|
||||||
|
|
||||||
where, params = filter_query_to_sql(
|
|
||||||
"{objet} icontains '; DROP TABLE decp; --", SCHEMA
|
|
||||||
)
|
|
||||||
assert "DROP TABLE" not in where
|
|
||||||
assert any("DROP TABLE" in str(p) for p in params)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sort_by_empty():
|
def test_sort_by_empty():
|
||||||
from src.utils.table_sql import sort_by_to_sql
|
from src.utils.table_sql import sort_by_to_sql
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user