import json
import logging
import os
import uuid
from collections import OrderedDict
from time import localtime, sleep
import dash
import polars as pl
import polars.selectors as cs
from httpx import get, post
from polars.exceptions import ComputeError
from unidecode import unidecode
logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(message)s",
level=logging.INFO,
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("decp.info")
development = os.getenv("DEVELOPMENT", "False").lower() == "true"
if development:
logger.setLevel(logging.DEBUG)
logging.getLogger("httpx").setLevel("WARNING")
def split_filter_part(filter_part):
operators = [
["s<", "<"],
["s>", ">"],
["i<", "<"],
["i>", ">"],
["icontains", "contains"],
# [" ", "contains"]
]
logger.debug("filter part " + filter_part)
for operator_group in operators:
if operator_group[0] in filter_part:
name_part, value_part = filter_part.split(operator_group[0], 1)
name_part = name_part.strip()
value = value_part.strip()
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
logger.debug("=> " + " ".join([name, operator_group[1], value]))
return name, operator_group[1], value
return [None] * 3
def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
dff = dff.with_columns(
(
'' + pl.col("sourceDataset") + ""
).alias("sourceDataset")
)
dff = dff.drop(["sourceFile"])
return dff
def add_links(dff: pl.DataFrame):
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
if col in dff.columns:
if col.startswith("titulaire_"):
dff = dff.with_columns(
pl.when(
pl.Expr.or_(
pl.col("titulaire_typeIdentifiant").is_null(),
pl.col("titulaire_typeIdentifiant") == "SIRET",
)
)
.then(
''
+ pl.col(col)
+ ""
)
.otherwise(pl.col(col))
.alias(col)
)
if col.startswith("acheteur_"):
dff = dff.with_columns(
(
''
+ pl.col(col)
+ ""
).alias(col)
)
if col == "uid":
dff = dff.with_columns(
(
''
+ pl.col("uid")
+ ""
).alias("uid")
)
return dff
def add_links_in_dict(data: list[dict], org_type: str) -> list:
new_data = []
for marche in data:
org_id = marche[org_type + "_id"]
marche[org_type + "_nom"] = (
f'{marche[org_type + "_nom"]}'
)
if marche.get("uid"):
marche["id"] = f'{marche["id"]}'
marche["uid"] = f'{marche["uid"]}'
new_data.append(marche)
return new_data
def booleans_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
"""
Convert all boolean columns to string type.
"""
lff = lff.with_columns(
pl.col(cs.Boolean)
.cast(pl.String)
.str.replace("true", "oui")
.str.replace("false", "non")
)
return lff
def numbers_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
"""
Convert all numeric columns to string type.
"""
lff = lff.with_columns(pl.col(pl.Float64, pl.Int16).cast(pl.String).fill_null(""))
return lff
def dates_to_strings(lff: pl.LazyFrame, column: str) -> pl.LazyFrame:
"""
Convert a date column to string type.
"""
lff = lff.with_columns(pl.col(column).cast(pl.String).fill_null(""))
return lff
def format_number(number) -> str:
number = "{:,}".format(number).replace(",", " ")
return number
def unformat_montant(number: str) -> float:
number = number.replace(" €", "")
number = number.replace(" €", "").replace(" ", "")
number = number.replace(",", ".")
number = number.strip()
return float(number)
def format_values(dff: pl.DataFrame) -> pl.DataFrame:
def format_montant(expr, scale=None):
# https://stackoverflow.com/a/78636786
expr = expr.cast(pl.String)
expr = expr.str.splitn(".", 2)
num = expr.struct[0]
frac = expr.struct[1]
# Ajout des espaces
num = (
num.str.reverse()
.str.replace_all(r"\d{3}", "$0 ")
.str.reverse()
.str.replace(r"^ ", "")
)
frac: pl.Expr = (
pl.when(frac.is_not_null() & ~frac.is_in(["0"]))
.then("," + frac.str.head(2))
.otherwise(pl.lit(""))
)
montant: pl.Expr = (
pl.when((num + frac) == pl.lit(""))
.then(pl.lit(""))
.otherwise(num + frac + pl.lit(" €"))
)
return montant
def format_distance(expr):
expr = expr.cast(pl.String)
return pl.concat_str(expr, pl.lit(" km"))
if "montant" in dff.columns:
dff = dff.with_columns(pl.col("montant").pipe(format_montant).alias("montant"))
if "titulaire_distance" in dff.columns:
dff = dff.with_columns(
pl.col("titulaire_distance")
.pipe(format_distance)
.alias("titulaire_distance")
)
return dff
def get_annuaire_data(siret: str) -> dict:
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
response = get(url)
return response.json()["results"][0]
def get_decp_data() -> pl.DataFrame:
# Chargement du fichier parquet
# Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment.
# On utilise polars pour la rapidité et la facilité de manipulation des données.
try:
logger.info(
f"Lecture du fichier parquet ({os.getenv('DATA_FILE_PARQUET_PATH')})..."
)
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
except ComputeError:
# Le fichier est probablement en cours de mise à jour
logger.info("Échec, nouvelle tentative dans 10s...")
sleep(10)
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
# Tri des marchés par date de notification
lff = lff.sort(by=["dateNotification", "uid"], descending=True, nulls_last=True)
# Uniquement les données actuelles, pas les anciennes versions de marchés
lff = lff.filter(pl.col("donneesActuelles")).drop("donneesActuelles")
# Convertir les colonnes booléennes en chaînes de caractères
lff = booleans_to_strings(lff)
# Bizarrement je ne peux pas faire lff = lff.fill_null("") ici
# ça génère une erreur dans la page acheteur (acheteur_data.table) :
# AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import)
return lff.collect()
def get_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
lff = dff.lazy()
lff = lff.select(
"uid",
cs.starts_with(org_type).exclude(
f"{org_type}_latitude", f"{org_type}_longitude"
),
)
lff = lff.group_by(cs.starts_with(org_type)).len("Marchés")
return lff.collect()
def get_statistics() -> dict:
return (
get(
"https://www.data.gouv.fr/api/1/datasets/r/0ccf4a75-f3aa-4b46-8b6a-18aeb63e36df",
follow_redirects=True,
)
.raise_for_status()
.json()
)
def get_departements() -> dict:
with open("data/departements.json", "rb") as f:
data = json.load(f)
return data
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 filter_table_data(
lff: pl.LazyFrame, filter_query: str, filter_source: str
) -> pl.LazyFrame:
_schema = lff.collect_schema()
track_search(filter_query, filter_source)
filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part)
col_type = str(_schema[col_name])
# logger.debug("filter_value:", filter_value)
# logger.debug("filter_value_type:", type(filter_value))
# logger.debug("operator:", operator)
# logger.debug("col_type:", col_type)
lff = lff.filter(pl.col(col_name).is_not_null())
if col_type == "Date":
# Convertir la colonne date en chaînes de caractères
lff = dates_to_strings(lff, col_name)
col_type = "String"
if col_type == "String":
lff = lff.filter(pl.col(col_name) != pl.lit(""))
elif col_type.startswith("Int") or col_type.startswith("Float"):
try:
filter_value = int(filter_value)
except ValueError:
logger.error(f"Invalid numeric filter value: {filter_value}")
continue
if operator in ("contains", "<", "<=", ">", ">="):
if operator == "<":
lff = lff.filter(pl.col(col_name) < filter_value)
elif operator == ">":
lff = lff.filter(pl.col(col_name) > filter_value)
elif operator == ">=":
lff = lff.filter(pl.col(col_name) >= filter_value)
elif operator == "<=":
lff = lff.filter(pl.col(col_name) <= filter_value)
elif operator == "contains":
if col_type in ["String", "Date"]:
filter_value = filter_value.strip('"')
if filter_value.endswith("*"):
lff = lff.filter(
pl.col(col_name).str.starts_with(filter_value[:-1])
)
elif filter_value.startswith("*"):
lff = lff.filter(
pl.col(col_name).str.ends_with(filter_value[1:])
)
else:
lff = lff.filter(
pl.col(col_name).str.contains("(?i)" + filter_value)
)
elif col_type.startswith("Int") or col_type.startswith("Float"):
lff = lff.filter(pl.col(col_name) == filter_value)
else:
logger.error(f"Invalid column type: {col_type}")
else:
logger.error(f"Invalid operator: {operator}")
# elif operator == 'datestartswith':
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
return lff
def sort_table_data(lff: pl.LazyFrame, sort_by: list) -> pl.LazyFrame:
lff = lff.sort(
[col["column_id"] for col in sort_by],
descending=[col["direction"] == "desc" for col in sort_by],
nulls_last=True,
)
logger.debug(sort_by)
return lff
def setup_table_columns(
dff, hideable: bool = True, exclude: list = None, new_columns: list = None
) -> tuple:
# Liste finale de colonnes
markdown_exceptions = ["montant", "titulaire_distance", "distance", "dureeMois"]
columns = []
tooltip = {}
for column_id in dff.columns:
if exclude and column_id in exclude:
continue
column_object = data_schema.get(column_id)
if column_object:
column_name = column_object.get("title")
else:
# Si le champ est un champ créé par erreur lors d'une jointure, on le skip
if column_id.endswith("_left") or column_id.endswith("_right"):
logger.warning(f"Champ innatendu : {column_id}")
continue
column_name = column_id
column_object = {"title": column_name, "description": ""}
presentation = "input" if column_id in markdown_exceptions else "markdown"
column = {
"name": column_name,
"id": column_id,
"presentation": presentation,
"type": "text",
"format": {"nully": "N/A"},
"hideable": hideable,
}
columns.append(column)
if column_object:
tooltip[column_id] = {
"value": f"""**{column_object.get("title")}** ({column_id})
"""
+ column_object.get("description", ""),
"type": "markdown",
}
return columns, tooltip
def get_default_hidden_columns(page):
if page == "acheteur":
displayed_columns = [
"uid",
"objet",
"dateNotification",
"titulaire_id",
"titulaire_typeIdentifiant",
"titulaire_nom",
"titulaire_distance",
"montant",
"codeCPV",
"dureeRestanteMois",
]
elif page == "titulaire":
displayed_columns = [
"uid",
"objet",
"dateNotification",
"acheteur_id",
"acheteur_nom",
"titulaire_distance",
"montant",
"codeCPV",
"dureeRestanteMois",
]
elif page == "titulaire":
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
else:
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
logger.warning(f"Invalid page: {page}")
hidden_columns = []
for col in schema.names():
if col in displayed_columns:
continue
else:
hidden_columns.append(col)
return hidden_columns
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:
original_schema: dict = json.load(f)
else:
raise Exception(f"Chemin vers le schéma invalide: {path}")
new_schema = OrderedDict()
for col in original_schema["fields"]:
new_schema[col["name"]] = col
return new_schema
def track_search(query, category):
if len(query) >= 4 and not development and os.getenv("MATOMO_DOMAIN"):
url = "https://decp.info"
params = {
"idsite": os.getenv("MATOMO_ID_SITE"),
"url": url,
"rec": "1",
"action_name": "search" if category == "home_page_search" else "filter",
"search_cat": category,
"rand": uuid.uuid4().hex,
"apiv": "1",
"h": localtime().tm_hour,
"m": localtime().tm_min,
"s": localtime().tm_sec,
"search": query,
"token_auth": os.getenv("MATOMO_TOKEN"),
}
post(
url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php",
params=params,
).raise_for_status()
def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
"""
Search in either 'acheteur' or 'titulaire' DataFrame.
:param dff: Polars DataFrame with acheteur or titulaire columns
:param query: User search string
:param org_type: 'acheteur' or 'titulaire'
:return: Filtered DataFrame with 'matches' column
"""
if not query.strip():
return dff.select(pl.lit(False).alias("matches"))
# Enregistrement des recherche dans Matomo
track_search(query, "home_page_search")
# Normalize query
normalized_query = unidecode(query.strip()).upper()
tokens = [" " + t.strip() for t in normalized_query.split() if t.strip()]
# Define columns based on entity type
cols = [
f"{org_type}_id",
f"{org_type}_nom",
f"{org_type}_departement_nom",
f"{org_type}_departement_code",
f"{org_type}_commune_nom",
]
# Concatenate all fields into one string per row
org_str = pl.concat_str(pl.lit(" "), pl.col(cols), separator=" ").str.replace(
"-", " "
)
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Count how many tokens match per row
match_score = pl.sum_horizontal(token_matches).alias("match_score")
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Sélection des colonnes
if org_type == "acheteur":
dff = dff.select(cols + ["Marchés"])
if org_type == "titulaire":
dff = dff.select(cols + ["Marchés", "titulaire_typeIdentifiant"])
# Apply and filter
dff = (
dff.with_columns(token_matches + [match_score])
.filter(pl.col("match_score") == len(tokens))
.drop([f"token_{token}" for token in tokens])
)
# Format result
dff = add_links(dff)
dff = dff.with_columns(
pl.concat_str(
pl.col(f"{org_type}_departement_nom"),
pl.lit(" ("),
pl.col(f"{org_type}_departement_code"),
pl.lit(")"),
).alias("Département")
)
dff = dff.select(f"{org_type}_id", f"{org_type}_nom", "Département", "Marchés")
dff = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "Département").sum()
dff = dff.sort("Marchés", descending=True)
return dff
def prepare_table_data(
data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table
):
"""
Fonction de préparation des données pour les datatables, afin de permettre une gestion fine des logiques,
notamment pour les filtres et les tris.
:param data
:param data_timestamp:
:param filter_query:
:param page_current:
:param page_size:
:param sort_by:
:param source_table:
:return:
"""
if os.getenv("DEVELOPMENT").lower() == "true":
logger.debug(" + + + + + + + + + + + + + + + + + + ")
# Récupération des données
if isinstance(data, list):
lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
else:
lff: pl.LazyFrame = df.lazy() # start from the original data
# Application des filtres
if filter_query:
lff = filter_table_data(lff, filter_query, source_table)
# Application des tris
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
# Matérialisation des filtres
dff: pl.DataFrame = lff.collect()
height = dff.height
if height > 0:
nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)"
else:
nb_rows = "0 lignes (0 marchés)"
# Pagination des données
start_row = page_current * page_size
# end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size)
# Tout devient string
dff = dff.cast(pl.String)
# Remplace les strings null par "", mais pas les numeric null
dff = dff.fill_null("")
# Ajout des liens vers l'annuaire des entreprises
dff = add_links(dff)
# Ajout des liens vers les fichiers Open Data
if "sourceFile" in dff.columns:
dff = add_resource_link(dff)
# Formatage des montants
if height > 0:
dff = format_values(dff)
# Récupération des colonnes et tooltip
columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
# Propriétés du bouton de téléchargement
download_disabled, download_text, download_title = get_button_properties(height)
return (
dicts,
columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
)
def get_button_properties(height):
if height > 65000:
download_disabled = True
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
download_title = " Ajoutez des filtres pour réduire le nombre de lignes, Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul."
elif height == 0:
download_disabled = True
download_text = "Pas de données à télécharger"
download_title = ""
else:
download_disabled = False
download_text = "Télécharger au format Excel"
download_title = ""
return download_disabled, download_text, download_title
def invert_columns(columns):
"""
Renvoie les colonnes du schéma non spécifiées en paramètre. Utile pour passer d'une colonnes masquées à une liste de colonnes affichées, et vice versa.
:param columns:
:return:
"""
inverted_columns = []
for column in schema.names():
if column not in columns:
inverted_columns.append(column)
return inverted_columns
def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dict:
org_types = {"acheteur": "GovernmentOrganization", "titulaire": "Organization"}
address = None
if type_org_id.lower() == "siret" and len(org_id) == 14:
annuaire_data = get_annuaire_data(org_id)
annuaire_address = annuaire_data["matching_etablissements"][0]
code_postal = annuaire_address["code_postal"]
commune = annuaire_address["libelle_commune"]
address = (
{
"@type": "PostalAddress",
"streetAddress": annuaire_address.get("adresse", "")
.replace(code_postal, "")
.replace(commune, "")
.strip(),
"addressLocality": commune,
"postalCode": code_postal,
"addressCountry": "FR",
},
)
jsonld = {
"@type": org_types[org_type],
"name": org_name,
"url": f"https://decp.info/{org_type}s/{org_id}",
"sameAs": f"https://annuaire-entreprises.data.gouv.fr/etablissement/{org_id}",
"identifier": {
"@type": "PropertyValue",
"propertyID": type_org_id.lower(),
"value": org_id,
},
}
if address:
jsonld["address"] = address
return jsonld
def add_canonical_link(pathname):
@dash.hooks.index()
def update_index(html_string):
url = f"https://{domain_name}{pathname}"
canonical_tag = f''
return html_string.replace("", f"{canonical_tag}\n ")
df: pl.DataFrame = get_decp_data()
schema = df.collect_schema()
df_acheteurs = get_org_data(df, "acheteur")
df_titulaires = get_org_data(df, "titulaire")
df_acheteurs_departement: pl.DataFrame = (
df_acheteurs.select(["acheteur_id", "acheteur_nom", "acheteur_departement_code"])
.unique()
.sort("acheteur_nom")
)
df_titulaires_departement: pl.DataFrame = (
df_titulaires.select(
["titulaire_id", "titulaire_nom", "titulaire_departement_code"]
)
.unique()
.sort("titulaire_nom")
)
df_acheteurs_marches: pl.DataFrame = (
df.select("uid", "objet", "acheteur_id").unique().sort("acheteur_id")
)
df_titulaires_marches: pl.DataFrame = (
df.select("uid", "objet", "titulaire_id").unique().sort("titulaire_id")
)
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
)
meta_content = {
"image_url": f"https://{domain_name}/assets/decp.info.png",
"title": "decp.info - exploration des marchés publics français",
"description": (
"Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. "
"Pour une commande publique accessible à toutes et tous."
),
}
data_schema = get_data_schema()
columns = df.columns