diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index dbd7476..fd0c2e9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -20,7 +20,7 @@ jobs: environment: ${{ github.ref_name }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up SSH key run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b1e727..09d58a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +##### 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 diff --git a/docs/superpowers/plans/2026-04-28-organization-maps.md b/docs/superpowers/plans/2026-04-28-organization-maps.md new file mode 100644 index 0000000..4e9e54a --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-organization-maps.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ee90897..e5a73b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.7.5" +version = "2.7.6" requires-python = ">= 3.10" authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }] dependencies = [ @@ -21,6 +21,7 @@ dependencies = [ "duckdb", "flask-caching", "pyarrow>=23.0.1", + "flask-cors>=6.0.2", ] [dependency-groups] diff --git a/run.py b/run.py index 215b786..a3e312a 100644 --- a/run.py +++ b/run.py @@ -1,7 +1,9 @@ +from flask_cors import CORS + from src.app import app # To use `gunicorn run:server` (prod) -server = app.server +server = CORS(app.server) # To use `python run.py` (dev) if __name__ == "__main__": diff --git a/src/figures.py b/src/figures.py index b89e395..4670723 100644 --- a/src/figures.py +++ b/src/figures.py @@ -11,8 +11,10 @@ import plotly.graph_objects as go import polars as pl from dash import dash_table, dcc, html from dash_extensions.javascript import Namespace +from polars.exceptions import ColumnNotFoundError from src.db import schema +from src.utils import logger from src.utils.data import DATA_SCHEMA, DEPARTEMENTS_GEOJSON 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) -def point_on_map(lat, lon): - lat = float(lat) - lon = float(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. - # Create a scatter mapbox or choropleth map + 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) + lon = float(lon) + except (TypeError, ValueError): + return html.Div() # Div vide pour les coordonnées invalides + + # 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( - 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( - mapbox_style="light", # Light, clean background + map_style="light", # Fond de carte clair 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 - fig.update_geos( - 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 + return html.Div( + dcc.Graph(figure=fig, config={"displayModeBar": False}), ) - # 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): 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.fill_null("") - dff: pl.DataFrame = lff.collect(engine="streaming") + try: + 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: return html.Div() diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index e0e708a..23bf203 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -257,8 +257,15 @@ def update_acheteur_infos(url): if data_etablissement: 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( - data_etablissement["latitude"], data_etablissement["longitude"] + data_etablissement["latitude"], + data_etablissement["longitude"], + departement_code, ) code_departement, nom_departement, nom_region = get_departement_region( data_etablissement["code_postal"] diff --git a/src/pages/marche.py b/src/pages/marche.py index 3709377..9587b56 100644 --- a/src/pages/marche.py +++ b/src/pages/marche.py @@ -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 marche[col]: + if col in marche: if col == "acheteur_nom": value = html.A( href=f"/acheteurs/{marche['acheteur_id']}", @@ -243,7 +243,7 @@ def get_marche_jsonld(marche, titulaires) -> str: titulaire.get("titulaire_id"), org_name=titulaire.get("titulaire_nom"), org_type="titulaire", - type_org_id=titulaire.get("titulaire_typeIdentifiant"), + type_org_id=titulaire.get("titulaire_typeIdentifiant", "SIRET"), ), "orderedItem": { "@type": type_order, diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index e58c9ba..716037e 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -824,6 +824,9 @@ def toggle_montant_modal(n_triggers, _close): prevent_initial_call=False, ) 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): match = df_org.filter(pl.col(id_col) == org_id) return match[nom_col].item(0) if match.height >= 1 else None diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 2a1005c..3edb8ba 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -263,8 +263,15 @@ def update_titulaire_infos(url): if data_etablissement: 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( - data_etablissement["latitude"], data_etablissement["longitude"] + data_etablissement["latitude"], + data_etablissement["longitude"], + departement_code, ) code_departement, nom_departement, nom_region = get_departement_region( data_etablissement["code_postal"] diff --git a/src/utils/data.py b/src/utils/data.py index cd54f40..eb9c170 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -12,7 +12,7 @@ from src.utils import logger 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}" try: response = get(url).raise_for_status() diff --git a/src/utils/seo.py b/src/utils/seo.py index f21439f..e383624 100644 --- a/src/utils/seo.py +++ b/src/utils/seo.py @@ -7,6 +7,8 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic address = None if type_org_id.lower() == "siret" and len(org_id) == 14: annuaire_data = get_annuaire_data(org_id) + if not annuaire_data: + return {} annuaire_address = annuaire_data["matching_etablissements"][0] code_postal = annuaire_address["code_postal"] commune = annuaire_address["libelle_commune"] diff --git a/src/utils/table.py b/src/utils/table.py index cb4108e..ef5221c 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -154,6 +154,8 @@ def normalize_sort_by(sort_by) -> tuple: def format_number(number) -> str: + if not number: + return "" number = "{:,}".format(number).replace(",", " ") return number diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py index 8e8badd..bb84647 100644 --- a/src/utils/table_sql.py +++ b/src/utils/table_sql.py @@ -57,10 +57,18 @@ def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list value = raw_value.strip('"') 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) params.extend(param_list) logger.debug(params) + continue elif operator in (">", "<"): 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)) if dashboard_acheteur_id: + dashboard_acheteur_id = dashboard_acheteur_id.replace(" ", "") clauses.append('"acheteur_id" LIKE ?') params.append(f"%{dashboard_acheteur_id}%") else: @@ -141,6 +150,7 @@ def dashboard_filters_to_sql( 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: @@ -203,15 +213,22 @@ def dashboard_filters_to_sql( 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() + # 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 = [] for term in terms: - conditions.append(f'"{column}" ILIKE ?') + conditions.append(f"{quoted_col} ILIKE ?") if term.startswith("*") or term.endswith("*"): params.append(term.replace("*", "%")) diff --git a/tests/test_main.py b/tests/test_main.py index ae9b36c..9070ba4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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 from dash import dcc @@ -303,7 +303,7 @@ def test_get_distance_histogram_returns_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 from dash import dcc @@ -314,7 +314,7 @@ def test_get_distance_histogram_handles_nulls(): assert isinstance(result, dcc.Graph) -def test_get_distance_histogram_all_nulls(): +def test_014_get_distance_histogram_all_nulls(): import polars as pl 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)}) result = get_distance_histogram(lff) + 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}" diff --git a/uv.lock b/uv.lock index 783477a..db47a6d 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = "duckdb" }, { name = "flask-caching" }, + { name = "flask-cors" }, { name = "gunicorn" }, { name = "httpx" }, { 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 = "duckdb" }, { name = "flask-caching" }, + { name = "flask-cors", specifier = ">=6.0.2" }, { name = "gunicorn" }, { name = "httpx" }, { 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 }, ] +[[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]] name = "geobuf" version = "2.0.1"