Merge branch 'main' into feature/78_api

This commit is contained in:
Colin Maudry
2026-06-12 09:33:59 +02:00
15 changed files with 2845 additions and 1445 deletions
+1
View File
@@ -9,6 +9,7 @@ ANNOUNCEMENTS=
# 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_LOCAL=../schema.json
# 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"
+9
View File
@@ -14,6 +14,15 @@
- Nouvelles variables d'environnement : `USERS_DB_PATH`, `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TRACKING_ENABLED`.
- Création de 2 Custom Dimensions côté Matomo : `dimension1=token_id`, `dimension2=http_status`.
##### 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.
@@ -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,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`.
+2 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "decp.info"
description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.7.7"
version = "2.7.9"
requires-python = ">= 3.10"
authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }]
dependencies = [
@@ -47,5 +47,6 @@ env = [
"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"
+15 -8
View File
@@ -6,7 +6,7 @@ import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race acr
import tomllib
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
from dotenv import load_dotenv
from flask import Response
from flask import Flask, Response
from src.utils import DEVELOPMENT
from src.utils.cache import cache
@@ -27,12 +27,10 @@ META_TAGS = [
if DEVELOPMENT:
META_TAGS.append({"name": "robots", "content": "noindex"})
app: Dash = Dash(
title="decp.info",
use_pages=True,
compress=True,
meta_tags=META_TAGS,
)
# Le cache doit être initialisé AVANT la construction de Dash : `use_pages=True`
# importe les modules de pages pendant l'instanciation, et certains appellent des
# fonctions memoizées (@cache.memoize) dès l'import (ex. tableau.py).
server = Flask(__name__)
cache_dir = os.getenv("CACHE_DIR", "/tmp/decp-cache")
@@ -40,7 +38,7 @@ if os.path.exists(cache_dir):
rmtree(cache_dir)
cache.init_app(
app.server,
server,
config={
"CACHE_TYPE": "FileSystemCache",
"CACHE_DIR": cache_dir,
@@ -51,6 +49,14 @@ 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)
@@ -73,6 +79,7 @@ def sitemap():
"/observatoire",
"/tableau",
"/a-propos",
"/etapes",
]
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+215
View File
@@ -546,3 +546,218 @@ input[type="number"]::-webkit-inner-spin-button {
input[type="number"] {
-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;
}
}
+16 -11
View File
@@ -8,27 +8,31 @@ import polars as pl
import polars.selectors as cs
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)
parquet_path = Path(parquet_path)
if not db_path.exists():
return True
dev = os.getenv("DEVELOPMENT", "False").lower() == "true"
force = os.getenv("REBUILD_DUCKDB", "False").lower() == "true"
if dev and not force:
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.
Kept here (not in utils.py) so src.db has no dependency on utils.
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:
lff: pl.LazyFrame = pl.scan_parquet(str(parquet_path))
except ComputeError:
@@ -58,20 +62,21 @@ def _load_source_frame(parquet_path: Path) -> pl.DataFrame:
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.
Caller MUST hold the fcntl.flock on the .lock file.
"""
db_path = Path(db_path)
parquet_path = Path(parquet_path)
tmp_path = db_path.with_suffix(".duckdb.tmp")
staging_parquet = db_path.with_suffix(".staging.parquet")
if tmp_path.exists():
tmp_path.unlink()
logger.info(f"Construction de la base DuckDB à partir de {parquet_path}...")
frame = _load_source_frame(parquet_path)
logger.info(
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
# (avoids pyarrow dependency for the Polars→DuckDB handoff)
@@ -111,13 +116,13 @@ def build_database(db_path: Path, parquet_path: Path) -> None:
def _ensure_database() -> Path:
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")
with open(lock_path, "w") as lock_fd:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
if should_rebuild(db_path, parquet_path):
build_database(db_path, parquet_path)
build_database(db_path)
else:
logger.debug("Base de données déjà disponible et à jour.")
return db_path
+7 -3
View File
@@ -2,6 +2,7 @@ import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from src.db import get_cursor
from src.utils import logger
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES
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):
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
return f"Marchés publics {verbe} par {org_nom} | decp.info"
if org_type:
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
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):
+297
View File
@@ -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
+2 -1
View File
@@ -109,7 +109,7 @@ def update_marche_info(marche, titulaires):
column_object = DATA_SCHEMA.get(col)
column_name = column_object.get("title") if column_object else col
if col in marche:
if marche and col in marche:
if col == "acheteur_nom":
value = html.A(
href=f"/acheteurs/{marche['acheteur_id']}",
@@ -134,6 +134,7 @@ def update_marche_info(marche, titulaires):
"considerationsSociales",
"considerationsEnvironnementales",
]
and col in marche
and "," in marche[col]
):
col_values = marche[col].split(", ")
+2 -2
View File
@@ -21,7 +21,7 @@ from dash import (
from src.db import query_marches, schema
from src.figures import DataTable, make_column_picker
from src.utils import logger
from src.utils import get_last_modified, logger
from src.utils.seo import META_CONTENT
from src.utils.table import (
COLUMNS,
@@ -33,7 +33,7 @@ from src.utils.table import (
)
from src.utils.tracking import track_search
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
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()
+24
View File
@@ -1,5 +1,29 @@
import logging
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(
format="%(asctime)s %(levelname)-8s %(message)s",
+30 -17
View File
@@ -2,7 +2,9 @@ import json
import logging
import os
from collections import OrderedDict
from pathlib import Path
import httpx
import polars as pl
from httpx import HTTPError, get
@@ -51,28 +53,39 @@ def get_departements_geojson() -> dict:
return geojson
def get_departement_region(code_postal):
if code_postal > "97000":
code_departement = code_postal[:3]
else:
code_departement = code_postal[:2]
nom_departement = DEPARTEMENTS[code_departement]["departement"]
nom_region = DEPARTEMENTS[code_departement]["region"]
return code_departement, nom_departement, nom_region
def get_departement_region(code_postal: str | None):
if code_postal:
if code_postal > "97000":
code_departement = code_postal[:3]
else:
code_departement = code_postal[:2]
nom_departement = DEPARTEMENTS[code_departement]["departement"]
nom_region = DEPARTEMENTS[code_departement]["region"]
return code_departement, nom_departement, nom_region
return "", "", ""
def get_data_schema() -> dict:
# Récupération du schéma des données tabulaires
path = os.getenv("DATA_SCHEMA_PATH")
if path.startswith("http"):
original_schema: dict = get(
os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True
).json()
elif os.path.exists(path):
with open(path) as f:
url = os.getenv("DATA_SCHEMA_PATH")
local_path = Path(os.getenv("DATA_SCHEMA_LOCAL", ""))
original_schema = {}
if url:
try:
original_schema: dict = get(url, follow_redirects=True).json()
except (
httpx.ReadTimeout,
httpx.ReadError,
httpx.ConnectError,
httpx.ConnectTimeout,
):
logger.error(f"Erreur HTTP lors de la récupération du schéma ({url})")
if os.path.exists(local_path) and original_schema == {}:
with open(local_path) as f:
original_schema: dict = json.load(f)
else:
raise Exception(f"Chemin vers le schéma invalide: {path}")
logger.info(f"Utilisation du schéma local ({local_path})")
new_schema = OrderedDict()
Generated
+1404 -1402
View File
File diff suppressed because it is too large Load Diff