Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8af6a17a1 | |||
| 0c9666204b | |||
| cd468a837c | |||
| 8a2f7f620c | |||
| 3a73cdf4b9 | |||
| 62eb4d98f0 | |||
| 6fdec32b29 | |||
| 1447a9fcaf | |||
| 9d7f33905f | |||
| 755b8c13ab | |||
| f4b57dbe5c | |||
| 0322c20513 | |||
| edbdeaa370 | |||
| 25746b4869 | |||
| 1a5f049b1a | |||
| bd6a4ff266 | |||
| 1839928e69 | |||
| a6049b3244 |
@@ -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: |
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
##### 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)
|
##### 2.7.5 (24 avril 2026)
|
||||||
|
|
||||||
- Amélioration des permormances de l'observatoire
|
- Amélioration des permormances de l'observatoire
|
||||||
|
|||||||
@@ -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
|
||||||
+2
-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.5"
|
version = "2.7.7"
|
||||||
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,7 @@ dependencies = [
|
|||||||
"duckdb",
|
"duckdb",
|
||||||
"flask-caching",
|
"flask-caching",
|
||||||
"pyarrow>=23.0.1",
|
"pyarrow>=23.0.1",
|
||||||
|
"flask-cors>=6.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
+7
-1
@@ -145,7 +145,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-20
@@ -11,8 +11,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
|
||||||
|
|
||||||
@@ -173,39 +175,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__(
|
||||||
@@ -833,7 +874,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()
|
||||||
|
|||||||
@@ -87,14 +87,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")),
|
||||||
|
|||||||
@@ -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
-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 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']}",
|
||||||
@@ -243,7 +243,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,
|
||||||
|
|||||||
@@ -824,6 +824,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
|
||||||
|
|||||||
@@ -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
-1
@@ -12,7 +12,7 @@ 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()
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -154,6 +154,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
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -57,10 +57,18 @@ 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":
|
||||||
where_clause, param_list = tokenize_text_filter(col_name, value)
|
if col_is_date:
|
||||||
|
target = f"CAST({quoted_col} AS VARCHAR)"
|
||||||
|
|
||||||
|
if col_name in ("acheteur_id", "titulaire_id"):
|
||||||
|
value = value.replace(" ", "")
|
||||||
|
where_clause, param_list = tokenize_text_filter(
|
||||||
|
col_name, value, col_is_date
|
||||||
|
)
|
||||||
clauses.append(where_clause)
|
clauses.append(where_clause)
|
||||||
params.extend(param_list)
|
params.extend(param_list)
|
||||||
logger.debug(params)
|
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
|
||||||
@@ -129,6 +137,7 @@ def dashboard_filters_to_sql(
|
|||||||
params.append(datetime.now() - timedelta(days=365))
|
params.append(datetime.now() - timedelta(days=365))
|
||||||
|
|
||||||
if dashboard_acheteur_id:
|
if dashboard_acheteur_id:
|
||||||
|
dashboard_acheteur_id = dashboard_acheteur_id.replace(" ", "")
|
||||||
clauses.append('"acheteur_id" LIKE ?')
|
clauses.append('"acheteur_id" LIKE ?')
|
||||||
params.append(f"%{dashboard_acheteur_id}%")
|
params.append(f"%{dashboard_acheteur_id}%")
|
||||||
else:
|
else:
|
||||||
@@ -141,6 +150,7 @@ def dashboard_filters_to_sql(
|
|||||||
params.extend(dashboard_acheteur_departement_code)
|
params.extend(dashboard_acheteur_departement_code)
|
||||||
|
|
||||||
if dashboard_titulaire_id:
|
if dashboard_titulaire_id:
|
||||||
|
dashboard_titulaire_id = dashboard_titulaire_id.replace(" ", "")
|
||||||
clauses.append('"titulaire_id" LIKE ?')
|
clauses.append('"titulaire_id" LIKE ?')
|
||||||
params.append(f"%{dashboard_titulaire_id}%")
|
params.append(f"%{dashboard_titulaire_id}%")
|
||||||
else:
|
else:
|
||||||
@@ -203,15 +213,22 @@ def dashboard_filters_to_sql(
|
|||||||
return " AND ".join(clauses), params
|
return " AND ".join(clauses), params
|
||||||
|
|
||||||
|
|
||||||
def tokenize_text_filter(column: str, text: str) -> tuple[str, list]:
|
def tokenize_text_filter(
|
||||||
|
column: str, text: str, col_is_date: bool = False
|
||||||
|
) -> tuple[str, list]:
|
||||||
terms = text.split()
|
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"\"{column}\" <> ''"]
|
conditions = [f'"{column}" IS NOT NULL', f"{quoted_col} <> ''"]
|
||||||
|
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
for term in terms:
|
for term in terms:
|
||||||
conditions.append(f'"{column}" ILIKE ?')
|
conditions.append(f"{quoted_col} ILIKE ?")
|
||||||
|
|
||||||
if term.startswith("*") or term.endswith("*"):
|
if term.startswith("*") or term.endswith("*"):
|
||||||
params.append(term.replace("*", "%"))
|
params.append(term.replace("*", "%"))
|
||||||
|
|||||||
+22
-3
@@ -292,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
|
||||||
|
|
||||||
@@ -303,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
|
||||||
|
|
||||||
@@ -314,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
|
||||||
|
|
||||||
@@ -322,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}"
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decp-info"
|
name = "decp-info"
|
||||||
version = "2.7.5"
|
version = "2.7.6"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "dash", extra = ["compress"] },
|
{ name = "dash", extra = ["compress"] },
|
||||||
@@ -771,6 +771,7 @@ dependencies = [
|
|||||||
{ name = "dash-leaflet", version = "1.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
{ name = "dash-leaflet", version = "1.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
{ name = "duckdb" },
|
{ name = "duckdb" },
|
||||||
{ name = "flask-caching" },
|
{ name = "flask-caching" },
|
||||||
|
{ name = "flask-cors" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
@@ -803,6 +804,7 @@ requires-dist = [
|
|||||||
{ name = "dash-leaflet" },
|
{ name = "dash-leaflet" },
|
||||||
{ name = "duckdb" },
|
{ name = "duckdb" },
|
||||||
{ name = "flask-caching" },
|
{ name = "flask-caching" },
|
||||||
|
{ name = "flask-cors", specifier = ">=6.0.2" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
@@ -978,6 +980,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4c/0f/fe51e0b2301bbd429af44273a923ff92127b18d13abba5ae5a1d60e8e497/flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c", size = 11033 },
|
{ url = "https://files.pythonhosted.org/packages/4c/0f/fe51e0b2301bbd429af44273a923ff92127b18d13abba5ae5a1d60e8e497/flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c", size = 11033 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-cors"
|
||||||
|
version = "6.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "geobuf"
|
name = "geobuf"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user