Compare commits

...

666 Commits

Author SHA1 Message Date
Colin Maudry c304f05d85 Merge branch 'release/2.7.9' 2026-06-09 11:31:21 +02:00
Colin Maudry 5341ca002e Bump changelog 2.7.9 2026-06-09 11:28:16 +02:00
Colin Maudry ed32b0f66b Réduction des petites erreurs qui polluent les logs 2026-06-07 18:56:16 +02:00
Colin Maudry 4d3e8ac344 feat(etapes): fiches cliquables sur les barres et vue mobile 2026-06-04 22:15:10 +02:00
Colin Maudry ab74717679 feat(etapes): suppression légende, JAL à la place de Journaux d'annonces légales 2026-06-04 22:04:31 +02:00
Colin Maudry c9a97fe9d2 feat(etapes): référencement de /etapes dans le sitemap 2026-06-04 21:51:30 +02:00
Colin Maudry b8db0cddf8 feat(etapes): styles du graphique étapes/seuils 2026-06-04 21:48:28 +02:00
Colin Maudry 4c60fed0c2 feat(etapes): vue mobile liste par étape
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:45:25 +02:00
Colin Maudry 070a91fed1 feat(etapes): graphique données par étape et par seuil 2026-06-04 21:42:33 +02:00
Colin Maudry e638ce45be feat(etapes): squelette de la page /etapes 2026-06-04 21:38:50 +02:00
Colin Maudry e55f3447db Possibilité de charger les données depuis une URL, memoisation de last_modified 2026-06-04 21:34:36 +02:00
Colin Maudry 6cf213add5 Spec+plan: vue mobile dédiée pour /etapes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:31:17 +02:00
Colin Maudry 80c27bf357 Plan: page /etapes — données par étape et par seuil
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:26:09 +02:00
Colin Maudry 2e9b587613 Spec: page /etapes — données par étape et par seuil
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:13:35 +02:00
Colin Maudry ad7e3e5b1f Récupération de schéma plus robuste et changelog 2026-05-18 12:17:59 +02:00
Colin Maudry 7d4df0e6ad Récupération de schéma plus robuste et changelog 2026-05-18 12:16:03 +02:00
Colin Maudry 75e5aeaf40 Merge tag 'v2.7.7' into dev
- 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.
2026-05-11 10:35:56 +02:00
Colin Maudry b8af6a17a1 Merge branch 'release/2.7.7' 2026-05-11 10:35:29 +02:00
Colin Maudry 0c9666204b Autorisation du HTML dans les annonces 2026-05-11 10:34:06 +02:00
Colin Maudry cd468a837c Changelog v2.7.7 2026-05-11 10:31:18 +02:00
Colin Maudry 8a2f7f620c Suppression des mentions sur les profils d'acheteurs qui ne publient pas de données
Omnikles publie via l'API DUME de l'AIFE, ne reste que Klekoon. Mais bon, ça fait des mauvaises vibes sur la page de le préciser :)
2026-05-11 10:26:16 +02:00
Colin Maudry 3a73cdf4b9 P dans les announcements plus compacts 2026-05-05 15:43:08 +02:00
Colin Maudry 62eb4d98f0 Correction de l'ajout de CORS 2026-05-05 15:32:27 +02:00
Colin Maudry 6fdec32b29 Merge tag 'v2.7.6' into dev
- 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`
2026-05-05 15:06:32 +02:00
Colin Maudry 1447a9fcaf Merge branch 'release/2.7.6' 2026-05-05 15:05:55 +02:00
Colin Maudry 9d7f33905f Bumped version 2.7.6 2026-05-05 15:05:36 +02:00
Colin Maudry 755b8c13ab Bug nom colonne, amélioration test #76 2026-05-05 14:56:59 +02:00
Colin Maudry f4b57dbe5c Gestion des filtres de dates comme du texte #76 2026-05-05 14:08:50 +02:00
Colin Maudry 0322c20513 Suppression des espaces pour l'affichage du nom de l'org #75 2026-04-29 18:41:45 +02:00
Colin Maudry edbdeaa370 Suppression des espaces dans les SIREN/SIRET entrés dans l'observatoire et le tableau #75 2026-04-29 18:25:22 +02:00
Colin Maudry 25746b4869 Amélioration du rendu de la carte org 2026-04-28 13:34:33 +02:00
Colin Maudry 1a5f049b1a Map fonctionne mais lf cassé 2026-04-28 11:23:02 +02:00
Colin Maudry bd6a4ff266 Tentative de rétablissement de la carte sur acheteur/titulaire 2026-04-28 10:40:50 +02:00
Colin Maudry 1839928e69 Réduction des petites erreurs 2026-04-24 13:36:15 +02:00
Colin Maudry a6049b3244 Bumped version checkout 2026-04-24 13:35:49 +02:00
Colin Maudry d8ee6e5b37 Màj du mode d'emploi de Tableau #42 2026-04-24 12:31:46 +02:00
Colin Maudry b437decf5f Merge branch 'main' into dev 2026-04-24 12:10:51 +02:00
Colin Maudry 10f24dec30 Petites corrections 2026-04-24 12:08:48 +02:00
Colin Maudry 18b5488051 Merge tag 'v2.7.5' into dev
- Amélioration des permormances de l'observatoire
- Possibilité dans observatoire (champ objet) et tableau (tous champs texte) de soit chercher des mots présents, soit une suite de mot précise (voir mode d'emploi dans Tableau)
- Ajout d'une animation pendant le chargement de la prévisualisation des données de l'observatoire
2026-04-24 11:51:18 +02:00
Colin Maudry 7d8f8a7c19 Merge branch 'release/2.7.5' 2026-04-24 11:50:47 +02:00
Colin Maudry 93777cce6d Changelog v2.7.5 2026-04-24 11:50:36 +02:00
Colin Maudry dadbb0aeff Possibilité de chercher soit des mots présents, soit une suite de mot précise #42 2026-04-24 11:40:02 +02:00
Colin Maudry f7b7954ed2 Merge branch 'feature/72_observatoire_duckdb_filters' into dev 2026-04-23 12:33:48 +02:00
Colin Maudry fc4d965b20 Spinner de chargemetn sur la préviusalisation des données 2026-04-23 12:33:30 +02:00
Colin Maudry a715140af0 test(observatoire): intégration DuckDB pour prepare_dashboard_data (#72) 2026-04-23 00:07:24 +02:00
Colin Maudry c45d4e0ea1 refactor(observatoire): appelants utilisent la nouvelle signature (#72)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:57:10 +02:00
Colin Maudry 0777153c82 refactor(observatoire): prepare_dashboard_data utilise DuckDB (#72) 2026-04-22 23:55:45 +02:00
Colin Maudry 6e670c97c9 feat(observatoire): filtres montant min/max (#72) 2026-04-22 23:53:43 +02:00
Colin Maudry 522c467702 feat(observatoire): filtres liste via list_has_any (#72) 2026-04-22 23:49:32 +02:00
Colin Maudry 74ae1fb008 feat(observatoire): IN départements et skip conditionnel par ID (#72) 2026-04-22 23:43:55 +02:00
Colin Maudry e3a0fba4df feat(observatoire): filtres LIKE/ILIKE dans dashboard_filters_to_sql (#72) 2026-04-22 23:42:43 +02:00
Colin Maudry a382370767 feat(observatoire): filtres d'égalité simples dans dashboard_filters_to_sql (#72) 2026-04-22 23:39:13 +02:00
Colin Maudry 653999693c feat(observatoire): squelette de dashboard_filters_to_sql (#72) 2026-04-22 23:36:59 +02:00
Colin Maudry aaf54eef91 docs(observatoire): plan d'implémentation filtrage natif DuckDB (#72)
Plan en 10 tâches TDD : construction incrémentale de dashboard_filters_to_sql,
réécriture de prepare_dashboard_data, adaptation des 3 appelants de
observatoire.py, test d'intégration sur tests/test.parquet.
2026-04-22 23:21:47 +02:00
Colin Maudry b996eb97cc docs(observatoire): spec du filtrage natif DuckDB (#72)
Décrit la refonte de prepare_dashboard_data pour pousser le filtrage au
niveau DuckDB via un nouveau helper dashboard_filters_to_sql, sur le
modèle de filter_query_to_sql / _fetch_page_sql.
2026-04-22 23:16:41 +02:00
Colin Maudry 3e89dacff9 Correction de setup_table_columns et autres 2026-04-22 21:16:51 +02:00
Colin Maudry eb8d7abe0d actions/checkout@v4 2026-04-22 20:55:22 +02:00
Colin Maudry a484984e40 Merge tag 'v2.7.4' into dev
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72)
2026-04-22 20:50:47 +02:00
Colin Maudry bea160aa00 Merge branch 'release/2.7.4' 2026-04-22 20:50:12 +02:00
Colin Maudry 8e603d2806 Bump version number 2026-04-22 20:49:57 +02:00
Colin Maudry 1d833bb800 Merge branch 'feature/72_duckdb_performance' into dev 2026-04-22 20:48:39 +02:00
Colin Maudry f81c897342 Changelog 2.7.4 2026-04-22 20:48:27 +02:00
Colin Maudry 3532a9c381 Path de duckdb configurable, correction des tests #72 2026-04-22 20:43:38 +02:00
Colin Maudry b4956c34d1 Utilisation d'une seule fonction postprocess #72 2026-04-22 18:30:36 +02:00
Colin Maudry 72d4881796 Simplifications du code #72 2026-04-22 17:17:54 +02:00
Colin Maudry 1e67d329d0 perf(tableau): pousser filtre/tri/pagination/comptage dans DuckDB
Remplace le chemin lent de prepare_table_data (chargement de toutes les
lignes depuis DuckDB puis filtrage/post-traitement Polars avant slice)
par _fetch_page_sql qui pousse filtre, tri, pagination et comptage dans
DuckDB via filter_query_to_sql / sort_by_to_sql, puis post-traite
uniquement la page de 20 lignes.

Supprime _load_filter_sort_postprocess (plus utilisé). Met à jour les
tests test_table.py en supprimant les tests associés et en ajoutant
des tests dédiés pour _fetch_page_sql. Corrige le fixture flask_app
pour utiliser src.utils.cache (même instance que le module) afin que
@cache.memoize() fonctionne.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:53:45 +02:00
Colin Maudry 4e25ff5c85 feat(table): ajouter postprocess_page pour post-traiter une page seule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:42:52 +02:00
Colin Maudry cdb6a70f7a feat(db): ajouter count_marches, count_unique_marches et paramètre offset à query_marches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:39:51 +02:00
Colin Maudry 74213d3844 fix(table_sql): gérer *foo* et utiliser isinstance pour les types Polars
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:37:03 +02:00
Colin Maudry 95c90e319e feat: ajouter traducteurs filter_query→SQL et sort_by→SQL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:33:49 +02:00
Colin Maudry abc6390174 Améliorations CLAUDE.md pour plus utiliser rtk 2026-04-21 21:55:18 +02:00
Colin Maudry 6d22b7298a Merge tag 'v2.7.3' into dev
- Mise en cache des vues tableau par ensemble de filtres et de tris
- Résolution du bug d'écriture du fichier de vérouillage de la base de données
2026-04-20 11:55:55 +02:00
Colin Maudry e33e5da619 Merge branch 'release/2.7.3' 2026-04-20 11:42:17 +02:00
Colin Maudry ffeb708f1d Changelog 2.7.3 2026-04-20 11:42:04 +02:00
Colin Maudry aa445b6f01 Plan #72 2026-04-20 11:36:38 +02:00
Colin Maudry 330ed4f0cb Base de données et .lock à la racine de decp.info 2026-04-20 11:28:00 +02:00
Colin Maudry da5a99b3af Merge branch 'main' into dev 2026-04-20 10:49:31 +02:00
Colin Maudry 285ed37d79 Correction de l'import d'utils.cache 2026-04-20 00:06:44 +02:00
Colin Maudry e44fe452b2 Corrections de typage et d'appels à filter_table_data 2026-04-19 23:55:20 +02:00
Colin Maudry 7aef7acd34 Petits ajustements (cache => utils, noms de variables) 2026-04-19 23:49:02 +02:00
Colin Maudry 3ce6f224ae rtk, uv, pyproject 2026-04-19 23:39:33 +02:00
Colin Maudry c7c7c2c62c Factorisation des opération de postprocess des tables 2026-04-19 23:28:45 +02:00
Colin Maudry ad58c1152a Améliorations sur le typage 2026-04-19 23:23:07 +02:00
Colin Maudry 19449969d6 Mention de rtk dans CLAUDE.md 2026-04-19 23:22:48 +02:00
Colin Maudry b0d2aca4ff perf(tableau): memoize filter+sort+postprocess pipeline (#72)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:50:32 +02:00
Colin Maudry 0abbd982ea feat: add memoized _load_filter_sort_postprocess helper (#72)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:39:13 +02:00
Colin Maudry 18d07b5398 feat: add normalize_sort_by hashable cache-key helper (#72)
Add normalize_sort_by function to convert Dash DataTable's sort_by list
(unhashable) into a tuple representation (hashable) for use in cache keys.
Includes TDD-driven tests for empty inputs, hashability, and order preservation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:36:55 +02:00
Colin Maudry ff425108b0 fix: restore track_search import in table.py (#72) 2026-04-19 22:34:25 +02:00
Colin Maudry 0c7ca04f8e refactor: move track_search out of filter_table_data into callers (#72)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:32:28 +02:00
Colin Maudry 89904a5bad test: scaffold unit tests for table utilities (#72) 2026-04-19 22:30:28 +02:00
Colin Maudry 4d0baebb75 Vérification de l'existence de cache_dir 2026-04-19 15:54:36 +02:00
Colin Maudry 5ccfec35e9 Merge tag 'v2.7.2' into dev
- Chargement des données depuis une base DuckDB plutôt qu'en mémoire (plus de stabilité) ([#71](https://github.com/ColinMaudry/decp.info/issues/71))
- Mise en cache des vue sur l'observatoire pour un chargement plus rapide (remise à zéro quotidienne)
- Correction de bug : la liste de colonnes par défaut est bien appliquée plutôt qu'afficher toutes les colonnes
- Quelques corrections de bugs d'affichage
- Refactorisation des fonctions utilitaires (`utils.py` approchait des 1 000 lignes)
2026-04-19 15:33:58 +02:00
Colin Maudry 38f7543205 Merge branch 'release/2.7.2' 2026-04-19 15:32:41 +02:00
Colin Maudry 26169abc2f Suppression du cache à chaque redémarrage 2026-04-19 15:29:34 +02:00
Colin Maudry 1eb579da57 Logging des cache miss si DEVELOPMENT 2026-04-19 15:23:02 +02:00
Colin Maudry 1f5ffe2962 Changelog 2.7.2 2026-04-19 15:22:09 +02:00
Colin Maudry 4c4b010f44 Correction des tests avec données db 2026-04-18 21:39:36 +02:00
Colin Maudry 9d9760e596 Cache 2026-04-18 20:06:46 +02:00
Colin Maudry a7516d65e3 Utilisation du logger global dans app et db 2026-04-18 19:28:34 +02:00
Colin Maudry 600567330f Améliorations typing 2026-04-18 19:01:22 +02:00
Colin Maudry af3b3464e4 Refactorisation utils 2026-04-18 19:01:07 +02:00
Colin Maudry e4e1438220 Refactorisation des fonctions utils 2026-04-18 18:33:09 +02:00
Colin Maudry e352624c02 Capitalisation des constantes 2026-04-18 18:09:42 +02:00
Colin Maudry accdfe1384 Merge branch 'feat/duckdb-migration' into dev 2026-04-18 16:57:45 +02:00
Colin Maudry da13ed7984 DuckDB migration design spec #71 2026-04-18 16:57:41 +02:00
Colin Maudry 035e6b7ac3 Formatage prettier (reformatage automatique) 2026-04-16 11:13:08 +02:00
Colin Maudry 71f21b733f Mesure de l'impact mémoire post-migration DuckDB (#71) 2026-04-16 11:12:50 +02:00
Colin Maudry 09ddb0f485 Suppression des dataframes globaux remplacés par DuckDB (#71) 2026-04-16 11:11:58 +02:00
Colin Maudry 342f7b53a9 figures.py : remplacement de df.columns par schema.names() depuis src.db (#71) 2026-04-16 11:09:43 +02:00
Colin Maudry 88016d9517 observatoire.py : migration vers query_marches et schema depuis src.db (#71) 2026-04-16 11:09:22 +02:00
Colin Maudry 5ecfb463f3 tableau.py : migration vers query_marches et schema depuis src.db (#71) 2026-04-16 11:08:45 +02:00
Colin Maudry 1655af375c arbre/liste_marches_org.py : requêtes DuckDB pour les listes de marchés (#71) 2026-04-16 11:07:59 +02:00
Colin Maudry cba3128b8f departement.py : requêtes DuckDB sur acheteurs_departement et titulaires_departement (#71) 2026-04-16 11:07:32 +02:00
Colin Maudry a31d996812 titulaire.py : migration vers query_marches et schema depuis src.db (#71) 2026-04-16 11:07:07 +02:00
Colin Maudry e89311f3ab acheteur.py : migration vers query_marches et schema depuis src.db (#71) 2026-04-16 11:06:35 +02:00
Colin Maudry 31b68079e0 marche.py : utilisation de query_marches au lieu du df global (#71) 2026-04-16 11:05:52 +02:00
Colin Maudry 9cf92563ae Intégration de src.db dans utils (coexistence avec les globaux) (#71) 2026-04-16 11:05:18 +02:00
Colin Maudry 94ff13a66b test: reconstruction de la base DuckDB de test avant chaque session (#71) 2026-04-16 11:04:44 +02:00
Colin Maudry 4715db282e Les boutons de Tableau passent à ligne si écran plus étroit 2026-04-15 17:49:25 +02:00
Colin Maudry 2d592842ab Suppression print/logs inutiles 2026-04-15 16:33:57 +02:00
Colin Maudry cfd0da34cd test(db): sérialisation des builds concurrents par fcntl.flock (#71)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 16:27:41 +02:00
Colin Maudry f31522734c Connexion DuckDB globale, verrou fcntl et query_marches
- _ensure_database : vérifie rebuild sous verrou fcntl exclusif
- conn en lecture seule au niveau module, schema importé via SELECT LIMIT 0
- query_marches : helper SQL paramétré retournant un pl.DataFrame
- get_cursor : cursor par appel pour thread-safety Dash
- pyarrow ajouté en dépendance (requis par duckdb .pl())

refs #71

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:23:09 +02:00
Colin Maudry 077af6dd2e Implémentation de build_database avec transforms Polars et tables dérivées
- _load_source_frame reprend la pipeline Polars (sort, filtre donneesActuelles,
  booleans_to_strings, remplacement des noms null)
- build_database utilise write_parquet + read_parquet pour zéro-dépendance
  pyarrow (pyarrow absent du venv) et écrit atomiquement via .tmp + os.replace
- 4 tables dérivées : acheteurs_marches, titulaires_marches,
  acheteurs_departement, titulaires_departement

refs #71

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:16:12 +02:00
Colin Maudry 553e23dd98 Nettoyage des tests should_rebuild suite à la revue
- Import de should_rebuild au niveau module
- Suppression d'un setenv DEVELOPMENT inutile (branche db-missing)
- Utilisation de os.utime pour un ordre mtime déterministe

refs #71

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:10:17 +02:00
Colin Maudry e83df64261 Ajout de src.db.should_rebuild et ses tests
refs #71
2026-04-15 16:07:41 +02:00
Colin Maudry 0c3b0265b4 Ajout de la dépendance duckdb
Ajoute duckdb (==1.5.2) à pyproject.toml et ignore les artefacts
runtime (decp.duckdb, .tmp, .lock) qui sont régénérés au démarrage
depuis decp_prod.parquet.

refs #71

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:03:06 +02:00
Colin Maudry fd1a801ddb Ignore .worktrees pour exécution en worktree isolé
refs #71

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 13:50:31 +02:00
Colin Maudry 035d7f23fa Plan de migration DuckDB — découpage en 20 tâches
20 étapes bite-sized couvrant : dépendance duckdb + .gitignore,
should_rebuild (TDD), build_database avec transforms Polars et verrou
fcntl, startup guard + query_marches, migration page-par-page
(marche → acheteur → titulaire → arbre → tableau → observatoire →
figures), suppression des globaux Polars, mesure RSS avant/après.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 13:37:49 +02:00
Colin Maudry e75a69e259 Spec: DuckDB migration for decp data layer
Replace global Polars dataframes in src/utils.py (lines 891-913) with
an on-disk DuckDB database, built at startup from decp_prod.parquet.
Keeps two small search-path frames (df_acheteurs, df_titulaires) in
memory; moves heavy filtering and aggregation to DuckDB via a
query_marches helper that returns pl.DataFrame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 13:19:52 +02:00
Colin Maudry 9a19e5cba7 Mise en place d'un cache pour l'observatoire 2026-04-15 12:11:44 +02:00
Colin Maudry 0d0c0d0a75 Acheteur et titulaire appliquent bien la liste de colonnes par défaut 2026-04-15 11:35:10 +02:00
Colin Maudry ade9a20926 Tableau applique bien la liste de colonnes par défaut 2026-04-14 17:39:52 +02:00
Colin Maudry 24e0cee2b1 Améliorations de typage 2026-03-30 14:58:18 +02:00
Colin Maudry 9e8811e080 Merge tag 'v2.7.1' into dev
- Correction du partage de données filtrées entre dashboard et vue des données
2026-03-23 13:40:10 +01:00
Colin Maudry 06186c1691 Merge branch 'hotfix/2.7.1' 2026-03-23 13:40:01 +01:00
Colin Maudry 30b6874045 Changelog 2.7.1 2026-03-23 13:39:49 +01:00
Colin Maudry 6e5f4011e5 On garde toutes les valeurs en paramètre puour prepare_dashboard_data 2026-03-23 13:39:37 +01:00
Colin Maudry 1f48319a0f Utilisation d'un store plutôt que df global, regroupement des inputs/outputs 2026-03-23 13:25:08 +01:00
Colin Maudry 75005f43af Merge tag 'v2.7.0' into dev
- Remplacement de la page Statistiques par l'observatoire
- Généralisation de la grille dash (`dbc.Row`, `dbc.Col`)
- Ajout de l'histogramme de distances aux pages acheteur et titulaire
- Ajout de la colonne `acheteur_categorie` (commune, État, etc.)
2026-03-23 07:49:56 +01:00
Colin Maudry 08fc4dcfdc Merge branch 'release/2.7.0' 2026-03-23 07:49:04 +01:00
Colin Maudry ad3f2cf654 Changelog 2.7.0 2026-03-23 07:48:54 +01:00
Colin Maudry 24bce6e2d6 Lien vers #sources 2026-03-23 07:43:37 +01:00
Colin Maudry 72da1a15e2 Nettoyage HTML recherche.py 2026-03-23 07:43:37 +01:00
Colin Maudry f4514bf06c Le bouton Partager n'apparaît que s'il y a des filtres 2026-03-23 07:43:02 +01:00
Colin Maudry a54f78875e Utilise l'année en cours pour plafonner les données du graph sources #65 2026-03-21 12:25:31 +01:00
Colin Maudry 7b78e0a0ea Style, ordre des années #65 2026-03-21 12:09:00 +01:00
Colin Maudry eecd75ac42 Style des boutons Prévisualiser et Partager #65 2026-03-21 11:55:48 +01:00
Colin Maudry b06f3c91e0 test: add multi-param URL round-trip test for observatoire
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:33:38 +01:00
Colin Maudry 139b820b6b fix: remove duplicate observatoire-share-url element in layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:32:43 +01:00
Colin Maudry bb2cde2fc5 feat: sync_observatoire_share_url encodes all 17 filter params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:31:47 +01:00
Colin Maudry 3957ca1662 feat: restore_filters reads all 17 filter params from URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:27:54 +01:00
Colin Maudry 547accd7be fix: update test_010 to use prepare_dashboard_data instead of removed _apply_filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:25:54 +01:00
Colin Maudry ff6b5d0d41 Add design spec for full URL sharing on observatoire page 2026-03-21 11:15:26 +01:00
Colin Maudry a3dce84cc5 Merge branch 'feature/preview_data' into dev 2026-03-21 10:28:19 +01:00
Colin Maudry e6bf671f16 Prévisualisation des données: bonnes données téléchargées #65 2026-03-21 10:28:07 +01:00
Colin Maudry b34f63f711 Prévisualisation des données: choix des colonnes #65 2026-03-21 10:17:58 +01:00
Colin Maudry d6e14e2564 Prévisualisation des données fonctionnelle #65 2026-03-21 10:02:11 +01:00
Colin Maudry dd63feeeac Style des boutons 2026-03-20 17:49:27 +01:00
Colin Maudry 3b2cb15935 Prévisualisation basique des données #65 2026-03-20 17:38:10 +01:00
Colin Maudry 91ea9eccea Top 10 acheteurs et titulaires #65 2026-03-20 16:44:29 +01:00
Colin Maudry a89677604b Médian des distances titulaire-acheteur #65 2026-03-20 14:20:44 +01:00
Colin Maudry e5419bab9c Donut : ajout à autres si moins de 1% #65; 2026-03-20 13:57:44 +01:00
Colin Maudry 4f9f31c4c7 Ajout des filtres objet et code CPV #65 2026-03-20 11:46:56 +01:00
Colin Maudry 28fdca2a06 Tri des départements pour dropdown #65 2026-03-20 11:33:25 +01:00
Colin Maudry 771dcf0ea1 Moins de barres dans l'histogramme distances #65 2026-03-20 11:32:50 +01:00
Colin Maudry dac9efee88 Carte résumé => figures.py #65 2026-03-20 10:20:44 +01:00
Colin Maudry 50c3947c04 Filtres sur techniques, sous-traitance, innovant #65 2026-03-19 18:36:54 +01:00
Colin Maudry 9852d55a3d Tentative de tri des départemetns 2026-03-19 13:02:42 +01:00
Colin Maudry 144e714235 fix: reduce right margin in distance histogram to use full card width
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:02:41 +01:00
Colin Maudry a9d3d96a5e "Partager" masqué dans l'observatoire pour l'instant #65 2026-03-19 00:04:59 +01:00
Colin Maudry dbfc20921a Amélioration du layout dans titulaire et acheteur 2026-03-18 23:54:41 +01:00
Colin Maudry d8f1a884a3 fix: show actual km ranges in distance histogram tooltip
Replace px.histogram with manually-computed go.Bar so hover text
displays human-readable distance ranges (e.g. "8.9 – 10.0 km")
instead of raw log10 bin values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:35:04 +01:00
Colin Maudry 0ac6c55d9e Correction imports 2026-03-18 23:21:47 +01:00
Colin Maudry 6a6be51455 refactor: replace CSS grid layout with Dash Bootstrap Components grid
Replace the custom CSS grid (`.wrapper`, `.org_*`, `.results_*` classes)
in acheteur, titulaire, and recherche pages with dbc.Row/dbc.Col.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:21:17 +01:00
Colin Maudry 52958ccbaa Merge branch 'feature/65_observatoire' into dev 2026-03-18 22:59:28 +01:00
Colin Maudry 8a61d3268a fix: lecture du dataframe plus flexible dans la création de l'histogram 2026-03-18 22:53:17 +01:00
Colin Maudry 32aa877797 fix: guard against missing titulaire_distance column in get_distance_histogram 2026-03-18 22:40:28 +01:00
Colin Maudry 858ab6c61a feat: add distance histogram to titulaire detail page 2026-03-18 22:28:34 +01:00
Colin Maudry b08d517f36 feat: add distance histogram to acheteur detail page 2026-03-18 22:26:57 +01:00
Colin Maudry 44d2d7d2c1 feat: add distance histogram card to observatoire dashboard
Integrate the get_distance_histogram function into the observatoire dashboard
to display buyer-contractor distance distribution on a logarithmic scale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:24:01 +01:00
Colin Maudry f2af09bb35 fix: use streaming collect and filter zero distances in get_distance_histogram 2026-03-18 22:22:54 +01:00
Colin Maudry 24db748b6a feat: add get_distance_histogram figure function
Implement get_distance_histogram that creates a histogram of titulaire distances
with logarithmic scale. Add 3 unit tests covering basic functionality, null handling,
and edge cases. Also add DATA_SCHEMA_PATH to pytest env config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:15:26 +01:00
Colin Maudry 1ffa995a4a docs: spec for distance histogram on observatoire, acheteur, titulaire pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:02:20 +01:00
Colin Maudry 9da3d9ce34 Ne pas supprimer les données de test à la fin 2026-03-18 21:48:23 +01:00
Colin Maudry 125c520c19 Claude memory 2026-03-18 21:47:29 +01:00
Colin Maudry 78d528f75b Filtre par montant #65 2026-03-18 21:13:21 +01:00
Colin Maudry c77511d4e8 Taille de donut flexible #65 2026-03-18 20:57:30 +01:00
Colin Maudry f2046d6ba7 Meilleure intégration du nom de l'org #65 2026-03-18 20:46:38 +01:00
Colin Maudry 26dd2aaf1a Correction de l'injection du nom d'org dans le titre #65 2026-03-18 20:41:46 +01:00
Colin Maudry f8fffb6fa4 get_top_org un peu plus configurable #65 2026-03-18 20:40:59 +01:00
Colin Maudry b4a42449ad feat: restore observatoire filters from localStorage on page load #65 2026-03-18 20:35:31 +01:00
Colin Maudry 051e908bd1 feat: save observatoire filters to localStorage on change #65 2026-03-18 20:33:15 +01:00
Colin Maudry 1fdcb12dd2 feat: add dcc.Store and debounce text inputs on observatoire page #65 2026-03-18 20:32:43 +01:00
Colin Maudry 5d4c0b8438 test: failing test for observatoire localStorage filter persistence #65 2026-03-18 20:31:45 +01:00
Colin Maudry 958c3956ea Correction des problèmes de double reload #65 2026-03-18 16:05:29 +01:00
Colin Maudry acb8500dc0 Test e2e : recherche → observatoire #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:02:27 +01:00
Colin Maudry e804b6bca2 URL partageable pour la page observatoire #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:59:32 +01:00
Colin Maudry 3745f6df74 Callback URL → filtres sur la page observatoire #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:55:41 +01:00
Colin Maudry bf1791635f Ajout du lien observatoire dans titulaire_nom via add_links() #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:48:05 +01:00
Colin Maudry 1d88682f85 Ajout du lien observatoire dans acheteur_nom via add_links() #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:43:10 +01:00
Colin Maudry d1a876ba9c Plan d'implémentation : lien observatoire depuis recherche/tableau #65
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:35:58 +01:00
Colin Maudry 3a70bbd9ea Ajout du spec : lien observatoire depuis recherche/tableau #65
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:24:11 +01:00
Colin Maudry 79a06f996e Bouton de téléchargement des données #65 2026-03-18 13:54:00 +01:00
Colin Maudry 2f90a754ab Style inputs, ajout donut type marché #65 2026-03-18 13:38:38 +01:00
Colin Maudry d50ec5b01e Statistiques => Observatoire #65 2026-03-17 22:59:01 +01:00
Colin Maudry c02fb995c5 Ajout de filtres et des donuts de catégorie #65 2026-03-17 22:44:15 +01:00
Colin Maudry 6c778882f9 Filtre par type de marché #65 2026-03-17 20:51:09 +01:00
Colin Maudry e754a3a217 Configuration fine des tailles de cartes #65 2026-03-17 20:33:36 +01:00
Colin Maudry 9629231e29 Fixed masquage silencieux de la chloropleth #65 2026-03-17 17:30:41 +01:00
Colin Maudry ab3377ef60 Grid de cards avec points de rupture #65 2026-03-17 17:16:24 +01:00
Colin Maudry f531ce7091 Choix de carte dynamique, même pour les TOM #65 2026-03-16 18:06:32 +01:00
Colin Maudry adc8457abc Cartes avec cluster de points si > lignes #65 2026-03-15 16:05:33 +01:00
Colin Maudry 302d253e6e Utilisation de cluster de marqueurs grâce à dash-leaflet #65 2026-03-14 00:28:02 +01:00
Colin Maudry f0f9d8cb3d dashboard_acheteur_departement_code est une liste 2026-03-14 00:15:53 +01:00
Colin Maudry 4771d14744 Utilise map_count_marches si trop de marchés 2026-03-14 00:00:15 +01:00
Colin Maudry d9f97cf8b3 Modification de map_count_marches (plus efficace, utilise le dép de l'acheteur) 2026-03-13 23:59:54 +01:00
Colin Maudry c7d1a5ec73 Début de dashboard avec quelques filtres et viz #65 2026-03-13 19:22:48 +01:00
Colin Maudry 4f19085b75 Merge branch 'main' into feature/65_observatoire 2026-03-03 14:31:00 +01:00
Colin Maudry 0db800fdab Changelog 2.6.2 2026-02-22 18:41:01 +01:00
Colin Maudry 4619dd2708 Correction du téléchargemnent buggé dans /tableau + test 2026-02-22 18:39:31 +01:00
Colin Maudry 367e5f64ff Changelog 2.6.1 2026-02-17 14:50:29 +01:00
Colin Maudry adda58bada Suppresion des liens canoniques, pas trouvé comment les insérer assez tôt 2026-02-17 14:24:46 +01:00
Colin Maudry 2b0b048520 Amélioration du nom titulaire/acheteur 2026-02-15 16:54:30 +01:00
Colin Maudry 5c43bfea78 AMélioration de la génération de liens canoniques (pas de doublons) 2026-02-15 16:50:09 +01:00
Colin Maudry 15c5a800ed Merge tag 'v2.6.0' into dev
- Suite de la refonte graphique
- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages
- Joli tableau pour choisir les colonnes à afficher
- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE
- Amélioration du SEO (liens canoniques)
2026-02-05 18:23:44 +01:00
Colin Maudry 83119b86e9 Merge branch 'release/2.6.0' 2026-02-05 18:23:26 +01:00
Colin Maudry af89bb0630 Plus grosses cases à cocher 2026-02-05 18:22:17 +01:00
Colin Maudry eb5be1972e Correction de soucis de sauvegarde des colonnes dans acheteur.py 2026-02-05 18:10:20 +01:00
Colin Maudry b303a8bea6 Bump version 2.6.0 et changelog 2026-02-05 17:29:54 +01:00
Colin Maudry 472fbb7cbb Améliorations des textes et petits ajustements 2026-02-05 17:16:31 +01:00
Colin Maudry 6891aabd4d Merge branch 'feature/ui_tests' into dev 2026-02-05 15:05:59 +01:00
Colin Maudry 3beedbcfe4 Test de la persistence des fitres (toutes pages) 2026-02-05 15:05:37 +01:00
Colin Maudry 37e893d642 Utilisation de la persistence sur /acheteurs 2026-02-05 14:54:30 +01:00
Colin Maudry 64fc40e5aa Meilleure gestion des SIRET absents du SIRENE dans acheteur et titulaire 2026-02-05 14:18:38 +01:00
Colin Maudry cb885eeb90 Utilisation des options de persistence de Dash 2026-02-04 23:59:01 +01:00
Colin Maudry 61dbf237cf Premier test UI 2026-02-04 23:33:59 +01:00
Colin Maudry 39af4bafbe titulaire : clientside callback ajouté 2026-02-04 13:05:59 +01:00
Colin Maudry f480439985 acheteur : le clientside callback est aussi trigger quand les colonnes affichées changent 2026-02-04 13:04:11 +01:00
Colin Maudry ad9b4f0fdf Nettoyage des filtres aussi sur acheteur (clientside callback) 2026-02-04 12:17:41 +01:00
Colin Maudry 0532f6b0a7 Colonnes, filtres et tri de la page titulaire en LocalStorage (buggy) 2026-02-04 09:46:22 +01:00
Colin Maudry 6fdf01e5e1 Colonnes, filtres et tri de la page acheteur en LocalStorage 2026-02-02 19:56:27 +01:00
Colin Maudry 15de2aaf17 Placeholder de dropdown plus visible 2026-02-02 19:51:40 +01:00
Colin Maudry 55e2992559 id table => tableau_datatable 2026-02-02 19:51:21 +01:00
Colin Maudry f16cca036d Toutes les pages s'ouvrent dans le même onglet puisque plus de perte de filtre 2026-02-02 19:02:27 +01:00
Colin Maudry deba0a244e Ajout d'un lien canonique vers chaque page dans le head #70
Dynamique, donc pas sûr que les moteurs de recherche le voit.
2026-02-02 18:28:50 +01:00
Colin Maudry ef11184286 Les colonnes masquées sont stockées en LocalStorage et correctement synchronisées 2026-02-02 17:11:32 +01:00
Colin Maudry edd52d471e Suppression (pour l'instant de la carte acheteur/titulaire) 2026-01-31 19:20:41 +01:00
Colin Maudry 7b3ea9c580 Les tris sont stockés en LocalStorage 2026-01-31 19:12:34 +01:00
Colin Maudry bfc4065cbb Les filtres sont stockés en LocalStorage 2026-01-31 18:28:19 +01:00
Colin Maudry 41a832d7be Tableau de choix des colonnes aussi sur les pages acheteur et titulaire 2026-01-31 17:38:54 +01:00
Colin Maudry 1561cb3c9a Tableau de choix des colonnes => filtrable 2026-01-31 01:44:11 +01:00
Colin Maudry f1725a95f8 Une ligne de boutons 2026-01-31 01:41:08 +01:00
Colin Maudry b59afd1cbc Merge branch 'feature/columns_modal' into dev 2026-01-31 01:34:20 +01:00
Colin Maudry 75b42f4baf Correction bug style cell 2026-01-31 01:33:26 +01:00
Colin Maudry 8f5d38ff78 Petites améliorations sur les boutons 2026-01-31 01:29:37 +01:00
Colin Maudry bd149ff11f Meilleure gestion des styles dans les tableaux 2026-01-31 01:27:59 +01:00
Colin Maudry cd81aa9532 Syncro sélection de colonnes et tableau fonctionnelle 2026-01-31 01:04:49 +01:00
Colin Maudry 2a381f6dbb Merge branch 'feature/mode_demploi_modal' into dev 2026-01-30 21:14:03 +01:00
Colin Maudry e609bc3e32 Weight des titres 2026-01-30 21:13:29 +01:00
Colin Maudry c3ebdb0057 Améliorations styles tableaux, headers 2026-01-30 20:13:59 +01:00
Colin Maudry 55bb92468e Styles de boutons homogènes 2026-01-30 20:05:58 +01:00
Colin Maudry 965aef06f9 Bonne application des fonts dans les cellules de tableau 2026-01-30 20:04:43 +01:00
Colin Maudry 3ce37784dc Modal mode d'emploi et style boutons 2026-01-30 19:22:54 +01:00
Colin Maudry cee51d929c Petites améliorations au suivi des recherches 2026-01-30 18:29:48 +01:00
Colin Maudry b11aa20743 Correction loggers 2026-01-30 15:19:46 +01:00
Colin Maudry 674639cf6e Merge branch 'main' into dev 2026-01-29 21:49:36 +01:00
Colin Maudry 68a87985c7 Bump version number 2.5.1 2026-01-29 21:48:35 +01:00
Colin Maudry 776aa271f1 Correction de la recherche #67 2026-01-29 21:48:13 +01:00
Colin Maudry 427eb111bf Merge tag 'v2.5.1' into dev
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68))
2026-01-29 21:27:12 +01:00
Colin Maudry fe7277ce26 Merge branch 'hotfix/2.5.1' 2026-01-29 21:26:57 +01:00
Colin Maudry b68fa3872f Changelog 2.5.1 2026-01-29 21:26:54 +01:00
Colin Maudry b7ff04c69c Correction de l'affichage de la distance 2026-01-29 21:26:16 +01:00
Colin Maudry ccee27baee Correction de la couleur des bordures dans statistiques 2026-01-29 21:19:36 +01:00
Colin Maudry 47e17c48e7 Bugs de créations de noms acheteur/titulaire #67 #68 2026-01-29 21:16:35 +01:00
Colin Maudry e3b57608f4 padding right dans les td 2026-01-29 20:32:35 +01:00
Colin Maudry f4f0d70864 Merge branch 'release/2.5.0' 2026-01-29 20:26:31 +01:00
Colin Maudry 10c69013f0 Bump version 2.5.0 2026-01-29 20:26:00 +01:00
Colin Maudry c08e2fa143 Date changelog 2026-01-29 20:24:43 +01:00
Colin Maudry afd812c50d Suppression des styles non utilisés 2026-01-29 19:55:11 +01:00
Colin Maudry 536d37d67c Correction style tableau sources (bords) 2026-01-29 19:39:22 +01:00
Colin Maudry f43bb14e23 Alignement à droite pour les nombres 2026-01-29 19:38:21 +01:00
Colin Maudry 5422f95ea7 400 et 600 en weight 2026-01-29 19:37:56 +01:00
Colin Maudry 29f6e5c2c2 Finalisation en-têtes 2026-01-29 19:37:35 +01:00
Colin Maudry c311ed5f23 Doublons 2026-01-29 19:36:29 +01:00
Colin Maudry 31d0a626e6 Version number redesign 2026-01-29 19:24:07 +01:00
Colin Maudry 307e3379e0 Angles arrondis OK et tableau aligné 2026-01-29 18:48:30 +01:00
Colin Maudry 1fb18bcf6f Tentative d'angles ronds pour les data tables 2026-01-29 18:23:24 +01:00
Colin Maudry 30e822d435 Quelques améliorations visuelles sur les tableaux 2026-01-29 03:01:50 +01:00
Colin Maudry a44aaf6df6 Changelog 2026-01-29 02:43:14 +01:00
Colin Maudry 8c11a018a0 Alignement du logo et du numéro de version 2026-01-29 02:38:10 +01:00
Colin Maudry a0d6222424 Ajout des attributions pour les fonts 2026-01-29 02:25:47 +01:00
Colin Maudry 25115c305c Smoothing des fonts, logo plus fin 2026-01-29 02:25:24 +01:00
Colin Maudry 9d9533b8a2 Auto-cleanup des filtres après restauration URL 2026-01-29 01:41:21 +01:00
Colin Maudry e4f06acc56 Exemple de filtres domain-agnostic 2026-01-29 01:40:12 +01:00
Colin Maudry e0dc2687c4 Logging bug avec le contenu des filter_part 2026-01-29 01:39:27 +01:00
Colin Maudry 274e06cde4 Hébergement de bootstrap pour supprimer google fonts 2026-01-29 01:37:56 +01:00
Colin Maudry f9aadede8b robots.txt généré par le code #66 2026-01-29 01:37:08 +01:00
Colin Maudry d35862beb0 Réorganisation du CSS 2026-01-29 00:31:30 +01:00
Colin Maudry ed6811266d Nombreuses améliorations esthétiques et texte (font, couleurs, etc.) 2026-01-29 00:23:28 +01:00
Colin Maudry 06f658ccf1 Suivi des filtres comme des recherches, avec page source 2026-01-28 22:18:39 +01:00
Colin Maudry e4c6659205 Exclue des colonnes que si *_left *_right 2026-01-28 22:17:19 +01:00
Colin Maudry 434a659778 Généralisation du logging avec logger 2026-01-28 22:16:11 +01:00
Colin Maudry e573fcccae Page d'accueil plus accueillante, plus de recheche auto 2026-01-28 21:59:54 +01:00
Colin Maudry 84739df7cb Les pages dédiées au SEO ont leur répertoire #66 2026-01-28 21:59:06 +01:00
Colin Maudry 1d241ca9b7 Améliorations de l'UX tableau 2026-01-28 21:15:31 +01:00
Colin Maudry 0264aae3b9 Suivi des filtres appliqués 2026-01-28 18:42:25 +01:00
Colin Maudry fb5c37b34b Merge branch 'feature/66_seo' into dev 2026-01-28 14:06:03 +01:00
Colin Maudry 639b342178 Changelog #66 2026-01-28 14:02:25 +01:00
Colin Maudry e8534c8108 Finalement suppression des json-ld titulaire et acheteur #66 2026-01-28 14:01:15 +01:00
Colin Maudry f5d8026061 Ajout des données JSON-LD aux pages titulaires #66 2026-01-28 13:50:05 +01:00
Colin Maudry 1eecc45e29 Meta keywords (même si a priori ça aide pas pour le SEO) #66 2026-01-28 13:49:19 +01:00
Colin Maudry 07bdcf232d Ajout des données JSON-LD acheteur et fix make_org_jsonld #66 2026-01-28 13:32:08 +01:00
Colin Maudry 6e992e0a6e Ajout des données JSON-LD acheteur #66 2026-01-28 13:30:47 +01:00
Colin Maudry a76a14b030 Petits ajustements 2026-01-28 12:49:04 +01:00
Colin Maudry b3bd488882 Liste des marchés par acheteur et par titulaire #66 2026-01-28 12:48:47 +01:00
Colin Maudry a385e999d3 Plus jolie table des sources 2026-01-28 11:59:04 +01:00
Colin Maudry a4bda0483f Déplacement de la génération des df en bas, nouveaux dfs de base 2026-01-28 11:58:41 +01:00
Colin Maudry 9a1e5bee63 Départements sous forme de liste, ajout des titulaires par département #66 2026-01-28 11:57:41 +01:00
Colin Maudry 1c4fccac6e Nom de l'acheteur et du titulaire dans les titres #66 2026-01-28 11:50:55 +01:00
Colin Maudry 187feee544 Ajout d'une arborescence de departements #66 2026-01-27 17:48:49 +01:00
Colin Maudry 965f24ab92 Meta tag noindex sur test.decp.info 2026-01-26 07:02:03 +01:00
Colin Maudry 3aee2667d0 Meilleure description pour la page d'accueil 2026-01-26 07:01:35 +01:00
Colin Maudry 3d96a5e1b8 Ajout du JSON-LD #66 2026-01-24 17:19:47 +01:00
Colin Maudry 35928d7245 Namespacing de dcc.Location dans /acheteur 2026-01-24 14:44:32 +01:00
Colin Maudry ac42dde488 Fin de l'exception pour les colonnes *_left *_right 2026-01-24 14:43:33 +01:00
Colin Maudry ae366bf014 Page marché : objet en H1, durée restante, catégorie titulaire, distance 2026-01-24 14:42:45 +01:00
Colin Maudry bd64c397dc Namespacing des dcc.Location pour éviter les conflits entre pages 2026-01-24 14:35:12 +01:00
Colin Maudry 937e8be356 distance => titulaire_distance (+ affichage dans vue marché) 2026-01-24 14:19:02 +01:00
Colin Maudry e880eaff12 Description optionnelle 2026-01-22 18:58:23 +01:00
Colin Maudry 07a97293cc Merge branch 'main' into dev 2026-01-22 18:51:17 +01:00
Colin Maudry ef97cea679 Exclure les colonnes _right et _left 2026-01-22 18:49:08 +01:00
Colin Maudry c9fc2a01fc Changelog 2.4.1 2026-01-22 18:36:25 +01:00
Colin Maudry 8002bd711a Gestion des colonnes absentes du schéma 2026-01-22 18:32:27 +01:00
Colin Maudry 1ba78fe8df Màj changelog 2.4.0 2026-01-22 18:17:26 +01:00
Colin Maudry f1e24794e6 Màj changelog 2.4.0 2026-01-22 18:04:29 +01:00
Colin Maudry 9110b78f2a Merge tag 'v2.4.0' into dev
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50))
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36))
- Désactivation du bloquage des robot d'agents de LLM (robots.txt)
2026-01-22 17:58:47 +01:00
Colin Maudry 2ce6005c88 Merge branch 'release/2.4.0' 2026-01-22 17:57:53 +01:00
Colin Maudry 4234736656 Déblockage de tous les bots d'agents LLM 2026-01-22 17:42:39 +01:00
Colin Maudry 39faeca4d3 Màj mode d'emploi #42 2026-01-22 16:46:19 +01:00
Colin Maudry 82bd82944a Changelog #36 2026-01-22 16:36:55 +01:00
Colin Maudry 92f30a963f Ajout d'une table des matières dans A propos #36 2026-01-22 16:31:25 +01:00
Colin Maudry 267e2e15f8 Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (text* et *text) #42 2026-01-21 18:04:33 +01:00
Colin Maudry dd52bd5158 Amélioration des titres et descriptions de pages #50 2026-01-21 17:47:03 +01:00
Colin Maudry d499906bbe Génération d'une sitemap #50 2026-01-21 17:40:53 +01:00
Colin Maudry 2fc501efbb Container de l'app toujours fluid, fix noms props #63 2026-01-21 16:32:20 +01:00
Colin Maudry 945f7974c8 Configuration du point de rupture de la navbar à 992px #63 2026-01-21 15:13:59 +01:00
Colin Maudry 1044379425 Meilleur affichage sur petit écran #63 2026-01-21 14:22:52 +01:00
Colin Maudry 421eaa0772 Premier jet navbar #63 2026-01-20 17:31:42 +01:00
Colin Maudry 3776b18ff5 Meilleure gestion des colonnes absentes du schéma 2026-01-20 11:24:26 +01:00
Colin Maudry 6005b2fcb5 Mise en valeur des moyens de consommer les données 2026-01-19 11:44:41 +01:00
Colin Maudry fc99723b2e Gestion des colonnes ajoutées mais absentes du schéma 2026-01-19 11:44:14 +01:00
Colin Maudry c3e801a15a Utilisation des dbc pour /marche #63 2026-01-19 11:34:20 +01:00
Colin Maudry 38ea8834af Merge tag 'v2.3.1' into dev
- Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs
2026-01-16 14:16:47 +01:00
Colin Maudry efac172ac8 Merge branch 'hotfix/2.3.1' 2026-01-16 14:11:15 +01:00
Colin Maudry 337f68f2fc Changelog 2.3.1 2026-01-16 14:10:56 +01:00
Colin Maudry 2d43654c81 Ignore les champs absents du schéma 2026-01-16 14:08:42 +01:00
Colin Maudry 5bcd17e8b0 Skip les colonnes absentes du schéma 2026-01-16 13:57:19 +01:00
Colin Maudry ec28e89788 Style et texte 2026-01-16 03:14:56 +01:00
Colin Maudry 338311fc62 Style et texte 2026-01-16 02:57:35 +01:00
Colin Maudry 86b445a007 Tableau pour les statistiques par an 2026-01-16 02:18:17 +01:00
Colin Maudry 16a914a98a Ajout de la matrice de doublons par source 2026-01-16 02:17:47 +01:00
Colin Maudry 0bc82fae1e màj de l'état des sources de DECP 2026-01-16 00:31:27 +01:00
Colin Maudry 3c68317f9d Utilisation des stats produites par decp_processing 2026-01-14 18:16:50 +01:00
Colin Maudry d8dcb04fbf Fix anchor pour Sources 2025-12-27 10:18:01 +01:00
Colin Maudry edfe6dd65e Correction URL changelog 2025-12-24 11:44:40 +01:00
Colin Maudry 021361e2c1 Màj CHANGELOG.md 2025-12-24 11:34:23 +01:00
Colin Maudry 04ec9438f1 Liste de colonnes dans les URL plus compacte #58 2025-12-24 11:32:15 +01:00
Colin Maudry e6960dc16d Merge branch 'main' into dev 2025-12-24 11:05:28 +01:00
Colin Maudry 2fb71e466c Exemple d'URL dans le CHANGELOG 2025-12-24 11:02:35 +01:00
Colin Maudry b47463b638 L'URL de partage contient les colonnes à afficher, non les colonnes à masquer #58 2025-12-24 10:50:21 +01:00
Colin Maudry 84cf48182d Possibilité de filtrer une colonne avec plusieurs mots 2025-12-24 10:16:25 +01:00
Colin Maudry 0f11a575ff Merge tag 'v2.3.0' into dev
- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire
- Possibilité de partager les filtres, tris et choix de colonnes via une adresse Web
2025-12-24 09:46:48 +01:00
Colin Maudry c9459771ac Merge branch 'release/2.3.0' 2025-12-24 09:45:55 +01:00
Colin Maudry 99eef0eaa3 Ajout mode d'emploi #58 2025-12-24 09:40:15 +01:00
Colin Maudry 4719ef5754 Ajout de copy.svg 2025-12-24 09:35:55 +01:00
Colin Maudry 032ca5554e Déplacement des notes de version vers CHANGELOG.md 2025-12-24 09:31:33 +01:00
Colin Maudry 90d66bcbb6 Merge branch 'feature/58_filter_url' into dev 2025-12-18 04:25:39 +01:00
Colin Maudry 43104f611e Réparation des callbacks, mode d'emploi #58 2025-12-18 04:23:53 +01:00
Colin Maudry 24ef21761f Gestion des annonces dans .env 2025-12-18 01:37:58 +01:00
Colin Maudry e0fa8464d2 Merge tag 'v2.2.3' into dev
- mise à jour de l'adresse email de contact (colmo.tech)
- message sur l'indisponibilité des données MINEF
2025-12-04 16:40:14 +01:00
Colin Maudry 0691329b04 Merge branch 'hotfix/2.2.3' 2025-12-04 16:38:38 +01:00
Colin Maudry 359ba248ac Changelog 2.2.3 2025-12-04 16:38:19 +01:00
Colin Maudry 19d7735306 Message informant du problème avec les données MINEF 2025-12-04 16:34:56 +01:00
Colin Maudry 43fd2df2c0 Suggested by Gemini Pro, but race condition: filters apply before columns are created #58
Prompt: - In the tableau page (src/pages/tableau.py), I want to allow users to copy a URL in their clipboard that enables opening the page with the same view: applied filters, sorting and column selection of the datatable. Typically to share the view with a colleague via email or chat.
  In terms of Dash callbacks, that would mean the following:
	- one callback syncs the filters, the sort parameters and the selection of columns with a read-only text input that stores the URL to copy. This callback also ensures the button to copy to the clipboard is visible, possibly replacing the confirmation message (see next point)
	- one callback reacts to clicks on a button to store the URLin the clipboard. This button in on the same row as "Télécharger au format Excel" button. When clicked, the URL is stored in the clipboard, and the button is replaced with a confrmation message (URL copiée)
	- one callback reads the URL, and applies the filters, sorting and column selection
- The URL stores the view configuration (filters, sorting and columns) in URL parameters, similar to what is done in REST APIs. The values are the same as stored in the DataTable parameters, just URL encoded. The URL parameters are in French: filtres, tris, colonnes.
2025-12-03 16:31:05 +01:00
Colin Maudry 9ae6f29391 Plus de tolérance dans l'ingestion des data dicts 2025-11-24 16:54:50 +01:00
Colin Maudry 80a7e51ec4 Merge branch 'feature/share_filtering_datatables' into dev 2025-11-24 16:01:34 +01:00
Colin Maudry e216b42379 Meilleure gestion des df vides acheteur/titulaire 2025-11-24 16:00:21 +01:00
Colin Maudry 7f42ca67a2 Bonne configuration des boutons de téléchargemetn 2025-11-24 15:33:27 +01:00
Colin Maudry ec15852406 Téléchargemetn filtré OK dans acheteur 2025-11-24 14:00:28 +01:00
Colin Maudry 8c98a60c75 filtres, tris fonctionnels (acheteur) 2025-11-24 13:43:42 +01:00
Colin Maudry 565a0cfd08 Custom filter logic 2025-11-24 11:17:05 +01:00
Colin Maudry 53919bff3e Cast acheteur_year to int et non dateNotification to string 2025-11-24 10:41:47 +01:00
Colin Maudry 193cf5302c Merge branch 'main' into dev 2025-11-24 09:38:27 +01:00
Colin Maudry fd2f87e382 Annonce fichiers excel fonctionnels 2025-11-24 09:37:35 +01:00
Colin Maudry 59e363a1fc Merge tag 'v2.2.2' into dev
- Correction d'un bug dans le téléchargement Excel
2025-11-22 18:49:11 +01:00
Colin Maudry fdeedde983 Merge branch 'hotfix/2.2.2' 2025-11-22 18:48:51 +01:00
Colin Maudry 925d050c96 Changelog 2.2.2 2025-11-22 18:48:42 +01:00
Colin Maudry 5d4ee692f3 Changements de type pour le téléchargement 2025-11-22 18:45:14 +01:00
Colin Maudry a51d2a83b4 Nettoyage 2025-11-18 15:37:47 +01:00
Colin Maudry 6b133c21ab Classe partagée pour la génération de datatables #56 2025-11-15 21:08:28 +01:00
Colin Maudry 503ec33f43 Classe partagée pour la génération de datatables #56 2025-11-15 21:01:59 +01:00
Colin Maudry 6b6d2ca790 Bump version 2.2.1 2025-11-15 16:49:29 +01:00
Colin Maudry fa84271759 Merge tag 'v2.2.1' into dev
- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté)
- Phrase "tagline" au-dessus du champ de recherche
- Les infos de Contact rebasculent dans À propos
- Police de caractère "Open Sans" généralisée
2025-11-15 16:48:53 +01:00
Colin Maudry d8f5739168 Merge branch 'hotfix/2.2.1' 2025-11-15 16:48:46 +01:00
Colin Maudry dd07351bdd Changelog 2.2.1 2025-11-15 16:48:37 +01:00
Colin Maudry 4fb376d43d Open Sans partout 2025-11-14 23:03:39 +01:00
Colin Maudry 2335ae3d59 Contact => À propos 2025-11-14 18:29:32 +01:00
Colin Maudry 9e9770b1a0 Ajout d'une phrase d'intro et développement du placeholder search #58 2025-11-14 18:17:42 +01:00
Colin Maudry ce4570afad Suppression de l'annonce sur le contact 2025-11-14 18:16:06 +01:00
Colin Maudry 4c72c0e269 Suppression du workflow tag release 2025-11-13 14:59:11 +01:00
Colin Maudry 9a035ae431 Ignorer les - dans les recherches #58 2025-11-13 14:53:08 +01:00
Colin Maudry f511c4fcef Merge tag 'v2.2.0' into dev
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
2025-11-13 14:27:01 +01:00
Colin Maudry d58183beb5 Merge branch 'release/2.2.0' 2025-11-13 14:26:41 +01:00
Colin Maudry 18f8388df5 Changelog 2.2.0 2025-11-13 14:25:41 +01:00
Colin Maudry ef39be3346 Réduction du logging httpx 2025-11-13 14:23:08 +01:00
Colin Maudry 783fb88d0c Line height 2025-11-13 14:13:36 +01:00
Colin Maudry c19076ee85 Hauteur de lignes tableau 2025-11-12 22:18:46 +01:00
Colin Maudry c44ef311c3 Suppression du temps de latence entre les recherches #58 2025-11-12 22:09:15 +01:00
Colin Maudry 63622d83f5 Suivi des recherches #58 2025-11-12 16:08:38 +01:00
Colin Maudry 5bfce2a1d9 Merge branch 'main' into dev 2025-11-12 15:07:36 +01:00
Colin Maudry a3f8bd0981 Merge branch 'feature/58_search' into dev 2025-11-12 14:56:37 +01:00
Colin Maudry bb6c0dec26 Changelog #58 2025-11-12 14:55:38 +01:00
Colin Maudry 459ca6a846 Réarrangements résultats #58 2025-11-12 14:54:12 +01:00
Colin Maudry 8637915eb2 Cleanup HTML 2025-11-11 12:50:58 +01:00
Colin Maudry e187cf33aa Recherche fonctinonelle acheteurs et titulaires #58 2025-11-11 12:42:01 +01:00
Colin Maudry 2f3ce6a467 Amélioration du message d'annonce 2025-11-11 09:04:11 +01:00
Colin Maudry 1310caef4d Merge tag 'v2.1.7' into dev
- Remplacement du formulaire de contact par une adresse email
2025-11-11 08:42:28 +01:00
Colin Maudry efd9bd4238 Merge branch 'hotfix/2.1.7' 2025-11-11 08:39:58 +01:00
Colin Maudry 39844071ad Changelog 2.1.7 2025-11-11 08:39:41 +01:00
Colin Maudry 5c64177c6a Remplacement du formulaire de contact par email 2025-11-11 08:37:47 +01:00
Colin Maudry 1e85dbe562 Champ de recherche, récup de données dédiée #58 2025-11-11 07:47:00 +01:00
Colin Maudry 6835f328b6 Ajout de la distance dans les tableaux top #55 2025-11-10 15:30:47 +01:00
Colin Maudry a3eb48bacb Format values inclut le formatage des distances 2025-11-10 15:29:44 +01:00
Colin Maudry 1848c881ac Merge branch 'feature/55_top10' into dev 2025-11-10 13:33:42 +01:00
Colin Maudry bbb04a4487 Changelog #55 2025-11-10 13:33:37 +01:00
Colin Maudry e9faf80609 Top 10 acheteurs sur les pages titulaire #55 2025-11-10 13:30:50 +01:00
Colin Maudry f6a38a29bd Top 10 titulaires sur les pages acheteur #55 2025-11-10 13:01:17 +01:00
Colin Maudry c6e096e42a Merge branch 'feature/54_colonnes_defaut' into dev 2025-11-07 08:19:12 +01:00
Colin Maudry 4c6f63f723 Changelog #54 2025-11-07 08:15:18 +01:00
Colin Maudry f3c8fd4481 Configuration des colonnes affichées par défaut #54 2025-11-07 08:13:35 +01:00
Colin Maudry 5ca52fadd7 Merge tag 'v2.1.6' into dev
- Stabilisation de la vue marché
2025-10-15 19:44:55 +02:00
Colin Maudry ee91e7a91f Merge branch 'hotfix/2.1.6' 2025-10-15 19:44:09 +02:00
Colin Maudry 392f862baa Changelog 2.1.6 2025-10-15 19:44:02 +02:00
Colin Maudry 199476ec30 Stabilisation affichage fiche marché #40 2025-10-15 19:42:53 +02:00
Colin Maudry c98a36f759 Fix fiche marche 2025-10-14 12:54:47 +02:00
Colin Maudry 65e0f4c5f0 Gestion des montants inexistants 2025-10-10 18:25:08 +02:00
Colin Maudry bc7652f336 Merge branch 'main' into dev 2025-10-10 17:48:47 +02:00
Colin Maudry 188383a882 Changelog 2.1.5 2025-10-10 17:47:18 +02:00
Colin Maudry 7f3a0bcb21 Renommage de la Github action d'auto release 2025-10-10 17:45:15 +02:00
Colin Maudry 3270abb7f6 Refactor du filtre pour être plus résilient (et fonctionner sur les montants) 2025-10-10 17:44:51 +02:00
Colin Maudry 2eb984a95f Ne plus afficher de nulls dans les tableaux de données (tableau, titulaire, acheteur) 2025-10-10 17:44:16 +02:00
Colin Maudry 776126a481 Merge tag 'v2.1.4' into dev
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
2025-10-08 09:34:30 +02:00
Colin Maudry 59abf35a12 Merge branch 'hotfix/2.1.4' 2025-10-08 09:34:00 +02:00
Colin Maudry a40eb64245 Changelog 2.1.4 2025-10-08 09:33:51 +02:00
Colin Maudry acae1517cb Nouvelle tentative auto-release 2025-10-08 09:31:41 +02:00
Colin Maudry 9a9decde6e Possible de filtrer sur le champ 'Source' 2025-10-08 09:26:44 +02:00
Colin Maudry a1ae962c73 Changelog 2.1.3 2025-10-04 19:15:56 +02:00
Colin Maudry 0404aa0973 Adaptation au format TableSchema 2025-10-04 19:13:51 +02:00
Colin Maudry effe01dedf Tentative d'auto release à partir d'un tag 2025-10-04 19:13:25 +02:00
Colin Maudry aaa70039d2 Merge branch 'hotfix/2.1.2' 2025-10-03 16:45:35 +02:00
Colin Maudry 7dc5f09545 Changelog 2.1.2 2025-10-03 16:45:29 +02:00
Colin Maudry 91e7f373ec Coquille 2025-10-03 16:42:44 +02:00
Colin Maudry c7768ea8f1 Global lf => global df pour être moins sensible au update de fichier source 2025-10-03 16:29:58 +02:00
Colin Maudry 816d0de324 Merge tag 'v2.1.1' into dev
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43))
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
2025-10-01 13:22:42 +02:00
Colin Maudry c2fab377d5 Merge branch 'hotfix/2.1.1' 2025-10-01 13:22:07 +02:00
Colin Maudry d7cdf38cda Changelog 2.1.1 2025-10-01 13:22:02 +02:00
Colin Maudry 85f0084b7b Exhaustivité et qualité des données close #43 2025-10-01 13:19:56 +02:00
Colin Maudry 712dc5a7f8 Rétrécissement de la carte 2025-10-01 13:18:58 +02:00
Colin Maudry 84b94fd882 Affiche du nombre de marchés 2025-10-01 11:57:55 +02:00
Colin Maudry 90e9bc3c8c Merge tag 'v2.1.0' into dev
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786)
([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
 - Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖#
- Affichage du numéro de version près du logo et lien vers ici 🤓
- Variables globales uniquement en lecture (😁)
2025-09-30 17:16:45 +02:00
Colin Maudry 442f1ded27 Merge branch 'release/2.1.0' 2025-09-30 17:14:16 +02:00
Colin Maudry e4dcc19e8b template.env 2025-09-30 17:13:42 +02:00
Colin Maudry 0b828c3d67 Numéro de version, description toml 2025-09-30 17:12:41 +02:00
Colin Maudry bc930bf6c9 Màj instruction #40 2025-09-30 15:09:21 +02:00
Colin Maudry 070e8c98b9 Merge branch 'feature/40_vue_marché' into dev 2025-09-30 15:02:04 +02:00
Colin Maudry 7c157d9d71 Vue marché simple #40 2025-09-30 15:01:54 +02:00
Colin Maudry 79d11b326e Formulaire de contact avec rate limiting simple #48 2025-09-30 11:35:18 +02:00
Colin Maudry 51c023a803 Merge branch 'feature/48_contact_form' into dev 2025-09-29 20:07:19 +02:00
Colin Maudry 819f2e315f Affichage du numéro de version et lien vers changelog 2025-09-29 20:06:04 +02:00
Colin Maudry 928f75f504 Formulaire de contact avec rate limiting simple #48 2025-09-29 19:32:15 +02:00
Colin Maudry 1d21cbb69d Début de configuration de colonnes partagée 2025-09-29 17:36:12 +02:00
Colin Maudry ee2bae190d Récupération du schéma #33 2025-09-29 17:08:45 +02:00
Colin Maudry ad6005259e Les filtres <> marchent aussi avec les strings #42 2025-09-29 17:07:35 +02:00
Colin Maudry f01a991f4f Améliorations de style 2025-09-29 17:06:16 +02:00
Colin Maudry a44e1a37cb Noms de colonnes friendly et définition de colonne (tooltip) #33 2025-09-29 17:05:37 +02:00
Colin Maudry 502f652791 Chemin vers le schéma de données configurable 2025-09-29 17:03:44 +02:00
Colin Maudry fdb38b3b9b Formatage des montants #45 2025-09-28 23:11:09 +02:00
Colin Maudry 4394f38e35 Fix link titulaire 2025-09-28 22:19:07 +02:00
Colin Maudry ce9d23100d Correction des balises meta #39 2025-09-28 22:08:03 +02:00
Colin Maudry 70c5b91e6d Configuration des filtres (case insensitive, placeholder text #28 #35 2025-09-28 21:27:40 +02:00
Colin Maudry 4895bfc5ac Ajout des filtres natifs #28 #35 2025-09-28 21:05:23 +02:00
Colin Maudry d9db156626 Liens entre pages acheteur et titulaire, fournisseur => titulaire #28 #35 2025-09-28 21:01:22 +02:00
Colin Maudry d00efcd500 Script de déploiement intégré au yaml 2025-09-28 20:40:35 +02:00
Colin Maudry 41df3f6d8e Merge branch 'feature/39_meta' into dev 2025-09-28 20:21:36 +02:00
Colin Maudry 0db814710b Changelog 2025-09-28 20:21:26 +02:00
Colin Maudry c1955ac807 Ajout des balises meta og et twitter (à tester) #39 2025-09-28 20:13:38 +02:00
Colin Maudry 4081b369d9 Recherche possible dans les dates (comme des strings) #37 2025-09-28 19:16:50 +02:00
Colin Maudry 8a63f72bee Vue fournisseur minimaliste #35 2025-09-28 18:40:00 +02:00
Colin Maudry cc05ac0d99 departements.json 2025-09-28 18:02:31 +02:00
Colin Maudry 8d236ab8dc Module httpx 2025-09-28 17:57:32 +02:00
Colin Maudry 0e20b0012f Début de vue fournisseur #35 2025-09-28 00:34:47 +02:00
Colin Maudry 5122bb7f3a Merge branch 'feature/28_vue_acheteur' into dev 2025-09-28 00:33:33 +02:00
Colin Maudry 4b8565dcf2 Chanlog 2025-09-28 00:33:21 +02:00
Colin Maudry 98b8699afb Rétablissement de la carte des marchés par dpt 2025-09-28 00:30:52 +02:00
Colin Maudry 1c5868d75f Utilisation de State plutôt qu'Input pour ne pas trigger le download #28 2025-09-27 23:58:23 +02:00
Colin Maudry bfa181cefd Suppression de la variable globale df_filtered, et adaptations 2025-09-27 23:57:51 +02:00
Colin Maudry 944f207d9b Téléchargement des données acheteur filtrées #28 2025-09-27 23:15:20 +02:00
Colin Maudry 8f335a52ea Notifié => attribué #28 2025-09-27 21:50:33 +02:00
Colin Maudry e88992a470 Le lien acheteur mène maintenant à la page acheteur au lieu de l'annuaire #28 2025-09-27 21:39:40 +02:00
Colin Maudry 9e5673c28f Ajout des liens dans le tableau le plus tard possible 2025-09-27 21:14:11 +02:00
Colin Maudry 2fe92d8a14 Plus d'informations, stats #28 2025-09-27 20:49:15 +02:00
Colin Maudry b88d1c9f93 Pagination derniers marchés #28 2025-09-27 18:16:53 +02:00
Colin Maudry 59321fc80a Commune acheteur, carte localisation #28 2025-09-27 14:29:48 +02:00
Colin Maudry df04286a4a Derniers marché notifiés #28 2025-09-27 11:48:57 +02:00
Colin Maudry 2fc319d1bc Merge branch 'main' into feature/28_vue_acheteur 2025-09-27 10:08:30 +02:00
Colin Maudry 29428a8706 Merge tag 'v2.0.1' into dev
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
- Amélioration du script de déploiement (deploy.sh)
- Meilleures instructions d'installation et lancement
- Coquilles 🐚
2025-09-24 10:24:09 +02:00
Colin Maudry dbc80ad94e Merge branch 'hotfix/2.0.1' 2025-09-24 10:22:59 +02:00
Colin Maudry ec877669d4 Détails sur le déploiement dans README 2025-09-24 10:21:40 +02:00
Colin Maudry a41b70d5af Script pour le déploiement 2025-09-24 09:51:03 +02:00
Colin Maudry e41dbe772c Merged 2025-09-23 17:42:09 +02:00
Colin Maudry 5a3770027f Merge branch 'hotfix/2.0.1' 2025-09-23 17:40:14 +02:00
Colin Maudry 5c90b5f350 bump version number 2025-09-23 17:40:05 +02:00
Colin Maudry dd229a212f Bloquage du bouton de téléchargement si trop de lignes #38 2025-09-23 17:35:31 +02:00
Colin Maudry 8bcf57ce83 Merge branch 'hotfix/2.0.1' into dev 2025-09-23 13:14:29 +02:00
Colin Maudry 0fea49109a Bloquage du bouton de téléchargement si trop de lignes #38 2025-09-23 13:10:42 +02:00
Colin Maudry 8337f7cbf1 Merge branch 'hotfix/2.0.1' into dev 2025-09-23 12:30:03 +02:00
Colin Maudry 159d409237 Bloquage du bouton de téléchargement si trop de lignes #38 2025-09-23 12:29:41 +02:00
Colin Maudry 1586f9b7e4 Merge tag 'v2.0.1' into dev
Typos et instructions d'installation
2025-09-23 10:29:09 +02:00
Colin Maudry 46dfc23d42 Merge branch 'hotfix/2.0.1' 2025-09-23 10:28:56 +02:00
Colin Maudry 8c3642e5d6 Typo 2025-09-23 10:28:31 +02:00
Colin Maudry 0c99f037b5 Merge tag 'v2.0' into dev
- détails des sources de données
- section "À propos" plus développée
- correction de bugs dans les filtres de la data table
2025-09-23 09:11:11 +02:00
Colin Maudry 929b836cb0 Merge branch 'release/2.0' 2025-09-23 09:10:21 +02:00
Colin Maudry 73a10e20a6 README 2025-09-23 09:10:01 +02:00
Colin Maudry 48da8cde5c Comment out le formulaire pour l'instant, script ne s'execute pas 2025-09-23 08:57:49 +02:00
Colin Maudry 36ea24278f Suivi d'audience 2025-09-23 08:49:08 +02:00
Colin Maudry 000e4e89db Liens vers les données et le blog 2025-09-23 08:13:46 +02:00
Colin Maudry 3396986b2d Explication sur les sources de données et l'exhaustivité 2025-09-21 12:16:06 +02:00
Colin Maudry f3305d42cd acheteur_nom plus étroit, comment contribuer 2025-09-21 11:43:55 +02:00
Colin Maudry f56f5bfc94 Ajout des détails sur les sources de données #32 2025-09-21 11:43:21 +02:00
Colin Maudry 5a9b585b44 Ajout des détails sur les sources de données #32 2025-09-21 11:43:07 +02:00
Colin Maudry 89a419a8d2 Ajout des détails sur les sources de données #32 2025-09-21 11:42:29 +02:00
Colin Maudry 90af1f6eb4 GPL v3 2025-09-21 11:13:48 +02:00
Colin Maudry e9db143f45 Coquille robots #31 2025-09-20 08:25:51 +02:00
Colin Maudry 6a5b4aa2aa Text compression ok (zstd) #31 2025-09-20 08:23:05 +02:00
Colin Maudry 56b7a8a0a1 robots.txt #31 2025-09-20 08:13:45 +02:00
Colin Maudry 5bd519f9e5 app.compress = True #31 2025-09-20 07:59:47 +02:00
Colin Maudry 5ca5fc300f Adaptation du graphique aux nouvelles sources #32 2025-09-19 09:30:00 +02:00
Colin Maudry b93b290f97 Lazyframe dès le départ 2025-09-19 08:26:03 +02:00
Colin Maudry e8eba9ba66 Suppression des champs siren, noms des champs source 2025-09-19 08:24:20 +02:00
Colin Maudry 41d3ca7539 WIP #28 2025-09-19 08:20:15 +02:00
Colin Maudry 636e0b059d Récupération des données acheteur depuis annuaire #28 2025-08-21 11:56:39 +02:00
Colin Maudry 87d657abf8 Merge branch 'feature/17_filtres_num' into dev 2025-08-18 12:28:13 +02:00
Colin Maudry 5a89f70d4c Amélioration de l'affichage et du téléchargement des champs source et sourceOpenData 2025-08-18 12:27:53 +02:00
Colin Maudry 6e37e59c4c Gestion des filtres numériques < et > #17 2025-08-18 12:26:26 +02:00
Colin Maudry 3f931313a6 Rajout du champ typeGroupementOperateurs 2025-08-18 12:24:08 +02:00
Colin Maudry 815d25519f Suppression de l'icône de sensibilité à la casse 2025-08-10 19:15:19 +02:00
Colin Maudry cd45c364e6 Masquage des colonnes et répercution dans le téléchargement closes #13 2025-08-09 11:38:10 +02:00
Colin Maudry b423c01068 dash == 3.2.0 2025-08-09 11:34:32 +02:00
Colin Maudry 0729ebb23e Tri par colonnes multiples closes #23 2025-08-09 09:11:03 +02:00
Colin Maudry c682f16c40 Lien vers liste de diffusion #25 2025-08-06 15:56:34 +02:00
Colin Maudry 94f9192760 Affichage de source inconnue si source null #26 2025-08-06 12:03:19 +02:00
Colin Maudry f490e099ea Lien annuaire sans icône 2025-08-06 11:35:48 +02:00
Colin Maudry 8a9f135662 Précisions sur les données absentes 2025-07-05 18:50:21 +02:00
Colin Maudry b12a5b72c2 ID de loading différents 2025-07-05 18:42:30 +02:00
Colin Maudry c2ab1f49de Barre de chargement pour les figures statistiques 2025-07-05 18:30:34 +02:00
Colin Maudry 81526ae2b4 Ajout de figures.py #24 2025-07-05 18:21:37 +02:00
Colin Maudry e41351dfc1 Ajout du nombre de marchés notifiés par mois de publication et par source #24 2025-07-05 18:16:48 +02:00
Colin Maudry b854d77d2f Ajout du nombre de marchés notifiés par mois de notif et par source #24 2025-07-05 18:10:53 +02:00
Colin Maudry 3db5b804e4 Déplacement des figures vers un fichier dédié #24 2025-07-05 17:38:47 +02:00
Colin Maudry 89b0666e70 Changement de couleurs 2025-07-05 17:25:32 +02:00
Colin Maudry 95bc323d63 Venez discuter sur teampopendata.org 2025-07-02 00:28:17 +02:00
Colin Maudry 8376295464 Coquille 2025-06-26 12:52:10 +02:00
Colin Maudry 1e194f52a6 Plus d'infos dans A propos 2025-06-25 12:14:18 +02:00
Colin Maudry ff1402d427 Correction hauteur table-menu (était incliquable) 2025-06-24 16:06:04 +02:00
Colin Maudry 1ab5c639f0 lf => lff pour éviter un conflit avec le lf global 2025-06-24 15:02:53 +02:00
Colin Maudry 637c35522f Print aussi le col_type dans le filtre 2025-06-24 15:02:01 +02:00
Colin Maudry 83744cea65 Génère le schéma après les adaptations sur le df 2025-06-24 15:01:37 +02:00
Colin Maudry 23932c8dd3 Que les données actuelles 2025-06-24 15:00:25 +02:00
Colin Maudry 8e216124f1 Que les données actuelles 2025-06-24 15:00:07 +02:00
Colin Maudry b0e440595a Suppression des départements DOM/TOM pour ne pas perturber l'échelle 2025-06-16 23:02:37 +02:00
Colin Maudry 88f37c199a Fixed import de lf dans stats 2025-06-16 22:33:02 +02:00
Colin Maudry 68b3072aa3 Déplacement d'opérations récurrentes hors du callback 2025-06-16 22:15:51 +02:00
Colin Maudry 9f74aaea69 Date dernière màj 2025-06-16 19:22:00 +02:00
Colin Maudry 89f95904b8 Changements cosmétiques 2025-06-16 19:08:30 +02:00
Colin Maudry 8e7048490e Tri des marchés par datePublicationDonnées 2025-06-16 18:26:04 +02:00
Colin Maudry 59fb9e7b3a Page stats avec nb de marchés par département close #21 2025-06-13 13:18:51 +02:00
Colin Maudry 01e714dba5 Page stats avec nb de marchés par département close #21 2025-06-13 13:17:40 +02:00
Colin Maudry 859506c4aa Supprime page Stats, Utilisation => Mode d'emploi 2025-06-13 11:30:10 +02:00
Colin Maudry 4c6f38f41e Affichage du nombre de lignes close #19 2025-06-13 11:24:57 +02:00
Colin Maudry 1171708d9e Le téléchargement Excel inclut toutes les données filtrées close #20 2025-06-13 10:54:42 +02:00
Colin Maudry c66bf97572 Remplacement des null par '' close #18 2025-06-10 10:01:11 +02:00
Colin Maudry 804f6e7883 Filtres des champs numériques close #17 2025-06-10 08:29:11 +02:00
Colin Maudry 12d01bb3e0 Petits changements cosmétiques 2025-06-09 15:24:21 +02:00
Colin Maudry 9d37e0c59b lf.remove => lf.drop #15 2025-06-03 17:15:33 +02:00
Colin Maudry 5cd540a8d0 nom du workflow github 2025-06-03 16:56:49 +02:00
Colin Maudry 1717c118ab nom du workflow github 2025-06-03 16:54:12 +02:00
Colin Maudry 87f336a457 Suppression de colonnes + renommage df=>lf #15 2025-06-03 16:44:37 +02:00
Colin Maudry 0e9597de54 ruff check --fix . 2025-06-03 14:44:12 +02:00
Colin Maudry 972d7f55b2 Suppresion du filtre de fichier pour ruff 2025-06-03 14:43:20 +02:00
Colin Maudry 17df388035 ruff check --select I --fix . 2025-06-03 14:40:41 +02:00
Colin Maudry 21f0be3265 Ruff formatting 2025-06-03 14:39:22 +02:00
Colin Maudry c8b2cc3683 Merge branch 'dev' 2025-06-03 14:22:14 +02:00
Colin Maudry ad19744a47 Lecture du parquet comme simple df #9 2025-06-03 13:55:04 +02:00
Colin Maudry 59fe1df1e7 Merged dev 2025-06-02 20:35:47 +02:00
Colin Maudry 313a7ba230 A propos du projet 2025-06-02 20:25:45 +02:00
Colin Maudry 5c9fd6b4ef <script> Matomo refermée 2025-05-31 16:42:56 +02:00
Colin Maudry 74b122e75c <script> Matomo refermée 2025-05-31 16:42:00 +02:00
Colin Maudry a9b37c1907 Change la manière de détécter l'évènement GA 2025-05-31 16:30:30 +02:00
Colin Maudry 8ad69b4bae Ajout de liens vers l'annuaire des entreprises 2025-05-31 16:26:21 +02:00
Colin Maudry 10ec2c6649 Ajout de </script> manquant 2025-05-31 15:40:28 +02:00
Colin Maudry fadb4b1930 Ajout de la possibilité de déployer main d'un clic 2025-05-31 15:30:29 +02:00
Colin Maudry e595128c13 use appleboy action 2025-05-31 00:55:34 +02:00
Colin Maudry f9b46eea36 one liner ssh deploy 2025-05-31 00:41:11 +02:00
Colin Maudry 5ed704a497 set port for ssh 2025-05-31 00:14:10 +02:00
Colin Maudry 64001959c3 set environment 2025-05-31 00:11:18 +02:00
Colin Maudry ec8b109258 test port 2025-05-31 00:07:41 +02:00
Colin Maudry cd25673a5f variable name 2025-05-31 00:01:11 +02:00
Colin Maudry 459984bcbf folder name 2025-05-30 23:57:21 +02:00
Colin Maudry a2f9b2dca0 variable name 2025-05-30 23:53:40 +02:00
Colin Maudry cd208ee57b deploy.yaml 2025-05-30 23:46:22 +02:00
Colin Maudry 5ec550373d Merge branch 'dash' 2025-05-30 21:38:59 +02:00
Colin Maudry d1da7c321c Texte d'instructions 2025-05-30 21:38:53 +02:00
Colin Maudry e4a670948d Ajout du code de suivi Matomo 2025-05-30 21:38:14 +02:00
Colin Maudry 5563ff49f8 Couleurs chaudes, placeholder pour les filtres 2025-04-21 23:43:29 +02:00
Colin Maudry 3b1f3c8c65 Code custom pour la pagination et les filtres avec chunking #7 2025-04-21 23:03:02 +02:00
Colin Maudry 67ee8628da Avec gunicorn le page_container était introuvable 2025-04-21 19:12:43 +02:00
Colin Maudry c8cae37ff1 Suppression de la navbar en attendant d'avoir plus de contenu 2025-04-21 18:34:42 +02:00
Colin Maudry 7082041853 Nettoyage des imports 2025-04-21 18:16:45 +02:00
Colin Maudry 60d204b061 Ajout d'un menu de navigation 2025-04-21 18:09:10 +02:00
Colin Maudry 75e09b0bc6 Réglage de la taille du texte colonne objet 2025-04-21 14:32:01 +02:00
Colin Maudry 578041cb7f ajout d'aide pour les filtres, personnalisation du bouton Export close #6 2025-04-21 13:54:40 +02:00
Colin Maudry 918236212c Déplacement de app dans src, création de run.py, ajout de prettier 2025-04-21 13:39:12 +02:00
Colin Maudry 34c129bd18 Ajout du bouton Export (XLSX), les filtres natifs fonctionnent 2025-04-21 12:39:20 +02:00
Colin Maudry 55dca61a96 Pas de sélection colonne/cellue, fixed pagination 2025-04-21 01:19:04 +02:00
Colin Maudry fd0dc4643d gitignore 2025-04-21 01:06:19 +02:00
Colin Maudry 2687705f29 Ajout d'un spinner de chargement 2025-04-21 01:01:13 +02:00
Colin Maudry ddcb8df904 Removed unused import, add dcc dependency 2025-04-21 00:19:44 +02:00
Colin Maudry 8b389515c4 Parquet dgfr url 2025-04-21 00:09:30 +02:00
Colin Maudry 2b17f64904 Execution en serveur gunicorn 2025-04-20 23:33:31 +02:00
Colin Maudry a1aebfff4f Dash et datatable fonctionnels 2025-04-18 23:11:58 +02:00
Colin Maudry 2034577eb2 Update FUNDING.yml 2024-05-27 22:03:16 +02:00
Colin Maudry 0f13869c20 Create FUNDING.yml 2024-05-27 22:02:32 +02:00
Colin Maudry 388d2993cc python 3.x instead of python 3.10 2023-02-02 14:06:50 +01:00
Colin Maudry 8e91737a4b Merge branch 'bugfix/1.5.0' into develop 2023-01-29 17:29:56 +01:00
Colin Maudry d9420468ba Amélioration logs script 2023-01-29 17:28:10 +01:00
Colin Maudry 950fb7865a Ajout de datasette-cluster map dans les deps 2023-01-29 17:26:56 +01:00
Colin Maudry 98bb73a7aa Freeze dependencies, remove reference to config var 2023-01-28 13:47:31 +01:00
Colin Maudry 282b30f515 Merge tag 'v1.4.1' into develop
ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp
2021-06-14 17:34:27 +02:00
Colin Maudry 91bac3f56d Merge branch 'hotfix/1.4.1' into main 2021-06-14 17:34:15 +02:00
Colin Maudry 24f7c720e0 doc: notes de version 1.4.1 2021-06-14 17:34:03 +02:00
Colin Maudry 3e15e11ffa bug: ajout des traductions d'opérations à toutes les vues 2021-06-14 17:31:17 +02:00
Colin Maudry e9f4f50d51 Merge tag 'v1.4.0' into develop
Meilleurs ergonomie pour les filtres
2021-06-14 17:05:01 +02:00
Colin Maudry 369cadb27c Merge branch 'release/1.4.0' into main 2021-06-14 17:04:42 +02:00
Colin Maudry 017333f379 feature: amélioration des meta tags 2021-06-14 16:51:10 +02:00
Colin Maudry 6919a77852 feature: ajout des meta tags pour les twitter cards 2021-06-14 16:47:08 +02:00
Colin Maudry c2adf169aa doc: ajout des notes de version 1.4.0 2021-06-14 16:46:47 +02:00
Colin Maudry 582f1cbb63 feat: moins d'anglais 2021-06-06 07:09:46 +02:00
Colin Maudry 45c50845d7 feat: traduction du nom des opérations de filtrage 2021-06-06 06:53:04 +02:00
Colin Maudry ca365b4d7f bug: correction du titre de la page des notes de versions 2021-06-06 06:52:24 +02:00
Colin Maudry d36b74b4d4 Merge tag 'v1.3.0' into develop
Noms de colonnes plus clairs, corrections mineurs
2021-06-03 16:01:16 +02:00
Colin Maudry 393940c2a2 Merge branch 'release/1.3.0' into main 2021-06-03 16:00:57 +02:00
Colin Maudry a98eeb1272 bug: correction de l'indentation des puces dans les notes de version 2021-06-03 16:00:32 +02:00
Colin Maudry 72c915294a doc: notes de version 1.3.0 2021-06-03 15:54:05 +02:00
Colin Maudry 17f3cc5244 feat: affichage de noms de colonnes plus accessibles closes #45 2021-06-02 18:07:57 +02:00
Colin Maudry 55babc362e Suppression du bloc license en index, correction du lien vers le code source footer 2021-05-29 15:54:30 +02:00
Colin Maudry d817eafe48 Ignore db 2021-05-28 18:34:35 +02:00
109 changed files with 30186 additions and 3752 deletions
+14
View File
@@ -0,0 +1,14 @@
# These are supported funding model platforms
github: [ColinMaudry] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+51
View File
@@ -0,0 +1,51 @@
name: Déploiement
on:
workflow_dispatch:
pull_request:
types:
- closed
push:
branches:
- dev
jobs:
deploy:
# Trigger deploy workflow if
# ...I clicked on "Run workflow" in Github actions, thus deploy main to production env (main)
# or
# ...I merged a PR or pushed on the dev branch, thus deploy to the test env (dev)
if: |
(github.event_name == 'workflow_dispatch' && github.ref_name == 'main') ||
((github.event_name == 'pull_request' || github.event_name == 'push') && github.ref_name == 'dev')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up SSH key
run: |
env
mkdir -p ~/.ssh
echo "${{ secrets.ARTIFACT_SSH_KEY }}" > /home/runner/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -p ${{ secrets.ARTIFACT_PORT }} ${{ secrets.ARTIFACT_HOST }} >> ~/.ssh/known_hosts
sudo apt-get install sshpass python3 python3-pip
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.ARTIFACT_HOST }}
username: ${{ secrets.USER }}
port: ${{ secrets.ARTIFACT_PORT }}
key: ${{ secrets.ARTIFACT_SSH_KEY }}
passphrase: ${{ secrets.SSH_PSWD }}
command_timeout: 5m
script: |
systemctl stop ${{ vars.APP_NAME }}
cd /var/www/${{ vars.APP_NAME }}
git pull
source .venv/bin/activate
pip install .
deactivate
chown -R ${{ vars.APP_NAME }}:www-data *
systemctl start ${{ vars.APP_NAME }}
+13
View File
@@ -0,0 +1,13 @@
*.db
*.egg-info
__pycache__
.idea
.venv
.worktrees
build
.env
# DuckDB runtime artifacts (regenerated from decp_prod.parquet at startup)
**/decp.duckdb
**/decp.duckdb.tmp
**/decp.duckdb.lock
+21
View File
@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-case-conflict
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.5.1
hooks:
- id: prettier
files: \.(js|css|html|json|md)$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.12
hooks:
- id: ruff
args: [ "--fix" ]
- id: ruff
args: [ "check", "--select", "I", "--fix" ]
- id: ruff-format
+27
View File
@@ -0,0 +1,27 @@
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
DUCKDB_PATH=./decp.duckdb
PORT=8050
DEVELOPMENT=True
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
# Annonce dans l'en-tête du site
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"
# Formulaire de contact
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
LOGIN_PASSWORD="" # mot de passe du serveur
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
# Matomo
MATOMO_ID_SITE=
MATOMO_BASE_URL=
MATOMO_TOKEN=
+230
View File
@@ -0,0 +1,230 @@
##### 2.7.9 (9 juin 2026)
- Ajout d'une vue "étapes" (elle sera mieux intégrée dans le site à l'avenir)
- Correction de petites erreurs qui polluent les logs
##### 2.7.8 (18 mai 2026)
- Récupération du schéma de données plus robuste, ne pas dépendre de data.gouv.fr
##### 2.7.7 (11 mai 2026)
- Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour.
##### 2.7.6 (5 mai 2026)
- Correction du problème de filtre par date dans les tableaux
- Retour des cartes dans les pages acheteur et titulaire
- Possibilité de chercher un SIRET/SIREN avec des espaces dans les champs `SIRET acheteur` et `Identifiant titulaire`
##### 2.7.5 (24 avril 2026)
- Amélioration des permormances de l'observatoire
- Possibilité dans observatoire (champ objet) et tableau (tous champs texte) de soit chercher des mots présents, soit une suite de mot précise (voir mode d'emploi dans Tableau)
- Ajout d'une animation pendant le chargement de la prévisualisation des données de l'observatoire
##### 2.7.4 (22 avril 2026)
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72)
##### 2.7.3 (20 avril 2026)
- Mise en cache des vues tableau par ensemble de filtres et de tris
- Résolution du bug d'écriture du fichier de vérouillage de la base de données
##### 2.7.2 (19 avril 2026)
- Chargement des données depuis une base DuckDB plutôt qu'en mémoire (plus de stabilité) ([#71](https://github.com/ColinMaudry/decp.info/issues/71))
- Mise en cache des vue sur l'observatoire pour un chargement plus rapide (remise à zéro quotidienne)
- Correction de bug : la liste de colonnes par défaut est bien appliquée plutôt qu'afficher toutes les colonnes
- Quelques corrections de bugs d'affichage
- Refactorisation des fonctions utilitaires (`utils.py` approchait des 1 000 lignes)
##### 2.7.1 (23 mars 2026)
- Correction du partage de données filtrées entre dashboard et vue des données
#### 2.7.0 (23 mars 2026)
- Remplacement de la page Statistiques par l'observatoire
- Généralisation de la grille dash (`dbc.Row`, `dbc.Col`)
- Ajout de l'histogramme de distances aux pages acheteur et titulaire
- Ajout de la colonne `acheteur_categorie` (commune, État, etc.)
##### 2.6.2 (22 février 2026)
- Correction du téléchargemnent buggé dans /tableau
##### 2.6.1 (17 février 2026)
- Corrections la création des liens canoniques (SEO)
#### 2.6.0 (5 février 2026)
- Suite de la refonte graphique
- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages
- Joli tableau pour choisir les colonnes à afficher
- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE
- Amélioration du SEO (liens canoniques)
##### 2.5.1 (29 janvier 2026)
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68))
#### 2.5.0 (29 janvier 2026)
- Refonte graphique et amélioration des textes d'aide
- Amélioration du filtrage du tableau à partir d'une URL
- Renforcement du SEO avec une arborescence permettant l'accès aux marchés et des snippets JSON-LD
- Suppression de la dépendance à Google Fonts grâce à [Bunny Fonts](https://fonts.bunny.net) 🇪🇺 🇸🇮
##### 2.4.1 (22 janvier 2026)
- Meilleure gestion des colonnes absentes du schéma
#### 2.4.0 (22 janvier 2026)
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
- Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source)
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50))
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36))
- Désactivation du bloquage des robot d'agents de LLM (robots.txt)
##### 2.3.1 (16 janvier 2026)
- Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs
#### 2.3.0 (24 décembre 2025)
- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire
- Possibilité de partager les filtres, tris et choix de colonnes via une adresse Web ([exemple](https://decp.info/tableau?filtres=%7Bobjet%7D+icontains+%22d%C3%A9corations+de+no%C3%ABl%22+%26%26+%7BdateNotification%7D+icontains+2025&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdateNotification%2Cdistance%2Cacheteur_departement_code))
- Possibilité de filtrer une colonne avec plusieurs mots
##### 2.2.3 (4 décembre 2025)
- mise à jour de l'adresse email de contact (colmo.tech)
- message sur l'indisponibilité des données MINEF
##### 2.2.2 (22 novembre 2025)
- Correction d'un bug dans le téléchargement Excel
##### 2.2.1 (15 novembre 2025)
- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté)
- Phrase "tagline" au-dessus du champ de recherche
- Les infos de Contact rebasculent dans À propos
- Police de caractère "Open Sans" généralisée
#### 2.2.0 (13 novembre 2025)
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
##### 2.1.7 (11 novembre 2025)
- Remplacement du formulaire de contact par une adresse email
##### 2.1.6 (15 octobre 2025)
- Stabilisation de la vue marché
##### 2.1.5 (10 octobre 2025)
- réparation des filtres (notamment < > sur les montants)
- remplacement des valeurs "null" dans les tableaux par des cellules vides
##### 2.1.4 (8 octobre 2025)
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
##### 2.1.3 (4 octobre 2025)
- tentative d'auto-release à chaque création de tag git
- adaptation au format TableSchema
##### 2.1.2 (3 octobre 2025)
- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire
##### 2.1.1 (1er octobre 2025)
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43))
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
#### 2.1.0 (30 septembre 2025)
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖
- Affichage du numéro de version près du logo et lien vers ici 🤓
- Variables globales uniquement en lecture (😁)
##### 2.0.1 (23 septembre 2025)
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
- Amélioration du script de déploiement (deploy.sh)
- Meilleures instructions d'installation et lancement
- Coquilles 🐚
### 2.0.0 (23 septembre 2025)
- détails des sources de données
- section "À propos" plus développée
- correction de bugs dans les filtres de la data table
#### 2.0.0-alpha
- Data table fonctionnelle
### 1.5.0 (28/01/2023
- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile)
#### 1.4.1 (14/06/2021)
- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp
### 1.4.0 (14/06/2021)
- traduction des opérations de filtrage (ex : contains => contient)
- élargissement des menus de filtrage
- correction du titre de la page des notes de versions
### 1.3.0 (03/06/2021)
- utilisation de noms de colonnes plus lisibles dans l'application
- suppression des références à la licence et aux données source sur la page d'accueil
- correction des liens vers le code source
- correction de l'indentation des puces dans les notes de version
### 1.2.0 (28/05/2021)
- ajout d'une page "Notes de version"
- meilleur lien pour la documentation des champs
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
### 1.1.0 (25/05/2021)
- ajout de nouvelles vues :
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
- amélioration de la page d'accueil
- développement de la page "db" avec description des vues et liste des colonnes
- les codes APE sont cliquables
- ajout des mentions légales
- ajout d'un formulatire d'inscription à une lettre d'information
- correction de bugs :
- correction du format de certaines dates dans les données
### 1.0.0
- publication sur <https://decp.info>
- ajout d'une vue équivalente au format DECP réglementaire
- personnalisation de datasette
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
+98
View File
@@ -0,0 +1,98 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**decp.info** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
## Commands
### Setup
Setting up the virtual environment:
```bash
python -m venv .venv # s'il n'existe pas déjà
source .venv/bin/activate
rtk pip install -U pip > /dev/null 2>&1
rtk pip install -e . --group=dev
```
Environment variables:
```bash
cp .template.env .env # then customize .env
```
### Development
```bash
python run.py # starts Dash app
```
### Production
```bash
gunicorn app:server
```
### Tests
```bash
rtk pytest # run all tests (some are Selenium-based integration tests)
rtk pytest tests/test_main.py::test_001_logo_and_search # run a single test
```
Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver.
## Architecture
### Multi-page Dash app
- `src/app.py` — creates the Dash app instance, navbar, SEO endpoints (robots.txt, sitemap.xml), Matomo analytics
- `src/pages/*.py` — each page registers itself with `@register_page()` and o.wns its own layout and callbacks
- `run.py` — dev entry point; exports `server` (Flask) for gunicorn
### Module imports
- always import modules from the app starting with `src.` (e.g. `src.utils.`, `src.pages.recherche`, etc.), NOT `utils.cache` or `pages.observatoire`.
### Key pages
| Page | URL | Purpose |
| ----------------- | --------------- | -------------------------------------- |
| `recherche.py` | `/` | Search homepage for buyers/contractors |
| `acheteur.py` | `/acheteur` | Buyer detail with stats, charts, maps |
| `titulaire.py` | `/titulaire` | Contractor detail |
| `tableau.py` | `/tableau` | Filterable data table with exports |
| `marche.py` | `/marche` | Individual contract detail |
| `observatoire.py` | `/observatoire` | An interactive analytics dashboard |
### Data layer
- Data is stored as **Parquet** at rest, possibly in DuckDB, loaded in DuckDB, served from DuckDB for big queries and manipulated with **Polars** for the remaining steps
- Path set via `DATA_FILE_PARQUET_PATH` env var; tests use `tests/test.parquet`
- `src/util/*.py` — helpers shared by other modules, search (`search_org`), link generation, geographic data loading
- `src/callbacks.py` — shared Dash callbacks (e.g. `get_top_org_table`)
- `src/figures.py` — chart and map components (Plotly Express, Dash Leaflet with marker clustering)
- a Parquet file with production data is located at `../decp-processing/decp_prod.parquet` (~ 1,5 million records)
- the TableSchema of the dataset with the list of field and their definition is located at `../decp-processing/reference/base_schema.json`
- `tests/test.parquet` is very small and may not contain all possible columns, only those necessary for testing
### UI stack
- **Dash 3.4** + **Dash Bootstrap Components** for layout
- **Plotly Express** for charts
- **Dash Leaflet** + **Dash Extensions** for interactive maps with clustering
- Custom CSS in `src/assets/css/`
### Environment
- `DEVELOPMENT=true` enables debug logging and is set automatically during tests
- `.env` file is required at runtime (copy from `template.env`)
### Deployment
- `main` branch → manual deploy to decp.info via GitHub Actions
- `dev` branch → auto-deploy to test.decp.info via GitHub Actions
+13 -198
View File
@@ -1,201 +1,16 @@
Apache License decp.info is a web application that enable analysis and download of
Version 2.0, January 2004 French public procurement data.
http://www.apache.org/licenses/ Copyright (C) 2025 Colin Maudry
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
1. Definitions. This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
"License" shall mean the terms and conditions for use, reproduction, You should have received a copy of the GNU General Public License
and distribution as defined by Sections 1 through 9 of this document. along with this program. If not, see <https://www.gnu.org/licenses/>.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+27 -30
View File
@@ -1,38 +1,35 @@
# decp.info # decp.info
Outil d'exploration et de téléchargement des données essentielles de la commande publique. > Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> https://decp.info => [decp.info](https://decp.info)
Dépôts de code connexe : ## Installation et lancement
- [decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) (traitement et publication des données) ```shell
- [decp-table-schema](https://github.com/ColinMaudry/decp-table-schema) (schéma de données tabulaire) # Copie et personnalisation du .env
cp template.env .env
nano .env
# Pour la production
uv run gunicorn app:server
# Pour avoir le debuggage et le hot reload
uv run run.py
```
## Déploiement
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml)
- **Test** (branche `dev`, [test.decp.info](https://test.decp.info)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
Ne pas oublier de mettre à jour les fichier .env.
## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
- [colin.maudry.com](https://colin.maudry.com) (blog)
## Notes de version ## Notes de version
### 1.2.0 (28/05/2021) Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md).
- ajout d'une page "Notes de version"
- meilleur lien pour la documentation des champs
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
### 1.1.0 (25/05/2021)
- ajout de nouvelles vues :
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
- amélioration de la page d'accueil
- développement de la page "db" avec description des vues et liste des colonnes
- les codes APE sont cliquables
- ajout des mentions légales
- ajout d'un formulatire d'inscription à une lettre d'information
- correction de bugs :
- correction du format de certaines dates dans les données
### 1.0.0
- publication sur https://decp.info
- ajout d'une vue équivalente au format DECP réglementaire
- personnalisation de datasette
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
File diff suppressed because one or more lines are too long
+406
View File
@@ -0,0 +1,406 @@
{
"01": {
"departement": "Ain",
"region": "Auvergne-Rhône-Alpes"
},
"02": {
"departement": "Aisne",
"region": "Hauts-de-France"
},
"03": {
"departement": "Allier",
"region": "Auvergne-Rhône-Alpes"
},
"04": {
"departement": "Alpes-de-Haute-Provence",
"region": "Provence-Alpes-Côte d'Azur"
},
"05": {
"departement": "Hautes-Alpes",
"region": "Provence-Alpes-Côte d'Azur"
},
"06": {
"departement": "Alpes-Maritimes",
"region": "Provence-Alpes-Côte d'Azur"
},
"07": {
"departement": "Ardèche",
"region": "Auvergne-Rhône-Alpes"
},
"08": {
"departement": "Ardennes",
"region": "Grand Est"
},
"09": {
"departement": "Ariège",
"region": "Occitanie"
},
"10": {
"departement": "Aube",
"region": "Grand Est"
},
"11": {
"departement": "Aude",
"region": "Occitanie"
},
"12": {
"departement": "Aveyron",
"region": "Occitanie"
},
"13": {
"departement": "Bouches-du-Rhône",
"region": "Provence-Alpes-Côte d'Azur"
},
"14": {
"departement": "Calvados",
"region": "Normandie"
},
"15": {
"departement": "Cantal",
"region": "Auvergne-Rhône-Alpes"
},
"16": {
"departement": "Charente",
"region": "Nouvelle-Aquitaine"
},
"17": {
"departement": "Charente-Maritime",
"region": "Nouvelle-Aquitaine"
},
"18": {
"departement": "Cher",
"region": "Centre-Val de Loire"
},
"19": {
"departement": "Corrèze",
"region": "Nouvelle-Aquitaine"
},
"21": {
"departement": "Côte-d'Or",
"region": "Bourgogne-Franche-Comté"
},
"22": {
"departement": "Côtes-d'Armor",
"region": "Bretagne"
},
"23": {
"departement": "Creuse",
"region": "Nouvelle-Aquitaine"
},
"24": {
"departement": "Dordogne",
"region": "Nouvelle-Aquitaine"
},
"25": {
"departement": "Doubs",
"region": "Bourgogne-Franche-Comté"
},
"26": {
"departement": "Drôme",
"region": "Auvergne-Rhône-Alpes"
},
"27": {
"departement": "Eure",
"region": "Normandie"
},
"28": {
"departement": "Eure-et-Loir",
"region": "Centre-Val de Loire"
},
"29": {
"departement": "Finistère",
"region": "Bretagne"
},
"30": {
"departement": "Gard",
"region": "Occitanie"
},
"31": {
"departement": "Haute-Garonne",
"region": "Occitanie"
},
"32": {
"departement": "Gers",
"region": "Occitanie"
},
"33": {
"departement": "Gironde",
"region": "Nouvelle-Aquitaine"
},
"34": {
"departement": "Hérault",
"region": "Occitanie"
},
"35": {
"departement": "Ille-et-Vilaine",
"region": "Bretagne"
},
"36": {
"departement": "Indre",
"region": "Centre-Val de Loire"
},
"37": {
"departement": "Indre-et-Loire",
"region": "Centre-Val de Loire"
},
"38": {
"departement": "Isère",
"region": "Auvergne-Rhône-Alpes"
},
"39": {
"departement": "Jura",
"region": "Bourgogne-Franche-Comté"
},
"40": {
"departement": "Landes",
"region": "Nouvelle-Aquitaine"
},
"41": {
"departement": "Loir-et-Cher",
"region": "Centre-Val de Loire"
},
"42": {
"departement": "Loire",
"region": "Auvergne-Rhône-Alpes"
},
"43": {
"departement": "Haute-Loire",
"region": "Auvergne-Rhône-Alpes"
},
"44": {
"departement": "Loire-Atlantique",
"region": "Pays de la Loire"
},
"45": {
"departement": "Loiret",
"region": "Centre-Val de Loire"
},
"46": {
"departement": "Lot",
"region": "Occitanie"
},
"47": {
"departement": "Lot-et-Garonne",
"region": "Nouvelle-Aquitaine"
},
"48": {
"departement": "Lozère",
"region": "Occitanie"
},
"49": {
"departement": "Maine-et-Loire",
"region": "Pays de la Loire"
},
"50": {
"departement": "Manche",
"region": "Normandie"
},
"51": {
"departement": "Marne",
"region": "Grand Est"
},
"52": {
"departement": "Haute-Marne",
"region": "Grand Est"
},
"53": {
"departement": "Mayenne",
"region": "Pays de la Loire"
},
"54": {
"departement": "Meurthe-et-Moselle",
"region": "Grand Est"
},
"55": {
"departement": "Meuse",
"region": "Grand Est"
},
"56": {
"departement": "Morbihan",
"region": "Bretagne"
},
"57": {
"departement": "Moselle",
"region": "Grand Est"
},
"58": {
"departement": "Nièvre",
"region": "Bourgogne-Franche-Comté"
},
"59": {
"departement": "Nord",
"region": "Hauts-de-France"
},
"60": {
"departement": "Oise",
"region": "Hauts-de-France"
},
"61": {
"departement": "Orne",
"region": "Normandie"
},
"62": {
"departement": "Pas-de-Calais",
"region": "Hauts-de-France"
},
"63": {
"departement": "Puy-de-Dôme",
"region": "Auvergne-Rhône-Alpes"
},
"64": {
"departement": "Pyrénées-Atlantiques",
"region": "Nouvelle-Aquitaine"
},
"65": {
"departement": "Hautes-Pyrénées",
"region": "Occitanie"
},
"66": {
"departement": "Pyrénées-Orientales",
"region": "Occitanie"
},
"67": {
"departement": "Bas-Rhin",
"region": "Grand Est"
},
"68": {
"departement": "Haut-Rhin",
"region": "Grand Est"
},
"69": {
"departement": "Rhône",
"region": "Auvergne-Rhône-Alpes"
},
"70": {
"departement": "Haute-Saône",
"region": "Bourgogne-Franche-Comté"
},
"71": {
"departement": "Saône-et-Loire",
"region": "Bourgogne-Franche-Comté"
},
"72": {
"departement": "Sarthe",
"region": "Pays de la Loire"
},
"73": {
"departement": "Savoie",
"region": "Auvergne-Rhône-Alpes"
},
"74": {
"departement": "Haute-Savoie",
"region": "Auvergne-Rhône-Alpes"
},
"75": {
"departement": "Paris",
"region": "Île-de-France"
},
"76": {
"departement": "Seine-Maritime",
"region": "Normandie"
},
"77": {
"departement": "Seine-et-Marne",
"region": "Île-de-France"
},
"78": {
"departement": "Yvelines",
"region": "Île-de-France"
},
"79": {
"departement": "Deux-Sèvres",
"region": "Nouvelle-Aquitaine"
},
"80": {
"departement": "Somme",
"region": "Hauts-de-France"
},
"81": {
"departement": "Tarn",
"region": "Occitanie"
},
"82": {
"departement": "Tarn-et-Garonne",
"region": "Occitanie"
},
"83": {
"departement": "Var",
"region": "Provence-Alpes-Côte d'Azur"
},
"84": {
"departement": "Vaucluse",
"region": "Provence-Alpes-Côte d'Azur"
},
"85": {
"departement": "Vendée",
"region": "Pays de la Loire"
},
"86": {
"departement": "Vienne",
"region": "Nouvelle-Aquitaine"
},
"87": {
"departement": "Haute-Vienne",
"region": "Nouvelle-Aquitaine"
},
"88": {
"departement": "Vosges",
"region": "Grand Est"
},
"89": {
"departement": "Yonne",
"region": "Bourgogne-Franche-Comté"
},
"90": {
"departement": "Territoire de Belfort",
"region": "Bourgogne-Franche-Comté"
},
"91": {
"departement": "Essonne",
"region": "Île-de-France"
},
"92": {
"departement": "Hauts-de-Seine",
"region": "Île-de-France"
},
"93": {
"departement": "Seine-Saint-Denis",
"region": "Île-de-France"
},
"94": {
"departement": "Val-de-Marne",
"region": "Île-de-France"
},
"95": {
"departement": "Val-d'Oise",
"region": "Île-de-France"
},
"971": {
"departement": "Guadeloupe",
"region": "Guadeloupe"
},
"972": {
"departement": "Martinique",
"region": "Martinique"
},
"973": {
"departement": "Guyane",
"region": "Guyane"
},
"974": {
"departement": "La Réunion",
"region": "La Réunion"
},
"975": {
"departement": "Saint-Pierre-et-Miquelon",
"region": "Saint-Pierre-et-Miquelon"
},
"976": {
"departement": "Mayotte",
"region": "Mayotte"
},
"977": {
"departement": "Saint-Barthelemy",
"region": "Saint-Barthelemy"
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"db": {
"hash": "e7781c4da0df3983d9e8bfc09d513162302d787bff766e3cfee2dd5dcd272b85",
"size": 121118720,
"file": "datasette/db.db",
"tables": {
"decp": {
"count": 292374
}
}
}
}
-31
View File
@@ -1,31 +0,0 @@
---
extra_css_urls:
- "/static/custom.css"
title: Exploration et téléchargement des données essentielles de la commande publique
(format tabulaire)
description: Ce site vous permet de filtrer et trier les données sur les marchés publics,
et de télécharger le résultat sous la forme d'un fichier que vous pourrez ouvrir
dans un logiciel de tableur (MS Excel, LibreOffice, OpenOffice).
source: Données essentielles de la commande publique
source_url: https://www.data.gouv.fr/fr/datasets/608c055b35eb4e6ee20eb325/
license: Licence ouverte
license_url: https://www.etalab.gouv.fr/wp-content/uploads/2014/05/Licence_Ouverte.pdf
databases:
db:
tables:
decp:
title: Marchés et titulaires (= DECP)
description_html: |-
Marché publics et leurs titulaires, données équivalentes au <a href="https://139bercy.github.io/decp-docs/schemas/">format réglementaire</a>. Une ligne = un titulaire de marché, donc ces données ne sont pas adaptées pour travailler avec les montants de marché ou compter les marchés.
size: 40
download: https://www.data.gouv.fr/fr/datasets/r/8587fe77-fb31-4155-8753-f6a3c5e0f5c9
decp-sans-titulaires:
title: Marchés publics sans leurs titulaires
description_html: Marchés publics sans les titulaires (pas de colonnes titulaire). Une ligne = un marché, donc ces données sont adaptées pour travailler avec les montants de marchés et compter les marchés.
size: 40
download: https://www.data.gouv.fr/fr/datasets/r/834c14dd-037c-4825-958d-0a841c4777ae
decp-titulaires:
title: Données sur les titulaires et géolocalisation
description_html: Données détaillées sur les titulaires, dont leur géolocalisation. Les colonnes <tt>formePrix</tt>, <tt>procedure</tt>, <tt>objetModification</tt> et <tt>datePublicationDonnees</tt> sont absentes. Une ligne = un titulaire de marché, donc ces données ne sont pas adaptées pour travailler avec les montants de marché ou compter les marchés.
size: 40
download: https://www.data.gouv.fr/fr/datasets/r/25fcd9e6-ce5a-41a7-b6c0-f140abb2a060
-12
View File
@@ -1,12 +0,0 @@
from datasette import hookimpl
@hookimpl
def menu_links(datasette, actor):
return [
{"href": "https://teamopendata.org/t/decp-info-les-donnees-de-la-commande-publique-pour-tous-questions-reponses-discussions/2948", "label": "Présentation / FAQ"},
{"href": "https://github.com/ColinMaudry/decp-table-schema/#documentation-du-sch%C3%A9ma", "label": "Documentation des champs"},
{"href": "https://github.com/ColinMaudry/decp.info", "label": "Code source"},
{"href": "/versions", "label": "Notes de version"},
{"href": "/inscription", "label": "Lettre d'information"},
{"href": "/mentions-legales", "label": "Mentions légales"}
]
-75
View File
@@ -1,75 +0,0 @@
from datasette import hookimpl
from datasette.utils.asgi import Response
from openpyxl import Workbook
from openpyxl.writer.excel import save_virtual_workbook
from openpyxl.cell import WriteOnlyCell
from openpyxl.styles import Alignment, Font, PatternFill
from tempfile import NamedTemporaryFile
def render_spreadsheet(rows):
wb = Workbook(write_only=True)
ws = wb.create_sheet()
ws = wb.active
ws.title = "decp"
columnSpecs = {
'acheteur.nom': {
'width': 7,
'wrapText': True
},
'objet': {
'width': 7,
'wrapText': True
},
'titulaire.denominationSociale': {
'width': 7,
'wrapText': True
},
}
columns = rows[0].keys()
if columns[0] == "rowid":
columns = columns[1:]
letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
headers = []
index = 0
for col in columns :
c = WriteOnlyCell(ws, col)
c.fill = PatternFill("solid", fgColor="DDEFFF")
headers.append(c)
if col in columnSpecs:
ws.column_dimensions[letters[index]].width = columnSpecs[col]['width'] * 5
else:
ws.column_dimensions[letters[index]].bestFit = True
index = index + 1
ws.append(headers)
for row in rows:
wsRow = []
for col in columns:
c = WriteOnlyCell(ws, row[col])
if col in columnSpecs :
c.alignment = Alignment(wrapText = columnSpecs[col]['wrapText'])
wsRow.append(c)
ws.append(wsRow)
with NamedTemporaryFile() as tmp:
wb.save(tmp.name)
tmp.seek(0)
return Response(
tmp.read(),
headers={
'Content-Disposition': 'attachment; filename=decp.xlsx',
'Content-type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
)
@hookimpl
def register_output_renderer():
return {"extension": "xlsx",
"render": render_spreadsheet,
"can_render": lambda: False}
-12
View File
@@ -1,12 +0,0 @@
{
"sql_time_limit_ms": 10000,
"max_returned_rows": 50000,
"num_sql_threads": 6,
"default_cache_ttl": 3600,
"max_csv_mb": 0,
"force_https_urls": 1,
"allow_facet": "off",
"suggest_facets": "off",
"template_debug": 1,
"hash_urls": 1
}
-166
View File
@@ -1,166 +0,0 @@
section.content > div.table-wrapper {
display: inline-block;
}
a, a:visited, a:focus, a:active {
color: #276890;
text-decoration: underline;
}
a:hover {
color: #0c286d;
}
a.explore {
font-size: 1.6em;
}
a.nodec {
text-decoration: none;
}
nav ul {
list-style-type: none;
}
nav li {
padding: 10px;
border-top: solid 1px #fff;
}
nav summary {
font-weight: bold;
}
nav .nav-menu-inner {
padding: 10px 0;
}
table.rows-and-columns td > div {
max-height: 100px;
font-size: 0.8em;
overflow-y: auto;
overflow-x: hidden;
}
table.rows-and-columns tr:nth-child(even) {
background-color: #DDEFFF;
}
h4,.header4 {
text-decoration: none;
}
table.rows-and-columns td.col-objet > div {
min-width: 140px;
}
table.rows-and-columns td.col-procedure > div {
min-width: 140px;
}
td.col-longitude,th.col-longitude,td.col-latitude,th.col-latitude {
display: none;
}
#sib-container form label {
width: 100%;
}
.db-table .columns {
font-size: 0.9em;
color: #555;
}
.db-presentation {
border-left: 10px solid #276890;
padding-left: 10px;
max-width: 1000px;
margin-top: 30px;
font-size: 1.2em;
}
/*
Flaticon icon font: Flaticon
Creation date: 22/06/2016 15:35
*/
@font-face {
font-family: "Flaticon";
src: url("./font/Flaticon.eot");
src: url("./font/Flaticon.eot?#iefix") format("embedded-opentype"),
url("./font/Flaticon.woff") format("woff"),
url("./font/Flaticon.ttf") format("truetype"),
url("./font/Flaticon.svg#Flaticon") format("svg");
font-weight: normal;
font-style: normal;
}
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: "Flaticon";
src: url("./font/Flaticon.svg#Flaticon") format("svg");
}
}
[class^="flaticon-"]:before, [class*=" flaticon-"]:before,
[class^="flaticon-"]:after, [class*=" flaticon-"]:after {
font-family: Flaticon;
font-size: 20px;
font-style: normal;
font-weight: bold;
margin-left: 20px;
}
.flaticon-avatar:before { content: "\f100"; }
.flaticon-avatar-1:before { content: "\f101"; }
.flaticon-back:before { content: "\f102"; }
.flaticon-book:before { content: "\f103"; }
.flaticon-cancel:before { content: "\f104"; }
.flaticon-chat:before { content: "\f105"; }
.flaticon-chat-1:before { content: "\f106"; }
.flaticon-chat-2:before { content: "\f107"; }
.flaticon-copy:before { content: "\f108"; }
.flaticon-dislike:before { content: "\f109"; }
.flaticon-download:before { content: "\f10a"; }
.flaticon-download-1:before { content: "\f10b"; }
.flaticon-edit:before { content: "\f10c"; }
.flaticon-envelope:before { content: "\f10d"; }
.flaticon-folder:before { content: "\f10e"; }
.flaticon-garbage:before { content: "\f10f"; }
.flaticon-glasses:before { content: "\f110"; }
.flaticon-hand:before { content: "\f111"; }
.flaticon-headphones:before { content: "\f112"; }
.flaticon-heart:before { content: "\f113"; }
.flaticon-house:before { content: "\f114"; }
.flaticon-like:before { content: "\f115"; }
.flaticon-link:before { content: "\f116"; }
.flaticon-logout:before { content: "\f117"; }
.flaticon-magnifying-glass:before { content: "\f118"; }
.flaticon-monitor:before { content: "\f119"; }
.flaticon-musical-note:before { content: "\f11a"; }
.flaticon-next:before { content: "\f11b"; }
.flaticon-next-1:before { content: "\f11c"; }
.flaticon-padlock:before { content: "\f11d"; }
.flaticon-paper-plane:before { content: "\f11e"; }
.flaticon-phone-call:before { content: "\f11f"; }
.flaticon-photo-camera:before { content: "\f120"; }
.flaticon-pie-chart:before { content: "\f121"; }
.flaticon-piggy-bank:before { content: "\f122"; }
.flaticon-placeholder:before { content: "\f123"; }
.flaticon-printer:before { content: "\f124"; }
.flaticon-reload:before { content: "\f125"; }
.flaticon-settings:before { content: "\f126"; }
.flaticon-settings-1:before { content: "\f127"; }
.flaticon-share:before { content: "\f128"; }
.flaticon-shopping-bag:before { content: "\f129"; }
.flaticon-shopping-cart:before { content: "\f12a"; }
.flaticon-shuffle:before { content: "\f12b"; }
.flaticon-speaker:before { content: "\f12c"; }
.flaticon-star:before { content: "\f12d"; }
.flaticon-tag:before { content: "\f12e"; }
.flaticon-upload:before { content: "\f12f"; }
.flaticon-upload-1:before { content: "\f130"; }
.flaticon-vector:before { content: "\f131"; }
Binary file not shown.
-445
View File
@@ -1,445 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<!--
2016-6-22: Created.
-->
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>
Created by FontForge 20120731 at Wed Jun 22 15:35:07 2016
By root
Created by root with FontForge 2.0 (http://fontforge.sf.net)
</metadata>
<defs>
<font id="Flaticon" horiz-adv-x="512" >
<font-face
font-family="Flaticon"
font-weight="500"
font-stretch="normal"
units-per-em="512"
panose-1="2 0 6 3 0 0 0 0 0 0"
ascent="448"
descent="-64"
bbox="0 -64 512.001 448"
underline-thickness="25.6"
underline-position="-51.2"
unicode-range="U+0020-F131"
/>
<missing-glyph />
<glyph glyph-name="space" unicode=" " horiz-adv-x="200"
/>
<glyph glyph-name="uniF113" unicode="&#xf113;"
d="M470.645 375.274c26.8105 -26.8096 41.4639 -62.4121 41.3555 -100.294c0 -37.8809 -14.7617 -73.4834 -41.5723 -100.294l-204.278 -203.519c-2.82227 -2.82227 -6.62109 -4.2334 -10.3115 -4.2334s-7.48926 1.51953 -10.3115 4.3418l-203.845 203.845
c-26.8105 26.8105 -41.6807 62.3037 -41.6807 100.186c-0.108398 37.8818 14.6533 73.4834 41.4639 100.294c26.7012 26.7012 62.3037 41.4639 100.077 41.4639c37.8809 0 73.5918 -14.8711 100.402 -41.6807l14.2188 -14.2197l14.002 14.002
c26.8105 26.8105 62.4131 41.5723 100.295 41.5723c37.8809 0 73.375 -14.6533 100.185 -41.4639zM449.805 195.419c21.2744 21.2744 32.9971 49.4951 32.8887 79.5615c0 30.0664 -11.6143 58.2881 -32.8887 79.5625c-21.2744 21.166 -49.4961 32.8887 -79.4541 32.8887
s-58.2871 -11.7227 -79.5615 -32.9971l-24.3145 -24.3135c-5.75195 -5.75293 -14.9785 -5.75293 -20.7314 0l-24.5303 24.5303c-21.2754 21.2744 -49.6045 32.9971 -79.6709 32.9971c-29.958 0 -58.1797 -11.7227 -79.3457 -32.8887
c-21.2744 -21.166 -32.8887 -49.3867 -32.8887 -79.4531s11.7227 -58.2881 32.9971 -79.5625l193.533 -193.532z" />
<glyph glyph-name="uniF131" unicode="&#xf131;"
d="M504.751 279.223c9.02734 -9.02637 9.02734 -23.5117 0.105469 -32.3281l-25.6104 -25.6104c-4.30371 -4.30371 -10.0762 -6.71777 -16.1641 -6.71777s-11.8604 2.41406 -16.1641 6.71777l-25.6104 25.6104c-8.92188 8.92188 -8.92188 23.5117 0 32.4326l1.57422 1.5752
c-59.7227 36.9463 -136.975 35.2666 -195.123 -4.93359c3.14941 -8.18652 1.46973 -17.8438 -5.14258 -24.4561l-25.6104 -25.6104c-4.30371 -4.30371 -10.0762 -6.71777 -16.1641 -6.71777c-2.93945 0 -5.66797 0.525391 -8.29199 1.5752
c-40.2002 -58.1484 -41.7744 -135.4 -4.93359 -195.123l0.735352 0.734375c4.30273 4.30371 10.0762 6.71777 16.1641 6.71777c6.08691 0 11.8604 -2.41406 16.1641 -6.71777l25.6104 -25.6104c8.9209 -8.92188 8.9209 -23.5107 0 -32.4326l-25.6104 -25.6104
c-4.30371 -4.30371 -10.0771 -6.71777 -16.1641 -6.71777c-6.08789 0 -11.8613 2.41406 -16.1641 6.71777l-25.6104 25.6104c-8.92188 8.92188 -8.92188 23.5107 0 32.4326l4.30273 4.30371c-22.4619 33.6924 -34.3223 72.8428 -34.4268 113.883
c-0.105469 25.6104 4.5127 50.4863 13.3301 73.7871l-44.7139 -44.7129c7.55762 -15.7441 4.72363 -35.2676 -8.29199 -48.2822c-7.97656 -8.08203 -18.6826 -12.4902 -30.0186 -12.4902s-22.042 4.5127 -30.0186 12.4902c-16.584 16.4785 -16.584 43.4541 0 60.0381
c7.97656 8.08203 18.6826 12.4902 30.0186 12.4902c6.40234 0 12.5957 -1.36523 18.2627 -4.09375l80.5059 80.5049l-2.72949 2.72949c-4.30273 4.30273 -6.71777 10.0762 -6.71777 16.1641s2.41504 11.8604 6.71777 16.1641l25.8203 25.7148
c4.30371 4.30371 10.0762 6.71777 16.1641 6.71777s11.8604 -2.41406 16.1641 -6.71777l2.72949 -2.72852l80.5049 80.5049c-2.62402 5.5625 -4.09375 11.8604 -4.09375 18.2637c0 11.335 4.51367 21.9365 12.4902 30.0186c7.97754 8.08203 18.6836 12.4902 30.0195 12.4902
c11.335 0 21.9365 -4.51367 30.0186 -12.4902c8.08203 -7.97754 12.4902 -18.6836 12.4902 -30.0186c0 -11.3359 -4.5127 -22.042 -12.4902 -30.0195c-7.97656 -8.08203 -18.6826 -12.4902 -30.0186 -12.4902c-6.40332 0 -12.5957 1.36426 -18.2637 4.09375
l-44.7129 -44.7139c23.1963 8.81738 47.8623 13.3301 73.3672 13.3301h0.419922c41.04 -0.104492 80.1904 -11.9648 113.883 -34.4268l3.46387 3.46387c4.30371 4.30273 10.0762 6.7168 16.1641 6.7168s11.8604 -2.41406 16.1641 -6.7168zM202.358 -15.6133
l-17.9482 17.9482l-17.9482 -17.9482l17.9482 -17.9482zM308.579 415.357c-2.62402 -2.72949 -4.09375 -6.19336 -4.09375 -9.97168s1.46973 -7.34766 4.09375 -9.97168v0v0c2.62402 -2.62402 6.19238 -4.09277 9.97168 -4.09277c3.77832 0 7.34668 1.46875 9.9707 4.09277
s4.09375 6.19336 4.09375 9.97168s-1.46973 7.34766 -4.09375 9.97168s-6.19238 4.09277 -9.9707 4.09277c-3.7793 0 -7.34766 -1.46875 -9.97168 -4.09277zM52.8945 119.682c5.5625 5.45801 5.5625 14.4844 0 19.9424c-2.62402 2.62402 -6.19336 4.09375 -9.97168 4.09375
s-7.34766 -1.46973 -9.97168 -4.09375c-5.5625 -5.45801 -5.5625 -14.3789 0 -19.9424c2.62402 -2.62402 6.19336 -4.09375 9.97168 -4.09375s7.34766 1.46973 9.97168 4.09375zM180.736 249.624l17.8438 18.0527l-8.92188 8.92188v0v0l-8.92188 8.92188l-17.9482 -17.9482z
M462.977 245.11l17.9482 17.9482l-17.9482 17.9482l-17.9482 -17.9482z" />
<glyph glyph-name="uniF12B" unicode="&#xf12b;"
d="M195.743 89.7715c6.51562 -4.8877 7.70996 -14.1182 2.93164 -20.5254c-14.334 -19.1123 -33.1211 -34.8584 -54.2969 -45.6094c-22.1543 -11.2939 -46.1533 -17.0498 -71.2393 -17.0498h-40.5059c-8.14453 0 -14.6602 6.51562 -14.6602 14.6602
s6.51562 14.6611 14.6602 14.6611h40.3975c40.5059 0 77.7549 18.5693 102.188 50.9307c4.88672 6.51562 14.0088 7.81934 20.5244 2.93262zM237.118 295.885c-6.40723 4.99609 -7.49316 14.1172 -2.71484 20.6338c30.0811 38.7686 75.3652 60.9219 124.232 60.9219h85.248
l-45.502 45.501c-5.75488 5.75586 -5.75488 14.9863 0 20.7422c5.75586 5.75488 14.9863 5.75488 20.7422 0l70.5869 -70.5869c5.75586 -5.75586 5.75586 -14.9863 0 -20.7422l-70.5869 -70.5869c-2.82324 -2.93164 -6.62402 -4.34375 -10.3164 -4.34375
s-7.49316 1.52051 -10.3174 4.34375c-5.75488 5.75586 -5.75488 14.9863 0 20.7422l45.502 45.501h-85.2471c-39.7461 0 -76.5596 -18.0264 -100.994 -49.5195c-2.93164 -3.69238 -7.27539 -5.64648 -11.6191 -5.64648c-3.14941 0 -6.29883 0.976562 -9.01367 3.04004z
M419.232 102.15l70.4785 -70.4775c5.75586 -5.75586 5.75586 -14.9863 0 -20.7422l-70.5869 -70.5869c-2.82324 -2.93164 -6.625 -4.34375 -10.3164 -4.34375c-3.69238 0 -7.49316 1.52051 -10.3174 4.34375c-5.75488 5.75586 -5.75488 14.9863 0 20.7422l45.502 45.501
h-85.2471c-86.6592 0 -157.138 70.4785 -157.138 157.138v56.4697c0 70.4785 -57.3389 127.816 -127.816 127.816h-40.5068c-8.14453 0 -14.6602 6.51562 -14.6602 14.6602s6.51562 14.6611 14.6602 14.6611h40.5068c86.6582 0 157.137 -70.4785 157.137 -157.138v-56.4697
c0 -70.4785 57.3389 -127.816 127.817 -127.816h85.2471l-45.502 45.501c-5.75488 5.75586 -5.75488 14.9863 0 20.7412c5.75586 5.75586 14.9863 5.75586 20.7422 0z" />
<glyph glyph-name="uniF10F" unicode="&#xf10f;"
d="M469.474 374.315c7.89453 0 14.2109 -6.31543 14.2109 -14.21s-6.31641 -14.2109 -14.2109 -14.2109h-25.6846v-333.895c0 -41.8945 -34.1045 -76 -76 -76h-223.578c-41.8955 0 -76 34.1055 -76 76v333.895h-25.6846c-7.89453 0 -14.2109 6.31641 -14.2109 14.2109
s6.31641 14.21 14.2109 14.21h106.526v17.3691c0 31.0527 25.2627 56.3154 56.3154 56.3154h101.264c31.0527 0 56.3154 -25.2627 56.3154 -56.3154v-17.3691h106.526zM177.474 391.685v-17.3691h157.053v17.3691c0 15.3682 -12.5264 27.8945 -27.8945 27.8945h-101.264
c-15.3682 0 -27.8945 -12.5264 -27.8945 -27.8945zM415.368 12h0.105469v333.895h-318.842v-333.895c0 -26.2109 21.3682 -47.5791 47.5791 -47.5791h223.578c26.2109 0 47.5791 21.3682 47.5791 47.5791zM256 15.3682c-7.89453 0 -14.2109 6.4209 -14.2109 14.3164v251.053
c0 7.89453 6.31641 14.21 14.2109 14.21s14.2109 -6.31543 14.2109 -14.21v-251.158c0 -7.89453 -6.31641 -14.2109 -14.2109 -14.2109zM163.263 31.0527c-7.78906 0 -14.21 6.31543 -14.21 14.21v219.896c0 7.89453 6.31543 14.21 14.21 14.21
c7.89551 0 14.2109 -6.31543 14.2109 -14.21v-219.896c0 -7.89453 -6.31543 -14.21 -14.2109 -14.21zM348.737 31.0527c-7.89551 0 -14.2109 6.31543 -14.2109 14.21v219.896c0 7.89453 6.31543 14.21 14.2109 14.21c7.89453 0 14.21 -6.31543 14.21 -14.21v-219.896
c0 -7.89453 -6.31543 -14.21 -14.21 -14.21z" />
<glyph glyph-name="uniF114" unicode="&#xf114;"
d="M507.273 215.844c5.79199 -5.26562 6.31836 -14.2168 1.26367 -20.1133c-2.84277 -3.16016 -6.73926 -4.73926 -10.6357 -4.73926c-3.37012 0 -6.73926 1.1582 -9.47754 3.58008l-43.3867 38.8584v-87.9316v-35.8037v-117.103
c0 -25.168 -15.7959 -44.8604 -35.8047 -44.8604h-76.0312c-18.5342 0 -33.5938 15.0586 -33.5938 33.5928v127.738c0 2.84277 -2.31641 5.15918 -5.16016 5.15918h-76.7686c-2.84375 0 -5.16016 -2.31641 -5.16016 -5.15918v-127.738
c0 -18.5342 -15.0586 -33.5928 -33.5928 -33.5928h-74.3477h-0.841797h-0.842773c-19.7979 0 -35.8047 16.1113 -35.8047 35.8047v131.213v112.468v7.58203l-43.3867 -38.8584c-2.7373 -2.42188 -6.10742 -3.58008 -9.47754 -3.58008
c-3.89648 0 -7.79199 1.5791 -10.6357 4.73828c-5.16016 5.89746 -4.73926 14.8486 1.1582 20.1143l240.943 215.459c5.58105 4.94922 14.0059 4.73828 19.2715 -0.316406c0.526367 -0.31543 0.947266 -0.631836 1.36914 -1.05273zM416.499 145.604v0v113.311
l-161.226 144.166l-159.856 -142.797v-32.9609v-112.469v-131.317c0 -4.10742 3.37012 -7.37207 7.37109 -7.37207h0.842773h0.842773h74.3467c2.84375 0 5.16016 2.31641 5.16016 5.16016v127.738c0 18.5332 15.0586 33.5928 33.5928 33.5928h76.7695
c18.5342 0 33.5928 -15.0596 33.5928 -33.5928v-127.527c0 -2.84375 2.31641 -5.16016 5.16016 -5.16016h76.0312c2.31738 0 7.37207 6.21289 7.37207 16.4277v116.996v35.8047z" />
<glyph glyph-name="uniF128" unicode="&#xf128;"
d="M405.688 119.069c50.498 0 91.6416 -41.0361 91.6416 -91.5342c0 -50.499 -41.0371 -91.5352 -91.5352 -91.5352s-91.5352 41.1426 -91.5352 91.5352c0 10.6309 1.91406 20.9434 5.31543 30.5117l-140.544 78.5645c-16.7979 -21.9004 -43.1631 -36.04 -72.7178 -36.04
c-50.499 0 -91.6416 41.0371 -91.6416 91.5352s41.1426 91.5352 91.5352 91.5352c29.5547 0 55.9199 -14.0332 72.6113 -35.9336l140.438 78.458c-3.40234 9.46191 -5.20898 19.668 -5.20898 30.2988c0 50.499 41.0361 91.5352 91.5342 91.5352
c50.499 0 91.5352 -41.0361 91.5352 -91.5352c0 -50.498 -41.1426 -91.5342 -91.5352 -91.5342c-29.5547 0 -55.9199 14.1387 -72.6113 35.9326l-140.545 -78.458c3.40234 -9.56836 5.20996 -19.668 5.20996 -30.2988c0 -10.5254 -1.80762 -20.7314 -5.10352 -30.1924
l140.651 -78.5654c16.7979 21.6875 43.0566 35.7207 72.5049 35.7207zM405.688 419.189c-34.5518 0 -62.8301 -28.1729 -62.8301 -62.8311c0 -34.6572 28.1729 -62.8301 62.8301 -62.8301c34.6582 0 62.8311 28.1729 62.8311 62.8301
c0 34.6582 -28.1729 62.8311 -62.8311 62.8311zM106.312 129.275c34.5518 0 62.8301 28.1729 62.8301 62.8311c0 34.6572 -28.1729 62.8301 -62.8301 62.8301c-34.6582 0 -62.8311 -28.1729 -62.8311 -62.8301c0 -34.6582 28.1729 -62.8311 62.8311 -62.8311z
M405.688 -35.1895c34.6582 0 62.8311 28.1729 62.8311 62.8311c0 34.6572 -28.1729 62.8301 -62.8311 62.8301c-34.6572 0 -62.8301 -28.1729 -62.8301 -62.8301c0 -34.6582 28.1729 -62.8311 62.8301 -62.8311z" />
<glyph glyph-name="uniF102" unicode="&#xf102;"
d="M155.784 192.014l231.12 -231.228c5.68652 -5.6875 5.68652 -14.8076 0 -20.4941c-2.79004 -2.89746 -6.54492 -4.29199 -10.1934 -4.29199s-7.40332 1.50195 -10.1934 4.29199l-241.528 241.421c-5.68652 5.68652 -5.68652 14.8066 0 20.4941l241.528 241.528
c5.68652 5.68652 14.8076 5.68652 20.4941 0c5.68652 -5.6875 5.68652 -14.8076 0 -20.4941z" />
<glyph glyph-name="uniF121" unicode="&#xf121;"
d="M442.611 179.047c7.95508 0 14.5127 -6.55762 14.5127 -14.5117c0 -125.984 -102.551 -228.535 -228.535 -228.535s-228.535 102.551 -228.535 228.535s102.551 228.535 228.535 228.535c8.06152 0 14.5117 -6.4502 14.5117 -14.5127v-199.511h199.511zM228.589 -34.9766
c105.13 0 191.557 81.6963 198.974 185h-198.974c-8.0625 0 -14.5117 6.44922 -14.5117 14.5117v198.974c-103.304 -7.41699 -185 -93.8438 -185 -198.974c0 -109.968 89.5439 -199.512 199.512 -199.512zM511.946 220.218v-0.75293
c0 -8.06152 -6.4502 -14.5117 -14.5117 -14.5117v0h-214.023c-8.06152 0 -14.5117 6.4502 -14.5117 14.5117v214.023c0 8.0625 6.4502 14.5117 14.5117 14.5117c65.5723 0 127.92 -28.1641 171.348 -77.2891c36.6562 -41.6006 56.9727 -95.0254 57.1875 -150.493z
M297.923 233.869h184.462c-3.22461 43.4287 -20.4238 84.707 -49.4473 117.492c-34.6133 39.2363 -83.2012 63.208 -135.015 66.9697v-184.462z" />
<glyph glyph-name="uniF104" unicode="&#xf104;"
d="M437.01 373.01c48.377 -48.377 74.9902 -112.592 74.9902 -181.01s-26.6133 -132.633 -74.9902 -181.01s-112.592 -74.9902 -181.01 -74.9902s-132.633 26.6133 -181.01 74.9902s-74.9902 112.592 -74.9902 181.01s26.6133 132.633 74.9902 181.01
s112.592 74.9902 181.01 74.9902s132.633 -26.6133 181.01 -74.9902zM416.431 31.5693c88.458 88.458 88.458 232.403 0 320.861c-42.8818 42.8828 -99.8789 66.4785 -160.431 66.4785s-117.549 -23.5957 -160.431 -66.4785c-88.458 -88.458 -88.458 -232.403 0 -320.861
c42.8818 -42.8828 99.8789 -66.4785 160.431 -66.4785s117.549 23.5957 160.431 66.4785zM368.808 304.809c5.71094 -5.71094 5.71094 -14.8691 0.000976562 -20.5801l-92.2295 -92.2285l92.0137 -92.2285c5.70996 -5.71094 5.70996 -14.8691 0 -20.5801
c-2.80176 -2.9082 -6.46484 -4.30957 -10.2363 -4.30957c-3.66309 0 -7.43359 1.50879 -10.2354 4.30957l-92.2285 92.2295l-92.2295 -92.2295c-2.80078 -2.9082 -6.57227 -4.30957 -10.2354 -4.30957s-7.43457 1.50879 -10.2354 4.30957
c-5.71094 5.71094 -5.71094 14.8691 0 20.5801l92.2285 92.2285l-92.2285 92.2285c-5.71094 5.71094 -5.71094 14.8691 0 20.5801c5.70996 5.70996 14.8682 5.70996 20.5791 0l92.2285 -92.2295l92.2285 92.2295c5.71094 5.70996 14.8691 5.70996 20.5791 0z" />
<glyph glyph-name="uniF12E" unicode="&#xf12e;"
d="M502.712 222.068c12.3838 -16.8975 12.3838 -43.3447 0 -60.1367l-87.6338 -119.538c-11.4395 -15.7432 -34.8428 -27.6025 -54.3643 -27.6025h-319.573c-22.6689 0 -41.1406 18.4717 -41.1406 41.1406v272.137c0 22.6689 18.4717 41.1406 41.1406 41.1406h319.573
c19.416 0 42.8203 -11.8594 54.3643 -27.6025zM479.833 178.514c5.14258 7.03223 5.14258 19.8359 0.104492 26.8672l-87.6328 119.539c-6.19238 8.29102 -21.2002 15.9521 -31.5908 15.9521h-319.573c-7.03223 0 -12.8037 -5.77246 -12.8037 -12.8037v-272.241
c0 -7.03223 5.77148 -12.8037 12.8037 -12.8037h319.469c10.3896 0 25.5029 7.55566 31.5898 15.9521z" />
<glyph glyph-name="uniF11E" unicode="&#xf11e;"
d="M507.617 443.548c4.36621 -4.25781 5.56738 -10.6982 3.16602 -16.2656l-209.041 -482.267c-2.29199 -5.13086 -7.0957 -8.51465 -12.6621 -8.8418h-0.874023c-5.12988 0 -10.042 2.83789 -12.6621 7.31348l-99.8809 169.962l-168.215 95.4053
c-4.80371 2.72852 -7.75098 7.96875 -7.42285 13.5352c0.327148 5.56738 3.71094 10.4795 8.8418 12.7725l482.485 211.441c5.45801 2.40234 12.0078 1.20117 16.2646 -3.05566zM430.878 387.985l-383.259 -167.997l136.34 -77.2852zM286.241 -16.5605l167.015 385.225
l-248.338 -246.701z" />
<glyph glyph-name="uniF105" unicode="&#xf105;"
d="M436.978 372.942c48.3584 -48.3584 74.9502 -112.693 74.9502 -180.996s-26.5918 -132.638 -74.9502 -180.996s-112.586 -74.9502 -180.996 -74.9502c-42.4609 0 -84.1719 10.5078 -121.271 30.4521c-27.5566 -18.9795 -60.582 -29.8086 -91.999 -29.8086v0
c-6.32617 0 -12.4385 0.536133 -18.1211 1.39355c-10.4004 1.6084 -18.335 9.65039 -19.9434 20.0508c-1.50195 10.2939 3.75293 20.373 13.0811 24.9844c15.2256 7.50488 28.8438 23.1602 40.3164 46.6426c-83.0996 101.006 -76.3438 250.049 16.9414 343.335
c48.3594 48.3584 112.587 74.9502 180.996 74.9502c68.4102 0 132.638 -26.6992 180.996 -75.0576zM416.498 31.4307c88.5674 88.46 88.5674 232.571 0 321.246c-42.8906 42.8906 -99.8271 66.4795 -160.517 66.4795c-60.582 0 -117.626 -23.5889 -160.516 -66.4795
c-84.708 -84.708 -88.8896 -221.312 -9.54297 -310.953c3.75293 -4.28906 4.71777 -10.293 2.46582 -15.4404c-12.1162 -27.7705 -26.5918 -47.7148 -43.8545 -60.582c14.4746 0.322266 47.5 4.0752 80.3115 29.4873c2.57324 1.92969 5.79004 3.00195 8.89941 3.00195
c2.46582 0 5.04004 -0.75 7.29102 -2.03711c34.6338 -20.373 74.415 -31.2021 114.945 -31.2021c60.583 0 117.626 23.5889 160.517 66.4795zM320.96 309.251c23.0537 0 44.8203 -9.00684 61.2256 -25.4131c16.2988 -16.4053 25.3057 -38.1719 25.3057 -61.332
c0 -23.0537 -9.00684 -44.8203 -25.4131 -61.2256l-116.018 -115.589c-2.78711 -2.78809 -6.54004 -4.18164 -10.1855 -4.18164c-3.64648 0 -7.39941 1.50098 -10.1865 4.28906l-115.696 115.695c-16.2979 16.4053 -25.4121 38.0654 -25.4121 61.2256
c-0.107422 23.0537 8.89941 44.8203 25.3047 61.2256c16.2988 16.2988 38.0654 25.3057 61.1182 25.3057c23.1611 0 44.9277 -9.00684 61.333 -25.4131l3.64551 -3.64551l3.64551 3.64551c16.4062 16.4062 38.1729 25.4131 61.333 25.4131zM361.599 181.76
c10.9365 10.9375 16.9414 25.4121 16.9414 40.8525c0.107422 15.334 -5.89746 29.8086 -16.834 40.7461c-10.9375 10.9365 -25.4131 16.9414 -40.7461 16.9414c-15.4404 0 -29.916 -6.00488 -40.8525 -16.9414l-13.832 -13.832
c-5.68262 -5.68262 -14.7969 -5.68262 -20.4805 0l-13.9385 13.9395c-10.9375 10.9365 -25.4131 16.9414 -40.8535 16.9414s-29.915 -6.00488 -40.7451 -16.835c-10.8301 -10.8291 -16.834 -25.3047 -16.834 -40.7451s6.00391 -29.916 16.9414 -40.8525l105.51 -105.51z" />
<glyph glyph-name="uniF10A" unicode="&#xf10a;"
d="M169.312 237.642c5.55859 5.66504 14.751 5.66504 20.416 0l51.8418 -51.8408v247.77c0 8.0166 6.41309 14.4297 14.4297 14.4297s14.4297 -6.41309 14.4297 -14.4297v-247.77l51.8418 51.8408c5.66504 5.66504 14.751 5.66504 20.416 0s5.66504 -14.751 0 -20.416
l-76.5332 -76.5322c-2.7793 -2.88672 -6.52051 -4.27539 -10.1543 -4.27539s-7.375 1.49609 -10.1543 4.27539l-76.5332 76.5322c-5.66504 5.66504 -5.66504 14.751 0 20.416zM491.905 119.422c0.426758 -1.17578 0.53418 -2.45801 0.53418 -3.84766v-75.4639
c0 -57.3994 -46.7109 -104.11 -104.11 -104.11h-264.658c-57.3994 0 -104.11 46.7109 -104.11 104.11v75.7842c0 1.2832 0.213867 2.56543 0.53418 3.84863l50.2383 181.712c1.06934 4.16797 4.06152 7.48145 7.91016 9.29883l1.38965 0.641602
c1.92383 0.855469 3.95508 1.28223 5.98535 1.28223h90.6426c8.0166 0 14.4297 -6.41309 14.4297 -14.4297s-6.41309 -14.4297 -14.4297 -14.4297h-80.8086l-42.6484 -154.028h107.851c8.01758 0 14.4307 -6.41309 14.4307 -14.4297v-13.04
c0 -19.1338 15.499 -34.6328 34.6318 -34.6328h88.0771c19.1328 0 34.6318 15.499 34.6318 34.6328v13.04c0 8.0166 6.41309 14.4297 14.4307 14.4297h112.233l-42.4355 153.707h-80.8076c-8.0166 0 -14.4307 6.41309 -14.4307 14.4307
c0 8.0166 6.41406 14.4297 14.4307 14.4297h90.5352c2.03027 0 4.06152 -0.427734 5.98535 -1.28223l1.38965 -0.641602c3.84863 -1.81738 6.73438 -5.13086 7.91016 -9.2998zM463.579 40.1104h0.213867v60.8203h-102.293
c-0.748047 -34.3115 -28.9668 -61.9961 -63.4922 -61.9961h-88.0771c-34.5254 0 -62.7441 27.6846 -63.4922 61.9961h-98.0176v-60.8203c0 -41.4736 33.7773 -75.25 75.25 -75.25h264.658c41.4727 0 75.25 33.7764 75.25 75.25z" />
<glyph glyph-name="uniF127" unicode="&#xf127;"
d="M270.924 198.449c33.4727 -6.71582 58.6299 -36.3506 58.6299 -71.6348c0 -35.3916 -25.2637 -64.9199 -58.6299 -71.6357v-104.682c0 -7.99414 -6.39551 -14.3906 -14.3906 -14.3906s-14.3916 6.39648 -14.3916 14.3906v104.682
c-33.3652 6.71582 -58.6299 36.2441 -58.6299 71.6357c0 35.3906 25.2646 64.9189 58.6299 71.6348v235.16c0 7.99414 6.39648 14.3906 14.3916 14.3906s14.3906 -6.39648 14.3906 -14.3906v-235.16zM256.533 82.5752c24.4111 0 44.2393 19.8271 44.2393 44.2393
c0 24.4111 -19.8281 44.2383 -44.2393 44.2383s-44.2393 -19.8271 -44.2393 -44.2383c0 -24.4121 19.8281 -44.2393 44.2393 -44.2393zM94.7139 319.12c33.3662 -6.71582 58.6299 -36.1367 58.6299 -71.5283c0 -35.3906 -25.2637 -64.9189 -58.6299 -71.6348v-225.459
c0 -7.99512 -6.39551 -14.3916 -14.3906 -14.3916s-14.3916 6.39648 -14.3916 14.3916v225.352c-33.3652 6.71582 -58.6299 36.2441 -58.6299 71.6357s25.2646 64.9189 58.6299 71.6348v114.489c0 7.99414 6.39648 14.3906 14.3916 14.3906
s14.3906 -6.39648 14.3906 -14.3906v-114.489zM80.3232 203.246c24.3047 0 44.2393 19.8281 44.2393 44.2393s-19.8281 44.2393 -44.2393 44.2393s-44.2393 -19.8281 -44.2393 -44.2393s19.8281 -44.2393 44.2393 -44.2393zM446.068 319.12
c33.4717 -6.71582 58.6299 -36.1367 58.6299 -71.5283c0 -35.3906 -25.2646 -64.9189 -58.6299 -71.6348v-225.566c0 -7.99414 -6.39648 -14.3906 -14.3916 -14.3906s-14.3906 6.39648 -14.3906 14.3906v225.459c-33.3662 6.71582 -58.6299 36.2441 -58.6299 71.6357
s25.2637 64.9189 58.6299 71.6348v114.489c0 7.99414 6.39551 14.3906 14.3906 14.3906s14.3916 -6.39648 14.3916 -14.3906v-114.489zM431.677 203.246c24.4111 0 44.2393 19.8281 44.2393 44.2393s-19.8281 44.2393 -44.2393 44.2393s-44.2393 -19.8281 -44.2393 -44.2393
s19.8281 -44.2393 44.2393 -44.2393z" />
<glyph glyph-name="uniF101" unicode="&#xf101;"
d="M452.09 -42.9824c2.16699 -4.55078 1.8418 -9.8584 -0.758789 -14.1924c-2.59961 -4.22461 -7.36621 -6.8252 -12.3506 -6.8252h-365.961c-4.98438 0 -9.64258 2.60059 -12.3506 6.8252c-2.60059 4.22559 -2.92578 9.53418 -0.758789 14.084
c24.1592 50.1602 66.8438 89.3779 118.195 108.878v29.793c-30.876 1.2998 -56.9854 7.69238 -77.8936 18.959c-18.3096 9.8584 -25.8926 20.042 -27.1934 21.9922c-3.25 4.76758 -4.33301 10.9424 -1.4082 15.8174c2.81738 4.7666 7.25879 7.58398 12.7842 7.15039
c1.625 0.433594 7.9082 2.59961 14.5166 13.542c8.01758 13.3252 17.334 41.0596 16.0342 97.7197c-1.2998 56.5518 9.31738 100.212 31.5264 129.68c19.0674 25.3506 41.6016 33.9092 53.7344 38.459l0.650391 0.216797c15.3838 5.95898 30.4424 8.88379 44.8516 8.88379
c30.876 0 51.0264 -13.542 60.2354 -21.5586h2.81641c24.1592 0 44.2021 -8.66699 59.5859 -25.6768c14.1914 -15.8164 23.9424 -38.459 28.8174 -67.2764c4.22461 -25.1348 4.7666 -54.3857 1.5166 -86.8867c-7.47559 -76.2686 29.793 -89.0527 30.2256 -89.1611
c4.9834 -1.4082 8.88379 -5.30859 10.1836 -10.292c1.30078 -4.9834 -0.108398 -10.292 -3.68262 -13.9756c-28.2764 -28.9258 -76.4863 -35.4258 -102.054 -36.834v-31.3096c52.002 -19.6094 94.9033 -58.502 118.737 -108.012zM97.9365 -34.6406v-0.216797h315.802
c-23.292 35.3184 -58.502 62.1855 -99.6699 75.5107c-6.06641 1.9502 -10.1836 7.47559 -10.1836 13.8672v56.0098c0 4.00879 1.625 7.80078 4.44238 10.5088c2.81641 2.70898 6.7168 4.22559 10.6162 4.11719h2.16699c23.9424 0 55.6855 4.6582 78.0029 17.0088
c-16.5762 14.9502 -35.751 45.7178 -29.6846 107.146c6.06641 60.8848 -2.38379 108.878 -22.9678 131.629c-9.75 10.834 -22.209 16.0342 -37.918 16.0342c-2.27441 0 -4.6582 -0.108398 -7.04102 -0.325195c-4.87598 -0.433594 -9.53418 1.4082 -12.5674 5.2002
c-1.19238 1.4082 -15.4922 16.792 -43.4434 16.792c-10.7256 0 -22.3174 -2.38379 -34.4512 -6.93359l-0.649414 -0.216797c-20.584 -7.7998 -69.0107 -26.2168 -66.1943 -140.079c1.4082 -61.6436 -8.66699 -104.112 -30.0088 -126.321
c-1.08398 -1.08301 -2.16699 -2.05762 -3.14258 -3.0332c13.8672 -8.2334 39.3262 -17.875 81.4697 -17.4424c3.90039 0.108398 7.58301 -1.5166 10.4004 -4.22461c2.81641 -2.70898 4.33301 -6.50098 4.33301 -10.4004v-54.4941
c0 -6.2832 -4.11621 -11.917 -10.1836 -13.8662c-40.626 -13.1094 -75.7275 -40.3018 -99.1279 -76.2695z" />
<glyph glyph-name="uniF106" unicode="&#xf106;"
d="M436.939 372.958c48.3486 -48.3486 74.9355 -112.777 74.9355 -181.065s-26.5869 -132.609 -74.9355 -180.958c-48.3477 -48.3486 -112.562 -74.9346 -180.958 -74.9346c-42.4521 0 -84.1533 10.5059 -121.246 30.4453
c-27.5508 -18.9746 -60.5693 -29.8018 -91.9795 -29.8018v0c-6.3252 0 -12.4355 0.536133 -18.1172 1.39355c-10.3994 1.60742 -18.332 9.64844 -19.9404 20.0469c-1.5 10.291 3.75293 20.3682 13.0791 24.9775c15.3301 7.50488 28.8379 23.1562 40.3086 46.6338
c-83.082 100.984 -76.3291 249.996 16.9375 343.263c48.3486 48.3486 112.563 75.042 180.958 75.042c68.3955 0 132.61 -26.6934 180.958 -75.042zM416.464 31.5176c88.5498 88.4424 88.5498 232.522 0 321.18c-42.8809 42.8809 -99.9121 66.4648 -160.482 66.4648
c-60.5693 0 -117.601 -23.584 -160.482 -66.4648c-84.6895 -84.6904 -88.8711 -221.267 -9.54102 -310.889c3.75195 -4.28809 4.7168 -10.291 2.46582 -15.4365c-12.1133 -27.7656 -26.5859 -47.7051 -43.8457 -60.5693c14.4727 0.321289 47.4912 4.07324 80.2949 29.4805
c2.57227 1.92969 5.78906 3.00195 8.89746 3.00195c2.46582 0 5.03906 -0.750977 7.29004 -2.03711c34.626 -20.3691 74.3984 -31.1963 114.921 -31.1963c60.5703 0 117.602 23.585 160.482 66.4658zM363.185 276.476c7.93262 0 14.4727 -6.43164 14.4727 -14.4727
c0 -8.04004 -6.43262 -14.4717 -14.4727 -14.4717h-214.405c-8.04004 0 -14.4727 6.43164 -14.4727 14.4717c0 8.04102 6.43262 14.4727 14.4727 14.4727h214.405zM363.185 206.473c7.93262 0 14.4727 -6.43262 14.4727 -14.4727s-6.43262 -14.4727 -14.4727 -14.4727
h-214.405c-8.04004 0 -14.4727 6.43262 -14.4727 14.4727s6.43262 14.4727 14.4727 14.4727h214.405zM363.185 136.469c7.93262 0 14.4727 -6.43164 14.4727 -14.4717c0 -8.04102 -6.43262 -14.4727 -14.4727 -14.4727h-214.405
c-8.04004 0 -14.4727 6.43164 -14.4727 14.4727c0 8.04004 6.43262 14.4717 14.4727 14.4717h214.405z" />
<glyph glyph-name="uniF10B" unicode="&#xf10b;"
d="M497.331 197.922c8.14941 0 14.6689 -6.62793 14.6689 -14.6689v-133.433c0 -52.374 -42.5938 -95.0762 -95.0762 -95.0762h-321.848c-52.373 0 -95.0762 42.5938 -95.0762 95.0762v135.605c0 8.14941 6.51953 14.6689 14.6689 14.6689
s14.6689 -6.51953 14.6689 -14.6689v-135.605c0 -36.1836 29.4463 -65.7393 65.7383 -65.7393h321.848c36.1836 0 65.7383 29.4473 65.7383 65.7393v133.433c0 8.14941 6.51953 14.6689 14.6689 14.6689zM245.678 71.2256l-93.3379 93.2295
c-5.75879 5.75879 -5.75879 14.9951 0 20.7539s14.9941 5.75879 20.7539 0l68.2373 -68.2383v297.617c0 8.14941 6.51953 14.6689 14.6689 14.6689s14.6689 -6.51953 14.6689 -14.6689v-297.617l68.1289 68.2383c5.75879 5.75879 14.9951 5.75879 20.7539 0
s5.75879 -14.9951 0 -20.7539l-93.2295 -93.2295c-2.8252 -2.93359 -6.62793 -4.3457 -10.3223 -4.3457s-7.49707 1.52051 -10.3223 4.3457z" />
<glyph glyph-name="uniF119" unicode="&#xf119;"
d="M448.851 421.953c34.8701 0 63.1494 -28.2783 63.1494 -63.1494v-244.624c0 -34.8711 -28.3857 -63.1494 -63.1494 -63.1494h-178.498v-60.2793h88.9834c7.97266 0 14.3516 -6.37891 14.3516 -14.3525s-6.37891 -14.3516 -14.3516 -14.3516h-206.672
c-7.97266 0 -14.3516 6.37793 -14.3516 14.3516s6.37891 14.3525 14.3516 14.3525h88.9834v60.2793h-178.498c-34.8701 0 -63.1494 28.3848 -63.1494 63.1494v244.624c0 34.8711 28.3857 63.1494 63.1494 63.1494h385.701zM63.1494 393.249
c-19.0303 0 -34.4453 -15.415 -34.4453 -34.4453v-204.438h454.698v204.438c0 19.0303 -15.5225 34.4453 -34.4453 34.4453h-385.808zM448.851 79.7344c19.0303 0 34.4453 15.5215 34.4453 34.4453v11.4814h-454.592v-11.4814c0 -19.0303 15.5215 -34.4453 34.4453 -34.4453
h385.701z" />
<glyph glyph-name="uniF11B" unicode="&#xf11b;"
d="M506.858 221.764c3.25293 -2.72852 5.1416 -6.82031 5.1416 -11.0176s-1.88867 -8.18457 -5.1416 -10.9121l-226.013 -186.98c-4.30176 -3.46191 -10.0732 -4.19727 -15.1094 -1.88867c-5.03711 2.30859 -8.18457 7.34473 -8.18457 12.8018v97.8965
c-91.3916 -0.314453 -154.452 -6.82031 -231.049 -141.861c-2.51855 -4.51172 -7.24023 -7.13477 -12.2773 -7.13477c-1.15332 0 -2.41309 0.104492 -3.56738 0.419922c-6.29492 1.67871 -10.5977 7.34473 -10.5977 13.7451v11.332
c-0.314453 49.001 -0.628906 123.079 33.1572 185.616c39.2432 72.5039 114.686 111.223 224.439 115.21v98.2109c0 5.45703 3.14746 10.4932 8.0791 12.8018s10.8076 1.57324 15.0049 -1.88867zM285.987 53.8799l189.498 156.866l-189.604 156.446v-82.0527
c0 -7.76465 -6.29492 -14.0605 -14.0596 -14.165c-107.761 -0.944336 -177.642 -33.9961 -213.736 -100.73c-22.7695 -42.0752 -28.2256 -91.8105 -29.4854 -133.467c31.374 45.6436 63.167 74.708 99.8906 91.8105c42.4961 19.832 86.25 21.4053 143.331 21.4053
c3.67188 0 7.34473 -1.57324 9.96777 -4.19629c2.72852 -2.62402 4.19727 -6.19141 4.19727 -9.96875v-81.9482z" />
<glyph glyph-name="uniF103" unicode="&#xf103;"
d="M445.14 314.912c7.80371 0 14.0254 -6.32812 14.1309 -14.0264v-350.859c0 -7.69922 -6.22168 -14.0264 -14.0254 -14.0264h-311.946c-44.3975 0 -80.5693 36.1719 -80.5693 80.5703v350.859c0 44.3984 36.1719 80.5703 80.5693 80.5703h311.946
c7.69824 0 14.0254 -6.22168 14.0254 -14.0264c0 -7.69824 -6.22168 -14.0254 -14.0254 -14.0254h-311.946c-29.001 0 -52.5176 -23.623 -52.5176 -52.5186c0 -29.001 23.6221 -52.5176 52.5176 -52.5176h311.841zM431.113 -35.9482v0v322.702h-297.814
c-20.0371 0 -38.3867 7.38281 -52.5176 19.5098v-289.693c0 -29.001 23.6221 -52.5186 52.5176 -52.5186h297.814zM137.729 380.19h263.435c7.69824 0 14.0264 -6.32715 14.0264 -14.0264c0 -7.69824 -6.22266 -14.0254 -14.0264 -14.0254h-263.435
c-7.69824 0 -14.0264 6.32715 -14.0264 14.0254c0 7.69922 6.22266 14.0264 14.0264 14.0264zM187.083 25.4287c-7.27637 2.63672 -11.0732 10.6514 -8.75293 18.0332l64.1182 179.174v0c0.105469 0.421875 0.316406 0.84375 0.527344 1.26562l0.316406 0.632812
l0.316406 0.632812s0.316406 0.421875 0.421875 0.632812c0.105469 0.105469 0.210938 0.31543 0.316406 0.420898c0.210938 0.210938 0.316406 0.421875 0.527344 0.632812c0.210938 0.105469 0.316406 0.316406 0.421875 0.421875l0.527344 0.527344l0.527344 0.527344
c0.105469 0.210938 0.316406 0.316406 0.421875 0.421875c0.210938 0.210938 0.421875 0.316406 0.632812 0.527344c0.105469 0.105469 0.316406 0.210938 0.421875 0.316406c0.316406 0.105469 0.527344 0.316406 0.738281 0.421875l0.632812 0.316406l0.632812 0.316406
c0.421875 0.210938 0.84375 0.421875 1.26465 0.527344v0h0.105469c0.316406 0.105469 0.738281 0.210938 1.16016 0.316406c0.210938 0 0.316406 0.105469 0.527344 0.105469c0.316406 0.105469 0.527344 0.210938 0.84375 0.210938
c0.210938 0.105469 0.421875 0.105469 0.632812 0.105469c0.210938 0.105469 0.527344 0.105469 0.738281 0.105469h0.738281h0.632812s0.527344 -0.105469 0.738281 -0.105469c0.210938 -0.105469 0.421875 -0.105469 0.632812 -0.105469
c0.316406 -0.105469 0.527344 -0.105469 0.84375 -0.210938c0.210938 0 0.316406 -0.105469 0.527344 -0.105469l1.26562 -0.316406h0.105469v0c0.421875 -0.105469 0.842773 -0.316406 1.26465 -0.527344l0.632812 -0.316406l0.632812 -0.316406
s0.421875 -0.316406 0.632812 -0.421875c0.105469 -0.105469 0.316406 -0.210938 0.421875 -0.316406c0.210938 -0.210938 0.421875 -0.316406 0.632812 -0.527344c0.105469 -0.210938 0.316406 -0.316406 0.421875 -0.421875l0.527344 -0.527344
s0.421875 -0.316406 0.527344 -0.527344c0.210938 -0.105469 0.316406 -0.316406 0.421875 -0.421875c0.210938 -0.210938 0.421875 -0.421875 0.527344 -0.632812c0.105469 -0.104492 0.210938 -0.31543 0.316406 -0.420898
c0.105469 -0.316406 0.316406 -0.527344 0.421875 -0.738281l0.316406 -0.632812l0.316406 -0.632812c0.210938 -0.421875 0.421875 -0.84375 0.527344 -1.26562v0l63.9072 -179.174c2.63672 -7.27637 -1.16016 -15.291 -8.43652 -17.9277
c-1.58203 -0.527344 -3.16406 -0.84375 -4.74512 -0.84375c-5.69531 0 -11.1787 3.58594 -13.1826 9.28027l-17.084 47.8779c-0.527344 -0.105469 -1.16016 -0.105469 -1.6875 -0.105469h-65.8066l-17.084 -47.7725c-2.00391 -5.69434 -7.38184 -9.28027 -13.1816 -9.28027
c-1.58203 0 -3.16406 0.316406 -4.74609 0.84375zM255.947 176.129l-23.834 -66.4395h47.5625z" />
<glyph glyph-name="uniF12C" unicode="&#xf12c;"
d="M298.227 389.779c7.91797 0 14.252 -6.43945 14.252 -14.251v-367.057c0 -7.91699 -6.33398 -14.252 -14.252 -14.252c-35.3643 0 -69.9902 9.39551 -100.288 27.2363c-27.0254 15.9414 -49.9336 38.1104 -66.7188 64.502h-75.5859
c-30.7197 0 -55.6338 24.9141 -55.6338 55.6338v100.816c0 30.7197 24.9141 55.6338 55.6338 55.6338h75.5859c16.7852 26.3916 39.6934 48.5605 66.7188 64.502c30.2979 17.8408 64.9238 27.2354 100.288 27.2354zM124.991 114.461v155.078h-69.3574
c-14.9902 0 -27.1309 -12.1406 -27.1309 -27.1309v-100.816c0 -14.9902 12.1406 -27.1309 27.1309 -27.1309h69.3574zM283.976 23.251v337.393c-53.5225 -4.54004 -102.295 -34.626 -130.481 -80.8643v-175.664c28.1865 -46.2383 76.959 -76.3252 130.481 -80.8643z
M368.323 302.37c36.1045 -25.125 57.7451 -66.7178 57.7451 -110.634s-21.6406 -85.1924 -57.7451 -110.424c-2.42773 -1.68848 -5.27832 -2.5332 -8.12891 -2.5332c-4.53906 0 -8.97266 2.11133 -11.7178 6.12305c-4.43359 6.43945 -2.85059 15.3076 3.58984 19.8467
c28.5029 19.8467 45.499 52.4668 45.499 87.0928s-16.9961 67.2461 -45.499 87.0928c-6.5459 4.53906 -8.12891 13.4072 -3.58984 19.8467c4.53906 6.54492 13.4072 8.12891 19.8467 3.58887zM423.43 361.382c55.5283 -38.6367 88.5703 -101.978 88.5703 -169.435
s-33.1484 -130.798 -88.5703 -169.436c-2.42871 -1.68848 -5.27832 -2.5332 -8.12891 -2.5332c-4.53906 0 -8.97363 2.11133 -11.7178 6.12305c-4.43457 6.43945 -2.85059 15.3066 3.58887 19.8467c47.8223 33.2529 76.3252 87.9375 76.3252 145.999
s-28.5029 112.64 -76.3252 145.999c-6.54492 4.53906 -8.12891 13.4072 -3.58887 19.8467c4.53906 6.54492 13.4062 8.12891 19.8467 3.58887z" />
<glyph glyph-name="uniF129" unicode="&#xf129;"
d="M460.8 5.41797c0.104492 -0.417969 0.104492 -0.836914 0.104492 -1.25586c0 -37.5889 -34.4473 -68.1621 -76.8516 -68.1621h-256.105c-42.4043 0 -76.8516 30.5732 -76.8516 68.1621c0 0.418945 0.104492 0.837891 0.104492 1.25586l29.3164 330.131
c0.628906 7.3291 6.70117 12.8779 14.0303 12.8779h60.3096c0.837891 55.0742 45.8604 99.5732 101.144 99.5732s100.306 -44.499 101.144 -99.5732h60.3096c7.22461 0 13.4014 -5.54883 14.0303 -12.8779zM256 419.73c-39.6826 0 -72.0361 -31.8301 -72.874 -71.3037
h145.748c-0.837891 39.4736 -33.1914 71.3037 -72.874 71.3037zM384.053 -35.7305c26.4893 0 48.1631 17.5908 48.582 39.2646l-28.165 316.519h-47.3262v-42.9287c0 -7.85254 -6.28223 -14.1348 -14.1348 -14.1348s-14.1348 6.28223 -14.1348 14.1348v42.9287h-145.853
v-42.9287c0 -7.85254 -6.28223 -14.1348 -14.1348 -14.1348s-14.1348 6.28223 -14.1348 14.1348v42.9287h-47.3262l-28.0605 -316.519c0.418945 -21.6738 21.9873 -39.2646 48.582 -39.2646h256.105z" />
<glyph glyph-name="uniF109" unicode="&#xf109;"
d="M489.891 284.098c10.4932 -13.4912 15.418 -27.7324 14.9902 -42.5088c-0.642578 -16.2744 -7.92383 -29.0166 -13.9199 -36.833c6.95996 -17.3457 9.74414 -44.543 -13.5986 -65.8506c-16.917 -15.5254 -45.9346 -22.4854 -85.9805 -20.5586
c-28.1602 1.28516 -51.7168 6.53223 -52.6797 6.74609h-0.107422c-5.46094 0.963867 -11.1357 2.1416 -16.918 3.42676c-0.427734 -6.85352 0.75 -23.8779 13.3848 -62.2109c14.9902 -45.6133 14.0264 -80.5195 -2.78418 -103.861
c-17.7744 -24.5195 -46.2559 -26.4473 -54.501 -26.4473c-8.1377 0 -15.418 3.31934 -20.665 9.42285c-11.7783 13.8125 -10.3857 39.2959 -8.99414 51.0742c-14.0264 37.9043 -53.6445 130.844 -87.2656 156.649c-0.642578 0.427734 -1.17773 0.962891 -1.71289 1.49902
c-9.85059 10.3857 -16.4893 21.6289 -20.9863 31.4795c-6.31738 -3.42676 -13.4912 -5.35352 -21.2012 -5.35352h-65.3145c-24.5205 0 -44.543 19.915 -44.543 44.542v173.996c0 24.5195 19.916 44.542 44.543 44.542h65.3145c9.53027 0 18.417 -2.99805 25.6982 -8.1377
l25.1621 2.99902c3.85449 0.535156 72.3818 9.20801 142.729 7.81641c34.9062 2.67676 63.709 1.71289 85.6592 -2.99902c32.7646 -6.95898 55.1436 -20.8789 66.6006 -41.4375c8.67285 -15.5254 8.67285 -31.1582 7.28125 -41.0088
c21.3076 -19.2734 25.0547 -40.4746 24.3057 -55.5713c-0.536133 -8.56641 -2.46289 -15.9541 -4.49707 -21.415zM116.951 209.574c8.67383 0 15.6328 6.95996 15.6328 15.6328v173.995c0 8.56543 -6.95898 15.6328 -15.6328 15.6328h-65.3145
c-8.56641 0 -15.6328 -6.95996 -15.6328 -15.6328v-173.995c0 -8.56641 6.95996 -15.6328 15.6328 -15.6328h65.3145zM461.408 199.188c-3.85449 5.99609 -2.89062 13.8125 2.46289 18.417c0.75 0.642578 11.5645 11.1357 11.9922 25.1621
c0.321289 9.42285 -4.17578 19.0596 -13.3838 28.6963c-4.60449 4.81836 -5.46094 11.8848 -1.92773 17.4531c1.92773 3.42578 16.4893 31.1582 -15.2041 54.9287c-5.13965 3.85449 -7.17383 10.3867 -4.92578 16.3828c0.321289 1.28418 4.49707 14.8828 -3.10449 28.0527
c-7.28125 12.7422 -23.1279 21.7363 -47.1133 26.876c-19.3799 4.17578 -45.6133 4.9248 -78.0566 2.35547h-1.49902c-68.8486 1.49902 -138.554 -7.49512 -139.196 -7.60254h-0.106445l-10.8154 -1.28418c0.642578 -2.99805 0.963867 -6.21094 0.963867 -9.42285v-173.889
c0 -4.60352 -0.749023 -9.10059 -2.03418 -13.2764c1.92773 -7.1748 7.3877 -23.1279 19.916 -36.7266c48.0762 -38.1182 95.0811 -166.821 97.1162 -172.282c0.749023 -2.35547 0.962891 -4.81836 0.642578 -7.17383c-1.92773 -12.0996 -1.17871 -26.7686 1.3916 -31.0518
c5.78223 0.107422 20.9863 1.71289 30.1943 14.4551c10.9219 15.0977 10.4941 42.0801 -1.28418 77.8428c-17.8818 54.501 -19.4873 83.1963 -5.24707 95.8311c7.06738 6.31738 16.4893 6.63867 23.3418 4.17578c6.4248 -1.3916 12.7422 -2.67676 18.6318 -3.74707
c0.535156 -0.107422 0.962891 -0.213867 1.3916 -0.321289c32.8711 -7.28125 91.7627 -11.5645 112.214 7.06641c7.49512 6.85352 10.2783 15.0977 8.67285 25.0557c-1.28516 8.03027 -5.0332 13.9199 -5.0332 14.0264z" />
<glyph glyph-name="uniF122" unicode="&#xf122;"
d="M474.302 260.83c20.8955 0 37.9062 -16.9053 37.6963 -37.6963v-58.4873c0 -20.8955 -17.0107 -37.9062 -37.9062 -37.9062h-6.19531c-11.4453 -31.1865 -31.1865 -59.7471 -57.332 -83.1631c-0.209961 -0.314453 -0.419922 -0.524414 -0.735352 -0.735352
l-0.209961 -0.209961c-0.945312 -0.839844 -1.99512 -1.67969 -2.93945 -2.51953c-19.8457 -18.0605 -21.5264 -38.9561 -21.6309 -43.3662v-19.1113c0 -14.9102 -12.0762 -26.9854 -26.9863 -26.9854h-78.2275c-14.9111 0 -26.9863 12.0752 -26.9863 26.9854v3.15039
c-11.2354 -0.525391 -22.4707 -0.419922 -33.6006 0.419922v-3.57031c0 -14.9102 -12.0762 -26.9854 -26.9863 -26.9854h-78.2275c-13.4404 0 -24.6758 9.97461 -26.6709 22.9951c-0.209961 1.05078 -0.31543 2.10059 -0.31543 3.25586v0.734375v5.46094
c-0.524414 11.1299 -4.2002 38.8506 -25.4111 60.6914c-15.3301 15.8555 -28.9805 36.4365 -40.4258 61.0078c-6.8252 13.5449 -12.1807 27.7207 -15.751 42.2109l-0.104492 0.525391c-0.105469 0.209961 -0.105469 0.524414 -0.209961 0.839844
c-3.46582 14.3857 -5.14551 29.0859 -5.14551 43.7861c0 56.9121 25.2012 110.359 70.9824 150.365c45.2568 39.4814 105.214 61.3223 168.951 61.3223c9.97559 0 20.0557 -0.629883 29.9258 -1.67969c3.99023 -0.31543 25.3057 -1.89062 42.1064 -7.14062
c13.9658 16.2754 37.2764 33.3916 75.6025 38.1162c1.15527 0.105469 2.41504 0.209961 3.57031 0.209961c10.1855 0 19.6357 -5.56445 24.6758 -14.5947c5.04004 -9.13574 4.72559 -19.8457 -0.839844 -28.7715c-9.97559 -15.751 -12.916 -30.9756 -9.03027 -45.0469
c27.7207 -23.415 48.5117 -52.291 60.9023 -84.1074h7.45508zM391.139 405.104l-1.88965 14.0713l1.78418 -14.0713v0v0.105469l0.105469 -0.105469c-0.105469 0 -0.105469 0 0 0zM483.856 164.542h0.105469v58.1719c0 5.25 -4.30566 9.55469 -9.55566 9.55469h-17.2207
h-0.209961c-6.08984 0 -11.5498 3.99023 -13.4404 9.76562c-10.5 32.0264 -31.0811 61.1123 -59.4316 84.0029c-3.15039 2.625 -5.14551 6.51074 -5.25 10.6055c-6.19531 21.9453 -1.99512 45.4668 12.1797 68.1475c-35.0703 -4.30566 -53.3418 -21.4209 -62.4766 -34.9658
c-4.0957 -5.98535 -12.0752 -7.98047 -18.4805 -4.51562c-8.71582 4.72559 -30.5566 7.77051 -42.3164 8.50488c-0.210938 0.105469 -0.420898 0.105469 -0.630859 0.105469c-8.9248 0.945312 -18.0605 1.46973 -27.0908 1.46973
c-116.659 0 -211.582 -82.2178 -211.582 -183.336c0 -12.6006 1.5752 -25.3057 4.51465 -37.6963v-0.209961c0 -0.105469 0.105469 -0.209961 0.105469 -0.31543c3.04492 -12.4951 7.66504 -24.6758 13.6504 -36.3311c0 -0.209961 0.105469 -0.31543 0.209961 -0.525391
c10.0801 -21.8408 21.9463 -39.7959 35.1768 -53.4463c28.2451 -29.0859 32.7607 -65.3125 33.3906 -79.4883v-0.629883v-4.51465h75.498v17.9551c0 4.0957 1.78516 7.98047 4.83008 10.7109c3.04492 2.625 7.13965 3.88477 11.2354 3.35938
c10.8154 -1.46973 21.8408 -2.20508 32.8662 -2.20508c8.50488 0 17.1152 0.419922 25.7256 1.36523c3.88477 0.419922 7.98047 -0.839844 10.9209 -3.57031c3.04492 -2.625 4.72461 -6.50977 4.72461 -10.5v-17.1152h75.6025v17.9551v0.31543
c0.209961 8.61035 3.04492 38.5361 31.0811 63.9473c0.105469 0.104492 0.209961 0.209961 0.31543 0.314453c0.945312 0.839844 1.88965 1.68066 2.83496 2.52051l0.105469 0.104492c0.104492 0.105469 0.104492 0.105469 0.209961 0.209961
c25.3057 22.5762 43.6807 50.5068 52.9209 80.748c1.78516 5.87988 7.35059 9.97559 13.5459 9.97559h16.3809c5.25 0 9.55469 4.30469 9.55469 9.55566z" />
<glyph glyph-name="uniF12A" unicode="&#xf12a;"
d="M153.703 110.887c-10.8379 0 -21.0449 2.94629 -29.6738 7.99707v-38.5127c0 -16.3105 13.2588 -29.6738 29.6738 -29.6738h268.328c31.5684 0 57.3486 -25.7812 57.3486 -57.3486c0 -31.5684 -25.7803 -57.3486 -57.3486 -57.3486s-57.3486 25.6758 -57.3486 57.3486
c0 10.5225 2.84082 20.4141 7.89258 28.9375h-127.745c5.05078 -8.41797 7.8916 -18.415 7.8916 -28.9375c0 -31.5684 -25.7803 -57.3486 -57.3486 -57.3486c-31.5674 0 -57.3486 25.6758 -57.3486 57.3486c0 10.7334 3.05176 20.835 8.20801 29.4629
c-28.5166 3.68359 -50.6143 28.0957 -50.6143 57.5596v88.3906v69.9756v78.2881v10.207v64.3984l-68.8184 29.043c-7.26074 3.05176 -10.6279 11.3643 -7.57617 18.625s11.3643 10.6279 18.625 7.57617l77.5527 -32.6201
c0.420898 -0.209961 0.946289 -0.420898 1.36719 -0.630859c0.105469 -0.105469 0.210938 -0.105469 0.316406 -0.210938c0.209961 -0.105469 0.525391 -0.31543 0.841797 -0.526367c0.209961 -0.209961 0.420898 -0.31543 0.630859 -0.525391
c0.105469 -0.105469 0.31543 -0.210938 0.420898 -0.316406c0.31543 -0.209961 0.526367 -0.420898 0.736328 -0.630859l0.316406 -0.316406c0.209961 -0.209961 0.420898 -0.419922 0.630859 -0.630859l0.31543 -0.31543
c0.105469 -0.210938 0.316406 -0.420898 0.420898 -0.631836c0.105469 -0.209961 0.210938 -0.31543 0.316406 -0.526367c0.104492 -0.209961 0.209961 -0.31543 0.31543 -0.525391c0.209961 -0.210938 0.31543 -0.420898 0.420898 -0.631836
c0.105469 -0.105469 0.105469 -0.31543 0.209961 -0.420898c0.210938 -0.31543 0.316406 -0.526367 0.420898 -0.841797c0 -0.105469 0.105469 -0.31543 0.105469 -0.420898l0.316406 -0.947266c0 -0.209961 0.104492 -0.31543 0.104492 -0.525391
c0.105469 -0.316406 0.105469 -0.526367 0.210938 -0.841797c0 -0.316406 0.105469 -0.631836 0.105469 -0.947266c0.104492 -0.210938 0.104492 -0.31543 0.104492 -0.420898c0.105469 -0.420898 0.105469 -0.947266 0.105469 -1.47363v-49.877l357.56 -49.3516
c0.210938 0 0.316406 -0.105469 0.420898 -0.105469c0.316406 0 0.526367 0 0.841797 -0.104492c0.105469 0 0.316406 -0.105469 0.526367 -0.105469c0.210938 0 0.526367 -0.105469 0.737305 -0.210938c0.209961 -0.104492 0.419922 -0.104492 0.630859 -0.209961
s0.420898 -0.210938 0.631836 -0.31543c0.209961 -0.105469 0.420898 -0.210938 0.630859 -0.316406c0.210938 -0.104492 0.420898 -0.209961 0.631836 -0.31543c0.209961 -0.209961 0.420898 -0.31543 0.630859 -0.420898
c0.210938 -0.105469 0.31543 -0.209961 0.526367 -0.31543c0.209961 -0.105469 0.420898 -0.31543 0.630859 -0.420898c0.210938 -0.105469 0.316406 -0.31543 0.526367 -0.420898l0.526367 -0.526367c0.105469 -0.105469 0.31543 -0.209961 0.420898 -0.420898
c0.105469 -0.209961 0.31543 -0.31543 0.420898 -0.526367c0.105469 -0.209961 0.31543 -0.31543 0.420898 -0.526367c0.209961 -0.209961 0.31543 -0.31543 0.420898 -0.525391c0.105469 -0.210938 0.31543 -0.420898 0.420898 -0.631836
c0.105469 -0.209961 0.209961 -0.31543 0.31543 -0.526367c0.105469 -0.209961 0.210938 -0.420898 0.31543 -0.630859c0.105469 -0.105469 0.210938 -0.31543 0.316406 -0.526367c0.104492 -0.31543 0.209961 -0.526367 0.31543 -0.736328
c0.105469 -0.210938 0.105469 -0.420898 0.209961 -0.631836c0.105469 -0.209961 0.105469 -0.420898 0.210938 -0.630859c0.105469 -0.31543 0.105469 -0.526367 0.209961 -0.736328c0 -0.210938 0.105469 -0.420898 0.105469 -0.631836
c0 -0.209961 0.105469 -0.526367 0.105469 -0.736328c0.105469 -0.210938 0.105469 -0.420898 0.105469 -0.631836v-0.841797v-0.420898v-118.695c0 -31.9893 -26.0967 -58.085 -58.085 -58.085h-282.113zM224.416 -6.5459c0 15.9941 -13.0488 28.9365 -28.9375 28.9365
c-15.8896 0 -28.9375 -13.0479 -28.9375 -28.9365c0 -15.8896 13.0479 -28.9375 28.9375 -28.9375c15.8887 0 28.9375 12.9424 28.9375 28.9375zM451.074 -6.5459c0 15.9941 -13.0488 28.9365 -28.9375 28.9365c-15.8896 0 -28.9375 -13.0479 -28.9375 -28.9365
c0 -15.8896 13.0479 -28.9375 28.9375 -28.9375c15.8887 0 28.9375 12.9424 28.9375 28.9375zM435.816 139.298c16.3096 0 29.6738 13.2588 29.6738 29.7793v106.384l-341.461 47.0361v-5.26074v-78.2891v-69.9756c0 -16.3105 13.2588 -29.6738 29.6738 -29.6738h282.113z
" />
<glyph glyph-name="uniF124" unicode="&#xf124;"
d="M423.66 343.054c41.7031 0 75.6592 -33.957 75.6592 -75.6602v-122.03c0 -41.7031 -33.9561 -75.6602 -75.6592 -75.6602h-13.2646v-95.9268c0 -20.7979 -16.9775 -37.7764 -37.7764 -37.7764h-234.618c-20.7979 0 -37.7764 16.9785 -37.7764 37.7764v95.9268h-11.8848
c-41.7031 0 -75.6592 33.957 -75.6592 75.6602v122.03c0 41.7031 33.9561 75.6602 75.6592 75.6602h11.9912v29.2871c0 41.7031 33.9561 75.6592 75.6592 75.6592h158.853c41.7031 0 75.6592 -33.9561 75.6592 -75.6592v-29.2871h13.1582zM128.875 372.341v-29.2871h252.764
v29.2871c0 25.8916 -21.1162 47.0088 -47.0078 47.0088h-158.747c-25.8916 0 -47.0088 -21.1172 -47.0088 -47.0088zM381.745 -26.4355v158.216h-252.87v-158.216c0 -4.9873 4.13867 -9.12598 9.12598 -9.12598h234.618c4.9873 0 9.12598 4.13867 9.12598 9.12598z
M470.669 145.257v122.031c0 25.8916 -21.1172 47.0078 -47.0088 47.0078h-335.32c-25.8916 0 -47.0088 -21.1162 -47.0088 -47.0078v-122.031c0 -25.8916 21.1172 -47.0088 47.0088 -47.0088h11.9912v33.5322h-21.1172c-7.95801 0 -14.3252 6.36719 -14.3252 14.3252
c0 7.95898 6.36719 14.3252 14.3252 14.3252h350.177c7.95801 0 14.3252 -6.36621 14.3252 -14.3252c0 -7.95801 -6.36719 -14.3252 -14.3252 -14.3252h-18.8887v-33.5322h13.1582c25.8916 0 47.0088 21.1172 47.0088 47.0088zM163.575 61.3203
c-7.85254 0 -14.3262 6.36719 -14.3262 14.3262c0 7.95801 6.36719 14.3252 14.3262 14.3252h182.409c7.95898 0 14.3262 -6.36719 14.3262 -14.3252c0 -7.95898 -6.36719 -14.3262 -14.3262 -14.3262h-182.409zM347.152 31.3965
c7.85156 0 14.2188 -6.36719 14.2188 -14.3252c0 -7.95898 -6.36719 -14.3252 -14.3252 -14.3252h-182.41c-7.95801 0 -14.3252 6.36621 -14.3252 14.3252c0 7.95801 6.36719 14.3252 14.3252 14.3252h182.517zM423.342 286.812
c7.95898 0 14.3252 -6.36621 14.3252 -14.3252c0 -7.95801 -6.36621 -14.3252 -14.3252 -14.3252h-29.0752c-7.95898 0 -14.3252 6.36719 -14.3252 14.3252c0 7.95898 6.36621 14.3252 14.3252 14.3252h29.0752z" />
<glyph glyph-name="uniF12D" unicode="&#xf12d;"
d="M511.298 251.688c1.68164 -5.14941 0.31543 -10.8232 -3.7832 -14.6064l-115.174 -112.968l27.6377 -158.153c0.31543 -1.05078 0.420898 -2.10156 0.420898 -3.25781c0 -7.77637 -6.41016 -14.1865 -14.1865 -14.1865v0c-2.20703 0 -4.41406 0.525391 -6.51562 1.57617
l-143.021 74.7158l-142.602 -75.5566c-2.10156 -1.15527 -4.30859 -1.68066 -6.62012 -1.68066c-2.94238 0 -5.88477 0.945312 -8.30176 2.73145c-4.41309 3.15332 -6.62012 8.5127 -5.6748 13.8721l26.7969 159.1l-115.909 112.336
c-3.8877 3.67773 -5.35938 9.35254 -3.67773 14.502c1.68066 5.14941 6.09473 8.82715 11.4541 9.66797l159.73 23.749l70.9326 144.913c2.41699 4.93945 7.35547 7.98633 12.7148 7.98633v0c5.35938 0 10.2988 -3.04688 12.7158 -7.88086l71.8779 -144.493l159.73 -22.6982
c5.35938 -0.84082 9.77246 -4.51855 11.4541 -9.66797zM367.331 139.142l100.146 98.0449l-138.713 19.7568c-4.62402 0.735352 -8.61719 3.57227 -10.7188 7.77539l-62.4209 125.473l-61.5801 -125.787c-1.99707 -4.2041 -5.99023 -7.04102 -10.6143 -7.77637
l-138.607 -20.5967l100.462 -97.5195c3.3623 -3.25781 4.83398 -7.88184 4.09863 -12.5059l-23.2246 -138.188l123.791 65.5742c4.20312 2.10156 9.14258 2.20605 13.2412 0l124.211 -64.8379l-24.0645 137.977c-0.84082 4.62402 0.629883 9.35352 3.99316 12.6104z" />
<glyph glyph-name="uniF11A" unicode="&#xf11a;"
d="M486.939 335.223v-0.107422v-238.504c0 -18.7754 -9.33398 -36.0488 -26.2861 -48.8164c-15.7715 -11.8018 -36.3701 -18.3467 -58.2578 -18.3467c-21.8867 0 -42.4863 6.54492 -58.2578 18.3467c-16.9512 12.7676 -26.2852 30.041 -26.2852 48.8164
s9.33398 36.0488 26.2852 48.8164c15.7715 11.8018 36.4785 18.3467 58.2578 18.3467c20.5996 0 40.2334 -5.79395 55.5762 -16.415v168.015l-263.824 -83.9004v-228.311c0 -18.7754 -9.33398 -36.0498 -26.2852 -48.8164
c-15.7715 -11.8018 -36.3711 -18.3467 -58.2578 -18.3467s-42.4863 6.54492 -58.2578 18.3467c-16.9521 12.7666 -26.2861 30.041 -26.2861 48.8164s9.33398 36.0488 26.2861 48.8164c15.7715 11.8018 36.4775 18.3467 58.2578 18.3467
c20.5996 0 40.2334 -5.79395 55.5752 -16.416v188.078v0.107422v98.3838c0 6.33008 4.07715 11.9092 10.085 13.8398l292.792 93.0195c4.39844 1.39453 9.22656 0.537109 12.9824 -2.14551c3.64746 -2.68262 5.90039 -7.08105 5.90039 -11.6943v-98.2764zM109.604 -35.0322
c30.1475 0 55.5752 17.3809 55.5752 38.1953c0 20.707 -25.4277 38.1943 -55.5752 38.1943c-30.1484 0 -55.5762 -17.4883 -55.5762 -38.1943c0 -20.707 25.4277 -38.1953 55.5762 -38.1953zM194.147 261.943l263.824 83.793v68.0215l-263.824 -83.793v-68.0215z
M402.503 58.417c30.041 0 55.4688 17.4873 55.5762 38.1943c0 20.707 -25.4277 38.1953 -55.5762 38.1953s-55.5752 -17.4883 -55.5752 -38.1953s25.4268 -38.1943 55.5752 -38.1943z" />
<glyph glyph-name="uniF118" unicode="&#xf118;"
d="M507.516 -37.8887c5.90332 -5.9043 5.90332 -15.667 0 -21.5703c-2.95117 -3.06543 -6.9248 -4.54102 -10.7852 -4.54102c-3.85938 0 -7.83301 1.58984 -10.7842 4.54102l-124.425 124.424c-38.3711 -33.376 -88.4355 -53.584 -143.155 -53.584
c-120.337 0 -218.31 97.9727 -218.31 218.31s97.8594 218.31 218.31 218.31c120.337 0 218.31 -97.8594 218.31 -218.31c0 -54.7197 -20.207 -104.784 -53.584 -143.155zM30.5947 229.69c0 -103.535 84.2363 -187.771 187.658 -187.771
c103.535 0 187.657 84.3496 187.657 187.771s-84.1221 187.657 -187.657 187.657c-103.422 0 -187.658 -84.1221 -187.658 -187.657z" />
<glyph glyph-name="uniF10D" unicode="&#xf10d;"
d="M449.496 386.82c34.5361 0 62.6094 -28.1797 62.5039 -62.7148v-264.316c0 -34.5361 -28.0732 -62.6094 -62.6094 -62.6094h-386.781c-34.5361 0 -62.6094 28.0732 -62.6094 62.6094v264.422c0 34.5361 28.0732 62.6094 62.6094 62.6094h386.887zM483.503 59.7891
h-0.106445v264.316c0 18.751 -15.2549 34.0059 -34.0059 34.0059h-386.781c-18.751 0 -34.0059 -15.2549 -34.0059 -34.0059v-264.316c0 -18.751 15.2549 -34.0059 34.0059 -34.0059h386.887c18.751 0 34.0068 15.2549 34.0068 34.0059zM322.9 195.284l125.748 -119.393
c5.7207 -5.40332 5.93262 -14.5137 0.530273 -20.2344c-2.86035 -2.96582 -6.56836 -4.44922 -10.3818 -4.44922c-3.49609 0 -7.09766 1.27148 -9.85254 3.91992l-127.338 120.981l-35.7012 -31.9932c-2.64844 -2.43652 -6.14453 -3.70801 -9.53418 -3.70801
c-3.39062 0 -6.78027 1.16504 -9.53516 3.60156l-34.7471 31.04l-128.08 -119.816c-2.75391 -2.54199 -6.25 -3.81348 -9.74609 -3.81348c-3.81348 0 -7.62793 1.58887 -10.4883 4.55566c-5.40234 5.82617 -5.08496 14.8311 0.635742 20.2334l126.173 118.122
l-126.702 113.248c-5.93262 5.29688 -6.46191 14.3018 -1.16504 20.2334c5.29688 5.93262 14.3018 6.46289 20.2344 1.16602l137.084 -122.677c0.847656 -0.635742 1.58887 -1.27148 2.33008 -2.0127c0 -0.106445 0.106445 -0.211914 0.211914 -0.318359l33.6885 -30.0859
l172.68 154.987c5.93262 5.29688 14.9375 4.76758 20.2344 -1.05957c5.29688 -5.93164 4.7666 -14.9365 -1.05957 -20.2334z" />
<glyph glyph-name="uniF111" unicode="&#xf111;"
d="M480.354 190.193c0.985352 -5.36719 25.0801 -131.861 -34.9365 -204.801c-26.9414 -32.7461 -66.1494 -49.3926 -116.527 -49.3926v0h-1.75293c-36.9072 0.328125 -75.6777 11.1709 -115.323 32.1982c-31.1035 16.5371 -62.8633 39.4268 -94.4053 68.0117
c-53.2256 48.4072 -87.1768 95.9385 -88.6006 97.9092c-4.05176 5.69531 -3.61426 13.3613 1.0957 18.5088c16.7559 18.3994 36.5791 27.8174 58.9209 27.8174c33.0742 0 59.7969 -21.5752 63.6299 -24.8604l19.7139 -15.2227l1.20508 182.349v74.6914
c0 0.547852 0.109375 0.985352 0.109375 1.5332c1.42383 27.5986 23.875 49.0645 51.6924 49.0645c28.1465 0 51.1455 -22.7803 51.6934 -50.8164l15.4414 -126.933c4.27148 -0.985352 8.76172 -1.86133 13.3613 -2.84766
c35.7031 -7.44727 84.5488 -17.7422 167.783 -67.3535c3.61426 -2.19043 6.13281 -5.69531 6.89941 -9.85645zM422.528 4.01074c44.2461 53.7744 33.623 148.727 29.8994 174.135c-75.3496 44.0273 -118.28 53.1172 -152.889 60.3447
c-8.65234 1.75293 -16.7559 3.50488 -24.6416 5.58594c-5.80469 1.5332 -10.0762 6.46191 -10.8428 12.4854l-16.5371 137.884c-0.109375 0.547852 -0.109375 1.20508 -0.109375 1.75195c0 12.1572 -9.85645 22.123 -22.123 22.123
c-12.0469 0 -21.7939 -9.41895 -22.1221 -21.4658v-0.4375v-73.9258l-1.42383 -212.247c-0.109375 -5.58496 -3.28613 -10.7324 -8.32324 -13.1426c-5.14746 -2.51855 -11.0615 -1.86133 -15.5518 1.53418l-43.6982 33.6221
c-0.21875 0.21875 -0.547852 0.4375 -0.766602 0.657227c-0.109375 0.21875 -20.6992 17.8516 -44.4648 17.8516c-10.2949 0 -19.6035 -3.28613 -28.2559 -9.9668c12.7041 -16.4277 40.4121 -49.9404 77.1016 -83.2344c45.998 -41.7266 116.309 -91.5576 189.577 -92.1045
h1.5332c41.0693 0 72.6104 12.9229 93.6377 38.5498z" />
<glyph glyph-name="uniF11D" unicode="&#xf11d;"
d="M419.897 224.875c15.2705 0 27.6787 -12.4082 27.6787 -27.6787v-178.267c0 -45.707 -37.2236 -82.9297 -82.9297 -82.9297h-217.293c-45.7061 0 -82.9297 37.2227 -82.9297 82.9297v178.267c0 15.2715 12.4082 27.6787 27.6787 27.6787h291.632v66.7041
c0 70.5225 -57.3711 127.788 -127.787 127.788c-70.5225 0 -127.788 -57.3721 -127.788 -127.788c0 -7.95312 -6.36328 -14.3164 -14.3164 -14.3164c-7.9541 0 -14.3164 6.36328 -14.3164 14.3164c0 86.3232 70.2031 156.421 156.421 156.421
c86.3232 0 156.421 -70.2041 156.421 -156.421v-66.7041h7.5293zM418.942 18.9297v0v177.312h-325.779v-177.312c0 -30.0117 24.3916 -54.2969 54.2969 -54.2969h217.187c30.0117 0 54.2959 24.3916 54.2959 54.2969zM256.053 24.7627
c-29.5869 0 -53.5537 24.0723 -53.5537 53.5537s23.9668 53.5547 53.5537 53.5547c29.4814 0 53.5547 -23.9668 53.5547 -53.5547c0 -29.4814 -23.9668 -53.5537 -53.5547 -53.5537zM256.053 103.344c-13.7861 0 -24.9209 -11.3467 -24.9209 -25.0273
c0 -13.6797 11.1348 -24.9209 24.9209 -24.9209s24.9219 11.3467 24.9219 25.0273c0 13.6797 -11.1357 24.9209 -24.9219 24.9209z" />
<glyph glyph-name="uniF116" unicode="&#xf116;"
d="M270.635 226.041c5.62012 5.62109 14.7402 5.62109 20.2549 0c34.3594 -34.3594 34.3594 -90.3525 0 -124.712l-121.955 -121.955c-17.1797 -17.1797 -39.7676 -25.7695 -62.3564 -25.7695c-22.5879 0 -45.1768 8.58984 -62.3564 25.7695l-18.3457 18.3457
c-16.7559 16.6494 -25.876 38.8135 -25.876 62.3564s9.22656 45.707 25.876 62.3564l121.955 121.955c5.62012 5.62012 14.6348 5.62012 20.2549 0c5.62109 -5.62012 5.62109 -14.6348 0 -20.2549l-121.955 -121.955c-23.2246 -23.2246 -23.2246 -60.9785 0 -84.2021
l18.3467 -18.3467c23.2246 -23.2246 60.9775 -23.2246 84.2021 0l121.955 121.955c23.2246 23.2246 23.2246 60.9775 0 84.2021c-5.62109 5.62109 -5.62109 14.6348 0 20.2549zM486.23 386.28c16.6494 -16.6494 25.7695 -38.8135 25.7695 -62.3564
s-9.22656 -45.707 -25.876 -62.3564l-121.955 -121.955c-2.75684 -2.86328 -6.46875 -4.24219 -10.0742 -4.24219s-7.31738 1.48535 -10.0752 4.24219c-5.62012 5.62012 -5.62012 14.6348 0 20.2549l121.956 121.955c23.2236 23.2246 23.2236 60.9785 0 84.2021
l-18.3467 18.3467c-23.2246 23.2246 -60.9775 23.2246 -84.2021 0l-121.955 -121.955c-23.2246 -23.2246 -23.2246 -60.9775 0 -84.2021c5.62012 -5.62109 5.62012 -14.6348 0 -20.2549c-5.62109 -5.62109 -14.6348 -5.62109 -20.2559 0
c-34.3594 34.3594 -34.3594 90.3525 0 124.712l121.956 121.955c34.3594 34.3604 90.3525 34.3604 124.712 0z" />
<glyph glyph-name="uniF120" unicode="&#xf120;"
d="M504.441 288.794c4.72363 -2.41504 7.55859 -7.24414 7.55859 -12.4932v-166.922c0 -66.9795 -54.4863 -121.36 -121.36 -121.36c-7.87305 0 -14.1719 6.29883 -14.1719 14.1729s6.29883 14.1729 14.1719 14.1729c51.3369 0 93.0146 41.7832 93.0146 93.0146v157.999
c-17.1113 7.76855 -62.1494 26.1406 -126.504 37.2686c-5.56348 0.944336 -10.0781 5.14355 -11.3379 10.708l-5.24902 22.5713c-5.24902 22.3613 -24.8809 37.8984 -47.7666 37.8984h-73.5938c-22.9902 0 -42.5176 -15.6426 -47.7666 -37.8984l-5.24902 -22.5713
c-1.25977 -5.45898 -5.77441 -9.76367 -11.3379 -10.708c-64.5645 -11.1279 -109.497 -29.5 -126.504 -37.2686v-157.999c0 -51.3369 41.7822 -93.0146 93.0137 -93.0146c7.87402 0 14.1729 -6.29883 14.1729 -14.1729s-6.29883 -14.1729 -14.1729 -14.1729
c-66.9785 0 -121.359 54.4863 -121.359 121.36v166.922c0 5.24902 2.93945 10.0781 7.55859 12.4932c2.09961 1.15527 51.2314 26.7705 133.118 42.0977l3.14941 13.4385c8.29395 35.2734 39.2637 59.8398 75.377 59.8398h73.5938
c36.2188 0 67.1885 -24.6709 75.377 -59.8398l3.14941 -13.4385c81.8867 -15.4316 131.019 -40.9424 133.118 -42.0977zM46.4023 317.77c-5.87891 0 -11.4434 3.7793 -13.543 9.7627c-2.41406 7.34863 1.5752 15.4326 9.02832 17.8477
c15.748 5.14355 32.2305 9.7627 48.9219 13.752c7.55957 1.78516 15.2227 -2.83398 17.0078 -10.498c1.78418 -7.55859 -2.83496 -15.2227 -10.499 -17.0068c-15.957 -3.7793 -31.5996 -8.18848 -46.5068 -13.123c-1.46973 -0.524414 -2.93945 -0.734375 -4.40918 -0.734375
zM256.053 306.221c89.9697 0 163.143 -73.2773 163.143 -163.248c0 -89.9697 -73.1729 -163.143 -163.143 -163.143c-89.9707 0 -163.144 73.2783 -163.144 163.248s73.1729 163.143 163.144 163.143zM256.053 8.17578c74.3271 0 134.797 60.5742 134.797 134.902
s-60.4697 134.798 -134.797 134.798c-74.3281 0 -134.798 -60.5752 -134.798 -134.903c0 -74.3271 60.4697 -134.797 134.798 -134.797z" />
<glyph glyph-name="uniF108" unicode="&#xf108;"
d="M329.502 358.455c22.2295 0 40.3691 -18.1396 40.4736 -40.4736v-341.508c0 -22.334 -18.1396 -40.4736 -40.4736 -40.4736h-238.018c-22.333 0 -40.4727 18.1396 -40.4727 40.4736v341.508c0 22.334 18.1396 40.4736 40.4727 40.4736h238.018zM341.561 -23.4219v341.403
c0 6.71094 -5.45215 12.1631 -12.1631 12.1631h-238.018c-6.71094 0 -12.1631 -5.45215 -12.1631 -12.1631v-341.403c0 -6.70996 5.45215 -12.1631 12.1631 -12.1631h238.018c6.71094 0 12.1631 5.45312 12.1631 12.1631zM420.516 448
c22.333 0 40.4727 -18.1396 40.4727 -40.4736v-341.508c0 -22.334 -18.1396 -40.4736 -40.4727 -40.4736c-7.86426 0 -14.1553 6.29102 -14.1553 14.1553s6.29102 14.1553 14.1553 14.1553c6.70996 0 12.1631 5.45215 12.1631 12.1631v341.508
c0 6.71094 -5.45312 12.1631 -12.1631 12.1631h-238.018c-6.71094 0 -12.1631 -5.45215 -12.1631 -12.1631c0 -7.86426 -6.29199 -14.1553 -14.1553 -14.1553c-7.86426 0 -14.1553 6.29102 -14.1553 14.1553c0 22.334 18.1396 40.4736 40.4736 40.4736h238.018z" />
<glyph glyph-name="uniF123" unicode="&#xf123;"
d="M255.893 448c113.134 0 205.229 -92.0957 205.444 -205.229c0 -12.8799 -1.28809 -25.8682 -3.64941 -38.5342c-0.107422 -0.750977 -0.537109 -3.00488 -1.39551 -6.86914c-3.11328 -13.9541 -7.72852 -27.4785 -13.7393 -40.4668
c-22.1113 -52.0586 -70.8428 -132.024 -177.429 -217.681c-2.68359 -2.14648 -5.9043 -3.21973 -9.12402 -3.21973s-6.44043 1.07324 -9.12402 3.21973c-106.693 85.6562 -155.317 165.622 -177.429 217.681c-6.01074 12.9883 -10.626 26.6201 -13.7393 40.4668
c-0.858398 3.86426 -1.28809 6.11816 -1.39551 6.86914c-2.46875 12.666 -3.64941 25.6543 -3.64941 38.5342c0 113.134 92.0957 205.229 205.229 205.229zM428.921 209.281c2.14648 10.9482 3.21973 22.2188 3.21973 33.5967c0 97.1406 -79.1074 176.248 -176.248 176.248
s-176.248 -79.1074 -176.248 -176.248c0 -11.2705 1.07324 -22.4336 3.21973 -33.4893c0 -0.322266 0.107422 -0.537109 0.107422 -0.750977c0.107422 -0.537109 0.322266 -2.14746 0.96582 -4.61621v-0.214844c2.79102 -12.0215 6.7627 -23.8281 11.915 -34.9912
c0.107422 -0.107422 0.107422 -0.322266 0.214844 -0.429688c20.0723 -47.5508 64.0801 -120.11 159.825 -199.219c95.7451 79.1084 139.754 151.668 159.825 199.219c0.107422 0.107422 0.107422 0.322266 0.214844 0.429688
c5.25977 11.2705 9.23145 22.9697 11.915 34.9912v0.214844c0.536133 2.46875 0.858398 4.0791 0.96582 4.61621c0 0.213867 0.107422 0.428711 0.107422 0.643555zM255.893 370.824c71.8086 0 130.308 -58.499 130.308 -130.308s-58.499 -130.308 -130.308 -130.308
s-130.308 58.499 -130.308 130.308s58.499 130.308 130.308 130.308zM255.893 139.189c55.9229 0 101.327 45.5117 101.327 101.327s-45.5117 101.326 -101.327 101.326s-101.326 -45.5107 -101.326 -101.326s45.4033 -101.327 101.326 -101.327z" />
<glyph glyph-name="uniF10E" unicode="&#xf10e;"
d="M429.752 343.887c45.3271 0 82.248 -36.8145 82.248 -82.1406v-207.269c0 -45.3271 -36.9209 -82.248 -82.248 -82.248h-347.504c-45.3271 0 -82.248 36.9209 -82.248 82.248v275.045c0 45.3271 36.9209 82.248 82.248 82.248h158.324
c7.97949 0 14.3633 -6.38379 14.3633 -14.3643c0 -29.5791 24.0469 -53.5195 53.5195 -53.5195h121.297zM483.271 54.584v0v207.056c0 29.5791 -24.0459 53.5195 -53.5195 53.5195h-121.297c-40.4316 0 -74.1611 29.3662 -80.9707 67.8828h-145.236
c-29.5801 0 -53.5195 -24.0459 -53.5195 -53.5195v-274.938c0 -29.5801 24.0459 -53.5195 53.5195 -53.5195h347.504c29.5801 0 53.5195 24.0459 53.5195 53.5195z" />
<glyph glyph-name="uniF107" unicode="&#xf107;"
d="M436.857 372.986c48.3857 -48.3857 75.1221 -112.575 75.1221 -180.986s-26.6279 -132.601 -75.0137 -180.986c-49.3604 -49.3594 -114.957 -75.0137 -181.203 -75.0137c-41.2412 0 -82.8076 9.95898 -120.802 30.417
c-34.0967 -24.5713 -67.4365 -29.876 -90.0596 -29.876c-8.44336 0 -15.3711 0.757812 -20.2422 1.51562c-10.3916 1.62402 -18.4014 9.74219 -20.0254 20.1338c-1.62305 10.3916 3.68066 20.458 13.0977 25.1123c19.5928 9.63477 32.7988 28.9023 41.0254 45.4639
c-83.8906 100.992 -77.3955 250.695 16.1279 344.22c48.3857 48.3857 112.575 75.0137 180.986 75.0137s132.601 -26.6279 180.986 -75.0137zM416.183 31.6885c88.4365 88.4365 88.4365 232.187 -0.108398 320.623c-88.3281 88.4365 -232.187 88.4365 -320.622 0
c-84.6484 -84.6484 -88.7617 -221.037 -9.52637 -310.556c2.05664 -2.38184 3.24805 -5.30469 3.57227 -8.22656c0.757812 -3.03125 0.541016 -6.27832 -0.757812 -9.41797c-8.11816 -18.5098 -21.9736 -42.3232 -43.9473 -58.3438h0.108398
c19.0508 0 47.8438 4.7627 77.1787 27.4941c0.324219 0.325195 0.757812 0.649414 1.19043 0.866211c4.65527 4.6543 11.9072 5.73633 17.7529 2.27246c88.9775 -52.2822 202.094 -37.7773 275.159 35.2881zM237.902 192c0 11.9792 5.98958 17.9688 17.9688 17.9688
c11.9792 0 17.9688 -5.98958 17.9688 -17.9688s-5.98959 -17.9688 -17.9688 -17.9688c-11.9792 0 -17.9688 5.98958 -17.9688 17.9688zM330.561 192c0 11.9792 5.98959 17.9688 17.9688 17.9688s17.9688 -5.98958 17.9688 -17.9688s-5.98959 -17.9688 -17.9688 -17.9688
s-17.9688 5.98958 -17.9688 17.9688zM145.244 192c0 11.9792 5.98958 17.9688 17.9688 17.9688s17.9688 -5.98958 17.9688 -17.9688s-5.98958 -17.9688 -17.9688 -17.9688s-17.9688 5.98958 -17.9688 17.9688z" />
<glyph glyph-name="uniF115" unicode="&#xf115;"
d="M489.917 99.9219c2.03418 -5.46094 3.96191 -12.8486 4.38965 -21.5215c0.75 -14.9893 -2.99805 -36.2959 -24.3047 -55.5684c1.39258 -9.84961 1.39258 -25.375 -7.28027 -41.0068c-11.3486 -20.4502 -33.7266 -34.3691 -66.4893 -41.3281
c-13.9189 -2.99805 -30.6211 -4.49707 -49.7871 -4.49707c-11.1348 0 -23.126 0.535156 -35.8672 1.49902c-70.3438 -1.3916 -138.867 7.28027 -142.722 7.81641l-25.1611 2.99707c-7.28125 -5.13867 -16.168 -8.13672 -25.6963 -8.13672h-65.3115
c-24.5186 0 -44.541 19.915 -44.541 44.54v173.986c0 24.5186 19.915 44.54 44.541 44.54h65.3115c7.70898 0 14.8818 -1.92773 21.1992 -5.35352c4.49707 9.85059 11.1348 21.0928 20.9854 31.4785c0.535156 0.535156 1.07031 1.07031 1.71289 1.49902
c33.5127 25.8027 73.1279 118.738 87.2607 156.641c-1.49902 11.7773 -2.89062 37.2598 8.99414 51.0713c5.24609 6.10254 12.6338 9.42188 20.6641 9.42188c8.35059 0 36.7236 -1.92773 54.4971 -26.4463c16.917 -23.3408 17.7734 -58.2451 2.78418 -103.855
c-12.6338 -38.3311 -13.8115 -55.3545 -13.3838 -62.207c5.78223 1.28516 11.4561 2.46289 16.8096 3.42676h0.107422c0.963867 0.213867 24.5186 5.45996 52.6777 6.74512c40.043 1.92676 68.9521 -5.03223 85.9756 -20.5576
c23.2334 -21.1992 20.5566 -48.502 13.5977 -65.8467c5.99609 -7.81543 13.2764 -20.5566 13.9189 -36.8311c0.642578 -14.7754 -4.38965 -29.123 -14.8828 -42.5059zM51.6875 -30.916v0.107422h65.3115c8.56543 0 15.6318 6.95898 15.6318 15.6318v173.985
c0 8.56543 -6.95996 15.6318 -15.6318 15.6318h-65.3115c-8.56543 0 -15.6318 -6.95898 -15.6318 -15.6318v-174.093c0 -8.56543 6.95898 -15.6318 15.6318 -15.6318zM462.508 112.448c9.31445 9.63672 13.8115 19.3799 13.3838 28.9082
c-0.536133 14.2402 -11.7783 24.8398 -11.8848 24.9473c-5.35352 4.81836 -6.53125 12.6338 -2.56934 18.6299c1.3916 2.24805 13.7041 23.2334 -3.64062 39.0801c-20.4502 18.6299 -79.3379 14.2402 -112.208 7.06641
c-0.427734 -0.107422 -0.962891 -0.213867 -1.3916 -0.321289c-5.88867 -0.963867 -12.0986 -2.24902 -18.6299 -3.74707c-6.85254 -2.46289 -16.2744 -2.1416 -23.3408 4.1748c-14.2402 12.6348 -12.7412 41.3291 5.24609 95.8262
c11.7783 35.7607 12.2061 62.7422 1.28516 77.8389c-9.20801 12.7412 -24.5186 14.3467 -30.1934 14.4541c-2.56934 -4.38965 -3.21191 -19.0576 -1.3916 -31.0498c0.427734 -2.46289 0.213867 -4.9248 -0.642578 -7.17383
c-2.03418 -5.56738 -49.0371 -134.156 -97.1104 -172.272c-12.6348 -13.5977 -17.9883 -29.5508 -19.915 -36.7246c1.28516 -4.1748 2.03418 -8.67188 2.03418 -13.2764v-174.093c0 -3.21191 -0.321289 -6.42383 -0.963867 -9.42188l10.8145 -1.28516h0.106445
c0.75 -0.106445 70.3438 -9.10059 139.189 -7.60156h1.49805c32.335 -2.56934 58.6738 -1.82031 78.0527 2.35547c24.1982 5.13965 40.1514 14.3477 47.3242 27.1953c7.49512 13.3838 2.8916 27.5166 2.8916 27.624c-2.1416 5.99512 -0.107422 12.6338 4.9248 16.4883
c14.1328 10.5996 20.8779 23.0195 20.1289 37.0459c-0.535156 10.2783 -4.92578 17.7734 -4.92578 17.8799c-3.42578 5.56738 -2.56934 12.7412 1.92773 17.4521z" />
<glyph glyph-name="uniF126" unicode="&#xf126;"
d="M485.793 245.746c15.2939 -2.56738 26.418 -15.7227 26.2041 -31.2314v-45.0283c0 -15.5088 -11.124 -28.6641 -26.418 -31.2314l-35.9375 -5.98926c-3.63672 -11.9785 -8.55664 -23.6367 -14.4385 -34.8672l21.1768 -29.7334
c8.98438 -12.6211 7.4873 -29.8408 -3.42285 -40.751l-31.8721 -31.8721c-5.99023 -5.99023 -13.9043 -9.30566 -22.3545 -9.30566v0c-6.63086 0 -13.0479 2.03223 -18.3965 5.88281l-29.626 21.1768c-11.124 -5.88184 -22.7822 -10.6953 -34.8682 -14.4385
l-6.0957 -35.9375c-2.56738 -15.2949 -15.7227 -26.418 -31.2314 -26.418h-45.1357c-15.5078 0 -28.6641 11.124 -31.2305 26.418l-5.99023 35.9375c-11.6582 3.63672 -22.9951 8.23535 -33.7979 13.9043l-30.0547 -21.3916
c-5.34766 -3.85059 -11.6572 -5.88184 -18.2891 -5.88184c-8.44922 0 -16.3643 3.31543 -22.3535 9.30469l-31.873 31.873c-11.0166 10.9092 -12.4062 28.1289 -3.42285 40.75l21.0703 29.3057c-5.98926 11.123 -10.9092 22.7812 -14.6523 34.7607l-36.3652 6.20312
c-15.2949 2.56738 -26.418 15.7227 -26.418 31.2314v45.0283c0 15.5088 11.123 28.6641 26.418 31.2305l35.5098 5.99023c3.63574 12.0859 8.3418 23.7441 14.2246 34.9746l-21.3906 30.0537c-8.98438 12.6211 -7.4873 29.8408 3.42188 40.751l31.9805 31.8721
c5.88184 5.99023 13.9033 9.30566 22.3535 9.30566c6.63086 0 13.0488 -2.03223 18.3965 -5.88281l29.4121 -21.1768c11.124 5.98926 22.7822 10.9092 34.7607 14.6523l6.2041 36.3652c2.56641 15.2949 15.7217 26.418 31.2305 26.418h45.0283
c15.5088 0 28.6641 -11.123 31.2314 -26.418l5.98926 -35.5098c12.5137 -3.74316 24.5996 -8.77051 36.1514 -14.8662l29.7334 21.1768c5.34766 3.85059 11.6582 5.88281 18.2891 5.88281c8.4502 0 16.3643 -3.31543 22.3545 -9.30566l31.8721 -31.8721
c11.0166 -10.9102 12.4072 -28.1299 3.42285 -40.75l-21.1768 -29.627c5.88184 -11.124 10.6953 -22.7822 14.4385 -34.8682zM483.333 169.486h0.106445v45.0283c0 1.39062 -0.962891 2.56738 -2.35254 2.78125l-44.9219 7.59375
c-5.56152 0.855469 -10.1611 5.02637 -11.5508 10.5889c-4.06445 15.7217 -10.2676 30.8027 -18.6104 44.8145c-2.8877 4.91992 -2.67383 11.123 0.641602 15.7217l26.418 37.1143c0.749023 1.17578 0.641602 2.67383 -0.321289 3.63574l-31.8721 31.873
c-0.749023 0.749023 -1.60449 0.855469 -2.03223 0.855469c-0.535156 0 -1.06934 -0.213867 -1.60449 -0.53418l-36.8994 -26.5254c-4.70605 -3.31543 -10.8027 -3.5293 -15.7227 -0.641602c-14.332 8.44922 -29.7334 14.7598 -45.8838 18.8242
c-5.5625 1.39062 -9.7334 5.98926 -10.6963 11.6582l-7.48633 44.6006c-0.213867 1.39062 -1.39062 2.35254 -2.78125 2.35254h-45.0283c-1.39062 0 -2.56641 -0.961914 -2.78027 -2.35254l-7.70117 -45.2422c-0.962891 -5.56152 -5.13379 -10.0537 -10.5889 -11.5518
c-15.7227 -4.06348 -30.8027 -10.4814 -44.707 -18.8242c-4.91992 -2.8877 -11.124 -2.67383 -15.8301 0.641602l-36.8994 26.3115c-0.427734 0.321289 -0.962891 0.535156 -1.60449 0.535156c-0.53418 0 -1.2832 -0.107422 -2.03223 -0.856445l-31.8721 -31.8721
c-0.962891 -0.962891 -1.07031 -2.56738 -0.321289 -3.63672l26.6318 -37.4346c3.31543 -4.59863 3.63672 -10.6953 0.749023 -15.6152c-8.23535 -14.0117 -14.332 -29.1992 -18.2891 -44.9219c-1.39062 -5.56152 -5.99023 -9.73242 -11.6582 -10.6953l-44.6006 -7.4873
c-1.39062 -0.213867 -2.35352 -1.38965 -2.35352 -2.78027v-45.0283c0 -1.39062 0.962891 -2.56738 2.35352 -2.78125l45.2422 -7.7002c5.56152 -0.962891 10.0537 -5.13477 11.5508 -10.5889c4.06445 -15.7227 10.4814 -30.8037 18.8242 -44.708
c2.8877 -4.91992 2.67383 -11.123 -0.641602 -15.8291l-26.3115 -36.8994c-0.748047 -1.17676 -0.641602 -2.67383 0.321289 -3.63672l31.873 -31.873c0.748047 -0.748047 1.60449 -0.855469 2.03223 -0.855469c0.53418 0 1.06934 0.213867 1.60352 0.535156
l37.4346 26.6318c2.45996 1.81836 5.45508 2.67383 8.34277 2.67383c2.45996 0 5.02734 -0.641602 7.27344 -1.9248c13.7969 -7.91504 28.5566 -14.0117 43.8516 -17.9688c5.45508 -1.39062 9.62598 -5.88281 10.5889 -11.5518l7.48633 -44.9209
c0.213867 -1.39062 1.39062 -2.35352 2.78125 -2.35352h45.0283c1.39062 0 2.56641 0.962891 2.78027 2.35352l7.59473 44.9209c0.855469 5.56152 5.02637 10.1611 10.5879 11.5518c15.7227 4.06445 30.8037 10.2676 44.8145 18.6104
c4.91992 2.8877 11.124 2.67383 15.7227 -0.641602l37.1143 -26.4189c0.427734 -0.320312 0.961914 -0.53418 1.60352 -0.53418c0.535156 0 1.28418 0.106445 2.03223 0.855469l31.873 31.873c0.962891 0.961914 1.06934 2.56641 0.321289 3.63574l-26.4189 37.2207
c-3.31543 4.70605 -3.5293 10.8027 -0.641602 15.7227c8.34277 14.0117 14.5459 29.0918 18.6104 44.8145c1.39062 5.45508 5.88281 9.62598 11.5518 10.5889l44.9209 7.48633c1.39062 0.214844 2.35352 1.39062 2.35352 2.78125zM256.052 302.539
c60.9648 0 110.485 -49.5205 110.485 -110.485s-49.5205 -110.485 -110.485 -110.485s-110.485 49.5205 -110.485 110.485s49.5205 110.485 110.485 110.485zM256.052 110.447c45.0283 0 81.6074 36.5781 81.6074 81.6064s-36.5791 81.6074 -81.6074 81.6074
s-81.6074 -36.5791 -81.6074 -81.6074s36.5791 -81.6064 81.6074 -81.6064z" />
<glyph glyph-name="uniF10C" unicode="&#xf10c;"
d="M478.836 414.833c21.3818 -21.2725 33.1641 -49.6367 33.1641 -79.8555c0 -30.2178 -11.7822 -58.582 -33.1641 -79.9639l-293.02 -292.91c-2.72754 -2.83691 -6.54492 -4.36328 -10.3633 -4.36328s-7.63672 1.63574 -10.3643 4.36328l-118.037 118.037
l-15.1631 -112.146l77.6729 10.4727c8.07324 1.09082 15.4912 -4.58105 16.582 -12.6543c1.09082 -8.07227 -4.58203 -15.4912 -12.6543 -16.582l-96.874 -13.0908c-0.654297 -0.109375 -1.30859 -0.109375 -1.96289 -0.109375
c-3.81836 0 -7.63672 1.63672 -10.3643 4.36426c-3.16309 3.16309 -4.7998 7.74512 -4.14551 12.3271l21.71 160.801c0.435547 3.16309 1.85449 6.10938 4.14551 8.39941l293.02 292.911c44.0723 44.1816 115.746 44.1816 159.818 0zM324.69 378.833l-267.384 -267.384
l49.2002 -49.2002l267.384 267.384zM175.562 -6.69629l267.384 267.383l-48.1094 48.1094l-267.384 -267.383zM463.454 281.741c12.3271 14.9453 19.0908 33.4912 19.0908 53.2363c0 22.3643 -8.61816 43.4189 -24.4365 59.1279
c-15.8174 15.709 -36.7637 24.4365 -59.127 24.4365c-19.6367 0 -38.291 -6.76367 -53.2373 -19.0908z" />
<glyph glyph-name="uniF112" unicode="&#xf112;"
d="M512 189.698c0 -0.986328 -0.109375 -1.97266 -0.329102 -2.95898c0.219727 -0.985352 0.329102 -1.97168 0.329102 -2.95801v-121.206c0 -68.4932 -55.7812 -124.273 -124.274 -124.273c-8.21875 0 -14.7939 6.5752 -14.7939 14.7939v218.85
c0 8.21875 6.5752 14.7949 14.7939 14.7949c37.918 0 71.8906 -16.9863 94.6855 -43.8359v40.877c0 0.986328 0.109375 1.97266 0.328125 2.95898c-0.21875 0.985352 -0.328125 1.97168 -0.328125 2.95801c0 124.822 -101.589 226.411 -226.411 226.411
s-226.411 -101.589 -226.411 -226.411v-40.876c22.7949 26.8486 56.7676 43.835 94.6855 43.835c8.21875 0 14.7939 -6.5752 14.7939 -14.7939v-218.85c0 -8.21875 -6.5752 -14.7949 -14.7939 -14.7949c-68.4932 0 -124.274 55.7812 -124.274 124.274v121.205
c0 68.3838 26.6299 132.713 74.959 181.041c48.3291 48.3291 112.657 74.959 181.041 74.959s132.712 -26.6299 181.041 -74.959c48.3291 -48.3281 74.959 -112.657 74.959 -181.041zM109.37 161.973c-45.1514 -7.12305 -79.7812 -46.3564 -79.8906 -93.4795
c0 -47.123 34.7393 -86.3564 79.8906 -93.4795v186.959zM402.63 -30.9043c45.1514 7.12305 79.7812 46.2471 79.8906 93.4795c0 47.123 -34.7393 86.3564 -79.8906 93.4795v-186.959z" />
<glyph glyph-name="uniF12F" unicode="&#xf12f;"
d="M416.401 305.023c55.1689 -14.5293 95.4932 -67.0664 95.5986 -126.132c0 -32.6377 -12.1074 -64.0127 -34.3232 -88.4385c-22.3203 -24.6367 -52.3262 -39.377 -84.4385 -41.6934h-1.05273h-78.1211c-7.89648 0 -14.2129 6.31738 -14.2129 14.2139
s6.31641 14.2129 14.2129 14.2129h77.5947c50.7471 3.89551 91.9141 49.3789 91.9141 101.705c0 49.3789 -35.376 92.6514 -82.2275 100.652c-6.31738 1.05371 -11.1602 6.21191 -11.792 12.6348c-5.89551 61.6963 -57.1699 108.232 -119.288 108.232
c-46.2197 0 -88.7549 -26.9531 -108.443 -68.751c-3.1582 -6.63281 -10.8438 -9.79102 -17.793 -7.26465c-5.68555 2.10547 -11.792 3.1582 -18.0029 3.1582c-29.0586 0 -52.748 -23.6885 -52.748 -52.7471c0 -6.42285 1.05273 -12.4238 3.1582 -18.1094
c2.31641 -6.52734 -0.209961 -13.792 -6.21191 -17.2666c-25.7939 -15.0557 -41.7979 -42.8506 -41.7979 -72.6465c0 -46.1143 36.2178 -87.0703 79.2793 -89.7021h90.124c7.89648 0 14.2139 -6.31738 14.2139 -14.2139s-6.31738 -14.2139 -14.2139 -14.2139h-90.5449
h-0.841797c-28.6377 1.58008 -55.4854 15.1611 -75.8057 38.1133c-19.793 22.4258 -30.6377 50.7471 -30.6377 79.9111c0 36.1133 17.583 70.0146 46.5361 91.0713c-1.05273 5.47559 -1.68457 11.0557 -1.68457 16.8457c0 44.7461 36.4287 81.1748 81.1748 81.1748
c5.26367 0 10.5283 -0.526367 15.582 -1.47363c11.6865 20.4248 28.2158 37.7969 48.1152 50.6416c23.8994 15.4766 51.7998 23.6895 80.4375 23.6895c37.0596 0 72.6465 -13.793 100.021 -38.8506c24.6367 -22.5312 40.7451 -52.2207 46.2197 -84.7539zM341.333 153.202
c-5.58008 -5.58008 -14.5293 -5.58008 -20.0039 0l-51.0635 51.0635v-234.68c0 -7.89648 -6.31641 -14.2139 -14.2129 -14.2139s-14.2139 6.31738 -14.2139 14.2139v234.68l-51.0625 -51.0635c-2.7373 -2.84277 -6.42285 -4.21094 -10.002 -4.21094
c-3.58008 0 -7.26465 1.47363 -10.0029 4.21094c-5.58008 5.58008 -5.58008 14.5293 0 20.1094l75.2793 75.2793c2.63184 2.7373 6.31641 4.21094 10.002 4.21094c3.68457 0 7.37012 -1.5791 10.002 -4.21094l75.2783 -75.2793
c5.58008 -5.58008 5.58008 -14.5293 0 -20.1094z" />
<glyph glyph-name="uniF130" unicode="&#xf130;"
d="M497.331 197.922c8.14941 0 14.6689 -6.62793 14.6689 -14.6689v-133.433c0 -52.374 -42.5938 -95.0762 -95.0762 -95.0762h-321.848c-52.373 0 -95.0762 42.5938 -95.0762 95.0762v135.605c0 8.14941 6.51953 14.6689 14.6689 14.6689
s14.6689 -6.51953 14.6689 -14.6689v-135.605c0 -36.1836 29.4463 -65.7393 65.7383 -65.7393h321.848c36.1836 0 65.7383 29.4473 65.7383 65.7393v133.433c0 8.14941 6.51953 14.6689 14.6689 14.6689zM173.094 310.927c-5.65039 -5.65039 -14.9951 -5.65039 -20.6455 0
c-5.75879 5.75879 -5.75879 14.9951 0 20.7539l93.2295 93.2295c2.71582 2.8252 6.41016 4.34668 10.3223 4.34668c3.80273 0 7.60645 -1.62988 10.3223 -4.34668l93.2295 -93.2295c5.75879 -5.75879 5.75879 -14.9951 0 -20.7539
c-2.8252 -2.93359 -6.51953 -4.3457 -10.3223 -4.3457c-3.69434 0 -7.49805 1.52051 -10.3232 4.3457l-68.2373 68.2383v-297.616c0 -8.14941 -6.51953 -14.6689 -14.6689 -14.6689s-14.6689 6.51953 -14.6689 14.6689v297.616z" />
<glyph glyph-name="uniF125" unicode="&#xf125;"
d="M117.261 346.469c-55.5166 -50.1846 -78.9365 -126.611 -61.3721 -199.379c1.88184 -7.52832 -2.82227 -15.1602 -10.3506 -17.042c-1.14941 -0.313477 -2.19531 -0.418945 -3.3457 -0.418945c-6.37695 0 -12.1279 4.28711 -13.6963 10.7695
c-20.0732 82.8047 6.69141 169.791 69.8408 226.98c45.8984 41.6123 105.388 62.8359 167.282 59.6992c49.9756 -2.50879 96.71 -20.4922 134.662 -51.5439l-2.19531 57.8174c-0.313477 7.84082 5.75 14.3232 13.5918 14.6367
c7.73633 0.313477 14.3232 -5.75 14.6367 -13.5918l3.4502 -92.2139v0v-0.104492v-0.523438v-1.04492c0 -7.63281 -5.95898 -13.8008 -13.5908 -14.1143l-93.2607 -3.4502h-0.522461c-7.52734 0 -13.8008 5.95898 -14.1143 13.5918
c-0.313477 7.84082 5.75 14.3232 13.5918 14.6367l60.7441 2.2998c-76.6357 62.418 -189.761 61.3721 -265.352 -7.00488zM483.505 243.591c20.0732 -82.9102 -6.69141 -169.792 -69.8408 -226.981c-43.0752 -38.8936 -97.8604 -60.0127 -155.468 -60.0127
c-3.86816 0 -7.8418 0.104492 -11.8145 0.313477c-49.6621 2.50977 -96.1875 20.3877 -134.14 51.126l5.01855 -56.5625c0.731445 -7.8418 -5.01855 -14.6377 -12.7549 -15.3691c-0.418945 -0.104492 -0.836914 -0.104492 -1.25488 -0.104492
c-7.21387 0 -13.3828 5.54102 -14.0098 12.8594l-8.36426 92.9463c-0.732422 7.8418 5.01855 14.6377 12.7549 15.3691l92.9463 8.36426c7.8418 0.732422 14.6377 -4.91406 15.3691 -12.7549c0.732422 -7.8418 -5.01855 -14.6377 -12.7549 -15.3691l-61.4766 -5.54102
c37.4297 -31.1562 83.8506 -46.9443 130.376 -46.9443c48.7207 0 97.6504 17.3555 136.648 52.5898c55.5166 50.1846 79.041 126.611 61.3721 199.38c-1.88184 7.52734 2.82227 15.1592 10.3506 17.042c7.52734 1.88184 15.1602 -2.82324 17.042 -10.3506z" />
<glyph glyph-name="uniF11C" unicode="&#xf11c;"
d="M387.058 202.207c5.58008 -5.58008 5.58008 -14.8076 -0.107422 -20.3867l-241.528 -241.528c-2.78906 -2.89746 -6.54492 -4.29199 -10.1934 -4.29199c-3.64746 0 -7.40332 1.50195 -10.1934 4.29199c-5.68652 5.68652 -5.68652 14.8066 0 20.4941l231.228 231.228
l-231.228 231.228c-5.68652 5.68652 -5.68652 14.8066 0 20.4941c5.6875 5.68652 14.8076 5.68652 20.4941 0z" />
<glyph glyph-name="uniF100" unicode="&#xf100;"
d="M254.145 172.12c-31.0654 0.530273 -56.1934 11.4512 -74.6426 32.3379c-40.8203 46.0156 -34.0342 124.898 -33.292 132.427c2.65039 56.9355 29.793 83.9727 52.0586 96.5898c22.1602 12.6172 45.2734 14.5254 57.042 14.5254h0.636719h0.423828h0.742188
c21.417 -0.423828 40.6084 -5.51367 57.042 -14.9492c22.0537 -12.6172 48.7725 -39.6543 51.4229 -96.166c0.742188 -7.52832 7.52734 -86.4111 -33.292 -132.427c-18.5547 -20.8867 -43.6826 -31.8076 -74.749 -32.3379h-1.05957h-0.636719h-1.69629zM174.519 334.234
c-0.105469 -0.636719 -7.31543 -73.0527 26.2949 -110.798c13.2529 -15.0557 31.1719 -22.4775 54.4971 -22.6895h0.530273v0h0.530273c23.4316 0.211914 41.2441 7.63379 54.6035 22.6895c33.7158 37.958 26.2949 110.056 26.1885 110.798
c-0.106445 0.211914 -0.106445 0.529297 -0.106445 0.847656c-3.28613 71.8857 -51.9521 83.5488 -80.5791 84.1846h-0.848633h-0.423828c-23.1143 0 -77.0811 -8.16406 -80.5801 -84.1846c0 -0.211914 -0.106445 -0.530273 -0.106445 -0.847656zM473.725 41.2842
c0.106445 -5.51367 0.211914 -33.8223 -5.08887 -47.9238c-1.06055 -2.75684 -3.0752 -5.08984 -5.51367 -6.67969c-3.18066 -2.01465 -79.4141 -50.6807 -206.963 -50.6807c-127.55 0 -203.782 48.5596 -206.963 50.6807
c-2.54492 1.58984 -4.45312 3.92285 -5.51367 6.67969c-5.61914 14.207 -5.51367 42.5166 -5.40723 48.0293v0.318359c0.106445 0.848633 0.106445 1.69629 0.106445 2.65039c0.635742 20.8877 2.01367 69.9775 48.0293 85.7754
c0.318359 0.106445 0.636719 0.211914 1.06055 0.318359c48.0293 12.1924 87.5771 39.7598 88.002 40.0771c6.4668 4.55957 15.373 2.96875 19.9326 -3.49805c4.55859 -6.46777 2.96875 -15.374 -3.49902 -19.9336c-1.80273 -1.16602 -44.001 -30.6416 -96.8018 -44.2129
c-24.7041 -8.7998 -27.4609 -35.2002 -28.2031 -59.374c0 -0.954102 -0.105469 -1.80273 -0.105469 -2.65137c-0.212891 -9.54199 0.529297 -24.2793 2.22656 -32.7617c17.2822 -9.75391 85.0322 -43.5762 187.029 -43.5762c102.422 0 169.748 33.7158 186.924 43.4707
c1.69727 8.48145 2.33301 23.2188 2.22656 32.7617c-0.105469 0.847656 -0.105469 1.69629 -0.105469 2.65039c-0.742188 24.1738 -3.49902 50.5742 -28.2031 59.375c-52.8008 13.5713 -94.999 42.9404 -96.8018 44.2129
c-6.46777 4.55859 -8.05762 13.4648 -3.49902 19.9326c4.55957 6.46777 13.4658 8.05762 19.9326 3.49902c0.424805 -0.318359 40.1846 -27.8848 88.002 -40.0781c0.318359 -0.105469 0.742188 -0.211914 1.06055 -0.318359
c46.0156 -15.6914 47.3936 -64.7812 48.0293 -85.7744c0 -0.954102 0.106445 -1.80273 0.106445 -2.65137v-0.317383z" />
<glyph glyph-name="uniF117" unicode="&#xf117;"
d="M247.328 -34.6621c8.04102 0 14.6689 -6.51953 14.6689 -14.6689s-6.51953 -14.6689 -14.6689 -14.6689h-133.542c-52.373 0 -95.0762 42.5938 -95.0762 95.0762v321.848c0 52.373 42.5938 95.0762 95.0762 95.0762h135.715c8.14941 0 14.6689 -6.51953 14.6689 -14.6689
s-6.51953 -14.6689 -14.6689 -14.6689h-135.715c-36.1836 0 -65.7383 -29.4463 -65.7383 -65.7383v-321.848c0 -36.1836 29.4463 -65.7383 65.7383 -65.7383h133.542zM488.985 202.322c5.75879 -5.65039 5.75879 -14.9941 -0.109375 -20.8623l-93.2285 -93.2295
c-2.8252 -2.93359 -6.62891 -4.3457 -10.3232 -4.3457s-7.49707 1.52148 -10.3223 4.3457c-5.75879 5.75977 -5.75879 14.9951 0 20.7539l68.2373 68.2383h-297.616c-8.14941 0 -14.6689 6.51953 -14.6689 14.6689s6.51953 14.6689 14.6689 14.6689h297.616
l-68.2373 68.2373c-5.75879 5.75879 -5.75879 14.9951 0 20.7539s14.9951 5.75879 20.7539 0z" />
<glyph glyph-name="uniF11F" unicode="&#xf11f;"
d="M404.642 130.834l59.3252 -59.542c10.9141 -10.8057 16.6416 -23.665 16.6416 -37.0645c0 -13.292 -5.61914 -26.2588 -16.3174 -37.3896c-4.64648 -4.8623 -9.40137 -9.50879 -14.0479 -13.9395c-6.80762 -6.5918 -13.292 -12.751 -18.9111 -19.5586
c-0.107422 -0.216797 -0.324219 -0.324219 -0.431641 -0.541016c-16.4258 -17.8301 -37.3896 -26.7988 -62.3516 -26.7988c-2.16113 0 -4.53809 0.108398 -6.80762 0.21582c-37.0654 2.37793 -70.2402 16.3174 -95.0938 28.0957
c-65.0527 31.4463 -122.001 76.0752 -169.332 132.592c-39.0098 46.8984 -65.1611 90.6631 -82.667 137.886c-7.13184 19.2344 -16.4248 48.627 -13.8311 80.1816c1.72852 19.5586 9.61719 36.416 23.4492 50.248l36.7402 37.0645
c0.108398 0.108398 0.108398 0.108398 0.21582 0.216797c10.8066 10.374 23.5576 15.8848 36.7412 15.8848s25.7188 -5.51074 36.2002 -15.8848c7.02441 -6.48438 13.832 -13.3994 20.3154 -20.0996c3.24219 -3.45801 6.7002 -6.91602 10.0498 -10.2656l29.501 -29.501
c22.6924 -22.6924 22.6924 -52.085 0 -74.7783c-3.02539 -3.02539 -6.05176 -6.15918 -9.07715 -9.18457c-7.99707 -8.10547 -16.209 -16.5342 -25.0703 -24.6387c6.5918 -14.5879 15.6689 -28.96 29.1768 -46.0342c27.8799 -34.2549 56.9482 -60.7295 88.9346 -81.0449
c3.13281 -1.94531 6.80762 -3.78223 10.6973 -5.72754c2.70215 -1.40527 5.51172 -2.80957 8.32129 -4.32227l34.1475 34.0391c10.9141 10.9141 23.665 16.6416 36.9561 16.6416c13.4004 0 26.043 -5.83594 36.5254 -16.75zM443.327 17.0459
c11.0225 11.4541 11.0225 22.8008 -0.108398 33.8232l-59.541 59.541l-0.216797 0.216797c-3.34961 3.67383 -8.86035 7.88867 -15.5605 7.88867c-6.80762 0 -12.6426 -4.43066 -16.3174 -8.10547l-36.957 -36.9561c-1.83691 -1.83789 -7.45605 -7.45703 -16.4248 -7.45703
c-3.56641 0 -7.02441 0.865234 -10.6982 2.70215c-0.431641 0.21582 -0.864258 0.540039 -1.29688 0.755859c-3.13379 1.94531 -6.80762 3.78223 -10.6982 5.72754c-4.32227 2.16113 -8.86035 4.43066 -13.291 7.24023c-34.6875 21.9365 -66.1338 50.5723 -95.958 87.3135
l-0.108398 0.107422c-16.4248 20.8564 -27.2314 38.3623 -34.9033 56.9482c-0.108398 0.324219 -0.216797 0.648438 -0.324219 0.972656c-1.94531 6.16016 -3.6748 15.2373 5.61914 24.5303c0.107422 0.21582 0.324219 0.324219 0.540039 0.540039
c9.40137 8.42871 18.0459 17.0742 27.123 26.3672c3.13379 3.02539 6.16016 6.15918 9.29395 9.29297c11.4541 11.4551 11.4541 22.0449 0 33.499l-29.501 29.501c-3.45801 3.56543 -6.91602 7.02344 -10.2656 10.4814c-6.5918 6.80859 -12.8594 13.1836 -19.4512 19.3428
c-0.108398 0.108398 -0.21582 0.216797 -0.324219 0.324219c-3.45801 3.45801 -8.96875 7.56445 -15.7773 7.56445c-5.51074 0 -11.1299 -2.70117 -16.4248 -7.67188l-36.8486 -36.8486c-8.96973 -8.96973 -13.832 -19.4512 -14.9131 -32.0947
c-1.62109 -19.7754 2.05371 -40.7393 12.1035 -67.6465c16.3164 -44.0889 40.9551 -85.1514 77.8037 -129.564c44.6289 -53.2744 98.3359 -95.3105 159.498 -124.919c22.3691 -10.6982 52.1934 -23.2334 84.2881 -25.2861
c1.62012 -0.108398 3.34961 -0.108398 4.9707 -0.108398c16.8574 0 29.7168 5.51074 40.6309 17.1816c6.69922 7.88867 13.832 14.9121 20.8555 21.6123c4.64648 4.53906 8.96973 8.64453 13.1836 13.1836c-0.108398 -0.108398 -0.108398 0 0 0zM276.697 326.209
c-7.99707 1.29688 -13.1836 8.96875 -11.7793 16.8574c1.40527 7.88867 8.86133 13.1836 16.8574 11.8867c34.2559 -5.83594 65.4854 -22.0449 90.2314 -46.791s40.9551 -55.9756 46.79 -90.2305c1.29688 -7.99707 -3.99805 -15.5605 -11.9941 -16.8574
c-0.864258 -0.108398 -1.62109 -0.216797 -2.48535 -0.216797c-7.02441 0 -13.1836 4.9707 -14.3721 12.1035c-4.86328 28.3115 -18.1543 54.0303 -38.6865 74.5615c-20.5312 20.5322 -46.25 33.9316 -74.5615 38.6865zM511.405 222.146
c1.40527 -7.8877 -3.99805 -15.4521 -11.8867 -16.6416c-0.864258 -0.107422 -1.62012 -0.21582 -2.48535 -0.21582c-7.02344 0 -13.1826 4.9707 -14.3721 12.1025c-8.53613 50.3574 -32.3096 96.2832 -68.835 132.808c-36.5244 36.417 -82.4502 60.2979 -132.807 68.835
c-7.99707 1.4043 -13.292 8.86133 -11.9951 16.8574c1.40527 7.88867 8.86133 13.292 16.75 11.8867c56.4072 -9.61719 107.736 -36.2002 148.584 -77.0479c40.8467 -40.8467 67.4297 -92.1758 77.0469 -148.584z" />
<glyph glyph-name="uniF110" unicode="&#xf110;"
d="M504.439 137.178c4.93359 -12.2803 7.66211 -25.7158 7.55762 -39.7803c0 -58.4629 -47.6523 -106.115 -106.115 -106.115c-53.0049 0 -97.0879 39.1504 -104.855 90.0566h-89.7412c-6.92676 -51.8506 -51.4307 -91.9453 -105.17 -91.9453
c-58.4629 0 -106.115 47.6514 -106.115 106.114c0 13.96 2.83398 27.3955 7.76758 39.6758c0.104492 0.629883 0.208984 1.1543 0.418945 1.78418l67.5947 215.483c9.65625 31.3838 42.9287 49.1221 74.3125 39.5703c7.45215 -2.2041 11.7549 -10.1816 9.44629 -17.6338
c-2.2041 -7.45215 -10.1816 -11.7549 -17.6338 -9.44629c-16.4785 5.03809 -33.9023 -4.30273 -38.9404 -20.7822v-0.104492l-49.1211 -156.182c15.4287 8.71191 33.2725 13.75 52.2705 13.75c53.7393 0 98.2432 -40.0947 105.17 -91.9453h89.2168
c6.08789 52.6904 51.0107 93.835 105.38 93.835c18.8926 0 36.6309 -4.93359 52.0605 -13.6445l-48.4922 154.187v0.104492c-5.03809 16.4795 -22.4609 25.8203 -38.9404 20.7822c-7.45215 -2.30859 -15.3242 1.99414 -17.6328 9.44629
c-2.30957 7.45215 1.99414 15.3242 9.44629 17.6338c31.3828 9.55176 64.7607 -8.18652 74.3125 -39.5703zM106.22 17.7324c42.8242 0 77.7754 34.8467 77.7754 77.7754c0 42.9297 -34.8467 77.7764 -77.7754 77.7764s-77.7754 -34.8467 -77.7754 -77.7764
c0 -42.9287 34.8467 -77.7754 77.7754 -77.7754zM405.987 19.6221c42.9287 0 77.7754 34.8467 77.7754 77.7754s-34.9521 77.7754 -77.7754 77.7754c-42.8242 0 -77.7764 -34.8467 -77.7764 -77.7754s34.8467 -77.7754 77.7764 -77.7754z" />
</font>
</defs></svg>

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.
Binary file not shown.
-139
View File
@@ -1,139 +0,0 @@
/*
Flaticon icon font: Flaticon
Creation date: 22/06/2016 15:35
*/
@font-face {
font-family: "Flaticon";
src: url("./Flaticon.eot");
src: url("./Flaticon.eot?#iefix") format("embedded-opentype"),
url("./Flaticon.woff") format("woff"),
url("./Flaticon.ttf") format("truetype"),
url("./Flaticon.svg#Flaticon") format("svg");
font-weight: normal;
font-style: normal;
}
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: "Flaticon";
src: url("./Flaticon.svg#Flaticon") format("svg");
}
}
.fi:before{
display: inline-block;
font-family: "Flaticon";
font-style: normal;
font-weight: normal;
font-variant: normal;
line-height: 1;
text-decoration: inherit;
text-rendering: optimizeLegibility;
text-transform: none;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-smoothing: antialiased;
}
.flaticon-avatar:before { content: "\f100"; }
.flaticon-avatar-1:before { content: "\f101"; }
.flaticon-back:before { content: "\f102"; }
.flaticon-book:before { content: "\f103"; }
.flaticon-cancel:before { content: "\f104"; }
.flaticon-chat:before { content: "\f105"; }
.flaticon-chat-1:before { content: "\f106"; }
.flaticon-chat-2:before { content: "\f107"; }
.flaticon-copy:before { content: "\f108"; }
.flaticon-dislike:before { content: "\f109"; }
.flaticon-download:before { content: "\f10a"; }
.flaticon-download-1:before { content: "\f10b"; }
.flaticon-edit:before { content: "\f10c"; }
.flaticon-envelope:before { content: "\f10d"; }
.flaticon-folder:before { content: "\f10e"; }
.flaticon-garbage:before { content: "\f10f"; }
.flaticon-glasses:before { content: "\f110"; }
.flaticon-hand:before { content: "\f111"; }
.flaticon-headphones:before { content: "\f112"; }
.flaticon-heart:before { content: "\f113"; }
.flaticon-house:before { content: "\f114"; }
.flaticon-like:before { content: "\f115"; }
.flaticon-link:before { content: "\f116"; }
.flaticon-logout:before { content: "\f117"; }
.flaticon-magnifying-glass:before { content: "\f118"; }
.flaticon-monitor:before { content: "\f119"; }
.flaticon-musical-note:before { content: "\f11a"; }
.flaticon-next:before { content: "\f11b"; }
.flaticon-next-1:before { content: "\f11c"; }
.flaticon-padlock:before { content: "\f11d"; }
.flaticon-paper-plane:before { content: "\f11e"; }
.flaticon-phone-call:before { content: "\f11f"; }
.flaticon-photo-camera:before { content: "\f120"; }
.flaticon-pie-chart:before { content: "\f121"; }
.flaticon-piggy-bank:before { content: "\f122"; }
.flaticon-placeholder:before { content: "\f123"; }
.flaticon-printer:before { content: "\f124"; }
.flaticon-reload:before { content: "\f125"; }
.flaticon-settings:before { content: "\f126"; }
.flaticon-settings-1:before { content: "\f127"; }
.flaticon-share:before { content: "\f128"; }
.flaticon-shopping-bag:before { content: "\f129"; }
.flaticon-shopping-cart:before { content: "\f12a"; }
.flaticon-shuffle:before { content: "\f12b"; }
.flaticon-speaker:before { content: "\f12c"; }
.flaticon-star:before { content: "\f12d"; }
.flaticon-tag:before { content: "\f12e"; }
.flaticon-upload:before { content: "\f12f"; }
.flaticon-upload-1:before { content: "\f130"; }
.flaticon-vector:before { content: "\f131"; }
$font-Flaticon-avatar: "\f100";
$font-Flaticon-avatar-1: "\f101";
$font-Flaticon-back: "\f102";
$font-Flaticon-book: "\f103";
$font-Flaticon-cancel: "\f104";
$font-Flaticon-chat: "\f105";
$font-Flaticon-chat-1: "\f106";
$font-Flaticon-chat-2: "\f107";
$font-Flaticon-copy: "\f108";
$font-Flaticon-dislike: "\f109";
$font-Flaticon-download: "\f10a";
$font-Flaticon-download-1: "\f10b";
$font-Flaticon-edit: "\f10c";
$font-Flaticon-envelope: "\f10d";
$font-Flaticon-folder: "\f10e";
$font-Flaticon-garbage: "\f10f";
$font-Flaticon-glasses: "\f110";
$font-Flaticon-hand: "\f111";
$font-Flaticon-headphones: "\f112";
$font-Flaticon-heart: "\f113";
$font-Flaticon-house: "\f114";
$font-Flaticon-like: "\f115";
$font-Flaticon-link: "\f116";
$font-Flaticon-logout: "\f117";
$font-Flaticon-magnifying-glass: "\f118";
$font-Flaticon-monitor: "\f119";
$font-Flaticon-musical-note: "\f11a";
$font-Flaticon-next: "\f11b";
$font-Flaticon-next-1: "\f11c";
$font-Flaticon-padlock: "\f11d";
$font-Flaticon-paper-plane: "\f11e";
$font-Flaticon-phone-call: "\f11f";
$font-Flaticon-photo-camera: "\f120";
$font-Flaticon-pie-chart: "\f121";
$font-Flaticon-piggy-bank: "\f122";
$font-Flaticon-placeholder: "\f123";
$font-Flaticon-printer: "\f124";
$font-Flaticon-reload: "\f125";
$font-Flaticon-settings: "\f126";
$font-Flaticon-settings-1: "\f127";
$font-Flaticon-share: "\f128";
$font-Flaticon-shopping-bag: "\f129";
$font-Flaticon-shopping-cart: "\f12a";
$font-Flaticon-shuffle: "\f12b";
$font-Flaticon-speaker: "\f12c";
$font-Flaticon-star: "\f12d";
$font-Flaticon-tag: "\f12e";
$font-Flaticon-upload: "\f12f";
$font-Flaticon-upload-1: "\f130";
$font-Flaticon-vector: "\f131";
-81
View File
@@ -1,81 +0,0 @@
/*
Flaticon icon font: Flaticon
Creation date: 22/06/2016 15:35
*/
@font-face {
font-family: "Flaticon";
src: url("./Flaticon.eot");
src: url("./Flaticon.eot?#iefix") format("embedded-opentype"),
url("./Flaticon.woff") format("woff"),
url("./Flaticon.ttf") format("truetype"),
url("./Flaticon.svg#Flaticon") format("svg");
font-weight: normal;
font-style: normal;
}
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: "Flaticon";
src: url("./Flaticon.svg#Flaticon") format("svg");
}
}
[class^="flaticon-"]:before, [class*=" flaticon-"]:before,
[class^="flaticon-"]:after, [class*=" flaticon-"]:after {
font-family: Flaticon;
font-size: 20px;
font-style: normal;
margin-left: 20px;
}
.flaticon-avatar:before { content: "\f100"; }
.flaticon-avatar-1:before { content: "\f101"; }
.flaticon-back:before { content: "\f102"; }
.flaticon-book:before { content: "\f103"; }
.flaticon-cancel:before { content: "\f104"; }
.flaticon-chat:before { content: "\f105"; }
.flaticon-chat-1:before { content: "\f106"; }
.flaticon-chat-2:before { content: "\f107"; }
.flaticon-copy:before { content: "\f108"; }
.flaticon-dislike:before { content: "\f109"; }
.flaticon-download:before { content: "\f10a"; }
.flaticon-download-1:before { content: "\f10b"; }
.flaticon-edit:before { content: "\f10c"; }
.flaticon-envelope:before { content: "\f10d"; }
.flaticon-folder:before { content: "\f10e"; }
.flaticon-garbage:before { content: "\f10f"; }
.flaticon-glasses:before { content: "\f110"; }
.flaticon-hand:before { content: "\f111"; }
.flaticon-headphones:before { content: "\f112"; }
.flaticon-heart:before { content: "\f113"; }
.flaticon-house:before { content: "\f114"; }
.flaticon-like:before { content: "\f115"; }
.flaticon-link:before { content: "\f116"; }
.flaticon-logout:before { content: "\f117"; }
.flaticon-magnifying-glass:before { content: "\f118"; }
.flaticon-monitor:before { content: "\f119"; }
.flaticon-musical-note:before { content: "\f11a"; }
.flaticon-next:before { content: "\f11b"; }
.flaticon-next-1:before { content: "\f11c"; }
.flaticon-padlock:before { content: "\f11d"; }
.flaticon-paper-plane:before { content: "\f11e"; }
.flaticon-phone-call:before { content: "\f11f"; }
.flaticon-photo-camera:before { content: "\f120"; }
.flaticon-pie-chart:before { content: "\f121"; }
.flaticon-piggy-bank:before { content: "\f122"; }
.flaticon-placeholder:before { content: "\f123"; }
.flaticon-printer:before { content: "\f124"; }
.flaticon-reload:before { content: "\f125"; }
.flaticon-settings:before { content: "\f126"; }
.flaticon-settings-1:before { content: "\f127"; }
.flaticon-share:before { content: "\f128"; }
.flaticon-shopping-bag:before { content: "\f129"; }
.flaticon-shopping-cart:before { content: "\f12a"; }
.flaticon-shuffle:before { content: "\f12b"; }
.flaticon-speaker:before { content: "\f12c"; }
.flaticon-star:before { content: "\f12d"; }
.flaticon-tag:before { content: "\f12e"; }
.flaticon-upload:before { content: "\f12f"; }
.flaticon-upload-1:before { content: "\f130"; }
.flaticon-vector:before { content: "\f131"; }
-705
View File
@@ -1,705 +0,0 @@
<!DOCTYPE html>
<!--
Flaticon icon font: Flaticon
Creation date: 22/06/2016 15:35
-->
<html>
<!DOCTYPE html>
<html>
<head>
<title>Flaticon WebFont</title>
<link href="http://fonts.googleapis.com/css?family=Varela+Round" rel="stylesheet" type="text/css" />
<link rel="stylesheet" type="text/css" href="flaticon.css">
<meta charset="UTF-8">
<style>
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
body {
font-family: 'Varela Round', Helvetica, Arial, sans-serif;
font-size: 16px;
color: #222;
}
a {
color: #333;
border-bottom: 1px solid #a9fd00;
font-weight: bold;
text-decoration: none;
}
* {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
margin: 0;
padding: 0;
}
[class^="flaticon-"]:before, [class*=" flaticon-"]:before, [class^="flaticon-"]:after, [class*=" flaticon-"]:after {
font-family: Flaticon;
font-size: 30px;
font-style: normal;
margin-left: 20px;
color: #333;
}
.wrapper {
max-width: 600px;
margin: auto;
padding: 0 1em;
}
.title {
font-size: 1.25em;
text-align: center;
margin-bottom: 1em;
text-transform: uppercase;
}
header {
text-align: center;
background-color: #222;
color: #fff;
padding: 1em;
}
header .logo {
width: 210px;
height: 38px;
display: inline-block;
vertical-align: middle;
margin-right: 1em;
border: none;
}
header strong {
font-size: 1.95em;
font-weight: bold;
vertical-align: middle;
margin-top: 5px;
display: inline-block;
}
.demo {
margin: 2em auto;
line-height: 1.25em;
}
.demo ul li {
margin-bottom: 1em;
}
.demo ul li .num {
color: #222;
border-radius: 20px;
display: inline-block;
width: 26px;
padding: 3px;
height: 26px;
text-align: center;
margin-right: 0.5em;
border: 1px solid #222;
}
.demo ul li code {
background-color: #222;
border-radius: 4px;
padding: 0.25em 0.5em;
display: inline-block;
color: #fff;
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
font-weight: lighter;
margin-top: 1em;
font-size: 0.8em;
word-break: break-all;
}
.demo ul li code.big {
padding: 1em;
font-size: 0.9em;
}
.demo ul li code .red {
color: #EF3159;
}
.demo ul li code .green {
color: #ACFF65;
}
.demo ul li code .yellow {
color: #FFFF99;
}
.demo ul li code .blue {
color: #99D3FF;
}
.demo ul li code .purple {
color: #A295FF;
}
.demo ul li code .dots {
margin-top: 0.5em;
display: block;
}
#glyphs {
border-bottom: 1px solid #ccc;
padding: 2em 0;
text-align: center;
}
.glyph {
display: inline-block;
width: 9em;
margin: 1em;
text-align: center;
vertical-align: top;
background: #FFF;
}
.glyph .glyph-icon {
padding: 10px;
display: block;
font-family:"Flaticon";
font-size: 64px;
line-height: 1;
}
.glyph .glyph-icon:before {
font-size: 64px;
color: #222;
margin-left: 0;
}
.class-name {
font-size: 0.65em;
background-color: #222;
color: #fff;
border-radius: 4px 4px 0 0;
padding: 0.5em;
color: #FFFF99;
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
}
.author-name {
font-size: 0.6em;
background-color: #fcfcfd;
border: 1px solid #DEDEE4;
border-top: 0;
border-radius: 0 0 4px 4px;
padding: 0.5em;
}
.class-name:last-child {
font-size: 10px;
color:#888;
}
.class-name:last-child a {
font-size: 10px;
color:#555;
}
.class-name:last-child a:hover {
color:#a9fd00;
}
.glyph > input {
display: block;
width: 100px;
margin: 5px auto;
text-align: center;
font-size: 12px;
cursor: text;
}
.glyph > input.icon-input {
font-family:"Flaticon";
font-size: 16px;
margin-bottom: 10px;
}
.attribution .title {
margin-top: 2em;
}
.attribution textarea {
background-color: #fcfcfd;
padding: 1em;
border: none;
box-shadow: none;
border: 1px solid #DEDEE4;
border-radius: 4px;
resize: none;
width: 100%;
height: 150px;
font-size: 0.8em;
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
-webkit-appearance: none;
}
.iconsuse {
margin: 2em auto;
text-align: center;
max-width: 1200px;
}
.iconsuse:after {
content: '';
display: table;
clear: both;
}
.iconsuse .image {
float: left;
width: 25%;
padding: 0 1em;
}
.iconsuse .image p {
margin-bottom: 1em;
}
.iconsuse .image span {
display: block;
font-size: 0.65em;
background-color: #222;
color: #fff;
border-radius: 4px;
padding: 0.5em;
color: #FFFF99;
margin-top: 1em;
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
}
#footer {
text-align: center;
background-color: #4C5B5C;
color: #7c9192;
padding: 1em;
}
#footer a {
border: none;
color: #a9fd00;
font-weight: normal;
}
@media (max-width: 960px) {
.iconsuse .image {
width: 50%;
}
}
@media (max-width: 560px) {
.iconsuse .image {
width: 100%;
}
}
</style>
</head>
<body class="characters-off">
<header>
<a href="http://www.flaticon.com" target="_blank" class="logo">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/" viewBox="0 0 560.875 102.036" enable-background="new 0 0 560.875 102.036" xml:space="preserve">
<defs>
</defs>
<g>
<g class="letters">
<path fill="#ffffff" d="M141.596,29.675c0-3.777,2.985-6.767,6.764-6.767h34.438c3.426,0,6.15,2.728,6.15,6.15
c0,3.43-2.724,6.149-6.15,6.149h-27.674v13.091h23.719c3.429,0,6.151,2.724,6.151,6.15c0,3.43-2.723,6.149-6.151,6.149h-23.719
v17.574c0,3.773-2.986,6.761-6.764,6.761c-3.779,0-6.764-2.989-6.764-6.761V29.675z"></path>
<path fill="#ffffff" d="M193.844,29.149c0-3.781,2.985-6.767,6.764-6.767c3.776,0,6.763,2.985,6.763,6.767v42.957h25.039
c3.426,0,6.149,2.726,6.149,6.153c0,3.425-2.723,6.15-6.149,6.15h-31.802c-3.779,0-6.764-2.986-6.764-6.768V29.149z"></path>
<path fill="#ffffff" d="M241.891,75.71l21.438-48.407c1.492-3.341,4.215-5.357,7.906-5.357h0.792
c3.686,0,6.323,2.017,7.815,5.357l21.439,48.407c0.436,0.967,0.701,1.845,0.701,2.723c0,3.602-2.809,6.501-6.414,6.501
c-3.161,0-5.269-1.845-6.499-4.655l-4.132-9.661h-27.059l-4.301,10.102c-1.144,2.631-3.426,4.214-6.237,4.214
c-3.517,0-6.24-2.81-6.24-6.325C241.1,77.64,241.451,76.677,241.891,75.71z M279.932,58.666l-8.521-20.297l-8.526,20.297H279.932
z"></path>
<path fill="#ffffff" d="M314.864,35.387H301.86c-3.429,0-6.239-2.813-6.239-6.238c0-3.429,2.811-6.24,6.239-6.24h39.533
c3.426,0,6.237,2.811,6.237,6.24c0,3.425-2.811,6.238-6.237,6.238h-13.001v42.785c0,3.773-2.99,6.761-6.764,6.761
c-3.779,0-6.764-2.989-6.764-6.761V35.387z"></path>
<path fill="#A9FD00" d="M352.615,29.149c0-3.781,2.985-6.767,6.767-6.767c3.774,0,6.761,2.985,6.761,6.767v49.024
c0,3.773-2.987,6.761-6.761,6.761c-3.781,0-6.767-2.989-6.767-6.761V29.149z"></path>
<path fill="#A9FD00" d="M374.132,53.836v-0.179c0-17.481,13.178-31.801,32.065-31.801c9.22,0,15.459,2.458,20.557,6.238
c1.402,1.054,2.637,2.985,2.637,5.357c0,3.692-2.985,6.59-6.681,6.59c-1.845,0-3.071-0.702-4.044-1.319
c-3.776-2.813-7.729-4.393-12.562-4.393c-10.364,0-17.831,8.611-17.831,19.154v0.173c0,10.542,7.291,19.329,17.831,19.329
c5.715,0,9.492-1.756,13.359-4.834c1.049-0.874,2.458-1.491,4.039-1.491c3.429,0,6.325,2.813,6.325,6.236
c0,2.106-1.056,3.78-2.282,4.834c-5.539,4.834-12.036,7.733-21.878,7.733C387.572,85.464,374.132,71.493,374.132,53.836z"></path>
<path fill="#A9FD00" d="M433.009,53.836v-0.179c0-17.481,13.79-31.801,32.766-31.801c18.981,0,32.592,14.143,32.592,31.628v0.173
c0,17.483-13.785,31.807-32.769,31.807C446.625,85.464,433.009,71.32,433.009,53.836z M484.224,53.836v-0.179
c0-10.539-7.725-19.326-18.626-19.326c-10.893,0-18.449,8.611-18.449,19.154v0.173c0,10.542,7.73,19.329,18.626,19.329
C476.676,72.986,484.224,64.378,484.224,53.836z"></path>
<path fill="#A9FD00" d="M506.233,29.321c0-3.774,2.99-6.763,6.767-6.763h1.401c3.252,0,5.183,1.583,7.029,3.953l26.093,34.265
V29.059c0-3.692,2.99-6.677,6.681-6.677c3.683,0,6.671,2.985,6.671,6.677v48.934c0,3.78-2.987,6.765-6.764,6.765h-0.436
c-3.257,0-5.188-1.581-7.034-3.953l-27.056-35.492v32.944c0,3.687-2.985,6.676-6.678,6.676c-3.683,0-6.673-2.989-6.673-6.676
V29.321z"></path>
</g>
<g class="insignia">
<path fill="#ffffff" d="M48.372,56.137h12.517l11.156-18.537H37.186L25.688,18.539h57.825L94.668,0H9.271
C5.925,0,2.842,1.801,1.198,4.716c-1.644,2.907-1.593,6.482,0.134,9.343l50.38,83.501c1.678,2.781,4.689,4.476,7.938,4.476
c3.246,0,6.257-1.695,7.935-4.476l2.898-4.804L48.372,56.137z"></path>
<g class="i">
<path fill="#A9FD00" d="M93.575,18.539h0.031v0.004l21.652,0.004l2.705-4.488c1.727-2.861,1.778-6.436,0.133-9.343
C116.454,1.801,113.371,0,110.026,0h-5.294L93.575,18.539z"></path>
<polygon fill="#A9FD00" points="88.291,27.356 64.725,66.486 75.519,84.404 109.942,27.356"></polygon>
</g>
</g>
</g>
</svg>
</a>
<strong>Font Demo</strong>
</header>
<section class="demo wrapper">
<p class="title">Instructions</p>
<ul>
<li>
<span class="num">1</span>Copy the "Fonts" files and CSS files to your website CSS folder.
</li>
<li>
<span class="num">2</span>Add the CSS link to your website source code on header.
<code class="big">
&lt;<span class="red">head</span>&gt;
<br/><span class="dots">...</span>
<br/>&lt;<span class="red">link</span> <span class="green">rel</span>=<span class="yellow">"stylesheet"</span> <span class="green">type</span>=<span class="yellow">"text/css"</span> <span class="green">href</span>=<span class="yellow">"your_website_domain/css_root/flaticon.css"</span>&gt;
<br/><span class="dots">...</span>
<br/>&lt;/<span class="red">head</span>&gt;
</code>
</li>
<li>
<p>
<span class="num">3</span>Use the icon class on <code>"<span class="blue">display</span>:<span class="purple"> inline</span>"</code> elements:
<br />
Use example: <code>&lt;<span class="red">i</span> <span class="green">class</span>=<span class="yellow">&quot;flaticon-airplane49&quot;</span>&gt;&lt;/<span class="red">i</span>&gt;</code> or <code>&lt;<span class="red">span</span> <span class="green">class</span>=<span class="yellow">&quot;flaticon-airplane49&quot;</span>&gt;&lt;/<span class="red">span</span>&gt;</code>
</li>
</ul>
</section>
<section id="glyphs">
<div class="glyph"><div class="glyph-icon flaticon-avatar"></div>
<div class="class-name">.flaticon-avatar</div>
<div class="author-name">Author: <a data-file="avatar" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-avatar-1"></div>
<div class="class-name">.flaticon-avatar-1</div>
<div class="author-name">Author: <a data-file="avatar-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-back"></div>
<div class="class-name">.flaticon-back</div>
<div class="author-name">Author: <a data-file="back" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-book"></div>
<div class="class-name">.flaticon-book</div>
<div class="author-name">Author: <a data-file="book" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-cancel"></div>
<div class="class-name">.flaticon-cancel</div>
<div class="author-name">Author: <a data-file="cancel" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-chat"></div>
<div class="class-name">.flaticon-chat</div>
<div class="author-name">Author: <a data-file="chat" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-chat-1"></div>
<div class="class-name">.flaticon-chat-1</div>
<div class="author-name">Author: <a data-file="chat-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-chat-2"></div>
<div class="class-name">.flaticon-chat-2</div>
<div class="author-name">Author: <a data-file="chat-2" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-copy"></div>
<div class="class-name">.flaticon-copy</div>
<div class="author-name">Author: <a data-file="copy" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-dislike"></div>
<div class="class-name">.flaticon-dislike</div>
<div class="author-name">Author: <a data-file="dislike" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-download"></div>
<div class="class-name">.flaticon-download</div>
<div class="author-name">Author: <a data-file="download" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-download-1"></div>
<div class="class-name">.flaticon-download-1</div>
<div class="author-name">Author: <a data-file="download-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-edit"></div>
<div class="class-name">.flaticon-edit</div>
<div class="author-name">Author: <a data-file="edit" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-envelope"></div>
<div class="class-name">.flaticon-envelope</div>
<div class="author-name">Author: <a data-file="envelope" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-folder"></div>
<div class="class-name">.flaticon-folder</div>
<div class="author-name">Author: <a data-file="folder" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-garbage"></div>
<div class="class-name">.flaticon-garbage</div>
<div class="author-name">Author: <a data-file="garbage" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-glasses"></div>
<div class="class-name">.flaticon-glasses</div>
<div class="author-name">Author: <a data-file="glasses" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-hand"></div>
<div class="class-name">.flaticon-hand</div>
<div class="author-name">Author: <a data-file="hand" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-headphones"></div>
<div class="class-name">.flaticon-headphones</div>
<div class="author-name">Author: <a data-file="headphones" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-heart"></div>
<div class="class-name">.flaticon-heart</div>
<div class="author-name">Author: <a data-file="heart" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-house"></div>
<div class="class-name">.flaticon-house</div>
<div class="author-name">Author: <a data-file="house" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-like"></div>
<div class="class-name">.flaticon-like</div>
<div class="author-name">Author: <a data-file="like" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-link"></div>
<div class="class-name">.flaticon-link</div>
<div class="author-name">Author: <a data-file="link" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-logout"></div>
<div class="class-name">.flaticon-logout</div>
<div class="author-name">Author: <a data-file="logout" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-magnifying-glass"></div>
<div class="class-name">.flaticon-magnifying-glass</div>
<div class="author-name">Author: <a data-file="magnifying-glass" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-monitor"></div>
<div class="class-name">.flaticon-monitor</div>
<div class="author-name">Author: <a data-file="monitor" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-musical-note"></div>
<div class="class-name">.flaticon-musical-note</div>
<div class="author-name">Author: <a data-file="musical-note" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-next"></div>
<div class="class-name">.flaticon-next</div>
<div class="author-name">Author: <a data-file="next" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-next-1"></div>
<div class="class-name">.flaticon-next-1</div>
<div class="author-name">Author: <a data-file="next-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-padlock"></div>
<div class="class-name">.flaticon-padlock</div>
<div class="author-name">Author: <a data-file="padlock" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-paper-plane"></div>
<div class="class-name">.flaticon-paper-plane</div>
<div class="author-name">Author: <a data-file="paper-plane" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-phone-call"></div>
<div class="class-name">.flaticon-phone-call</div>
<div class="author-name">Author: <a data-file="phone-call" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-photo-camera"></div>
<div class="class-name">.flaticon-photo-camera</div>
<div class="author-name">Author: <a data-file="photo-camera" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-pie-chart"></div>
<div class="class-name">.flaticon-pie-chart</div>
<div class="author-name">Author: <a data-file="pie-chart" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-piggy-bank"></div>
<div class="class-name">.flaticon-piggy-bank</div>
<div class="author-name">Author: <a data-file="piggy-bank" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-placeholder"></div>
<div class="class-name">.flaticon-placeholder</div>
<div class="author-name">Author: <a data-file="placeholder" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-printer"></div>
<div class="class-name">.flaticon-printer</div>
<div class="author-name">Author: <a data-file="printer" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-reload"></div>
<div class="class-name">.flaticon-reload</div>
<div class="author-name">Author: <a data-file="reload" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-settings"></div>
<div class="class-name">.flaticon-settings</div>
<div class="author-name">Author: <a data-file="settings" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-settings-1"></div>
<div class="class-name">.flaticon-settings-1</div>
<div class="author-name">Author: <a data-file="settings-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-share"></div>
<div class="class-name">.flaticon-share</div>
<div class="author-name">Author: <a data-file="share" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-shopping-bag"></div>
<div class="class-name">.flaticon-shopping-bag</div>
<div class="author-name">Author: <a data-file="shopping-bag" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-shopping-cart"></div>
<div class="class-name">.flaticon-shopping-cart</div>
<div class="author-name">Author: <a data-file="shopping-cart" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-shuffle"></div>
<div class="class-name">.flaticon-shuffle</div>
<div class="author-name">Author: <a data-file="shuffle" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-speaker"></div>
<div class="class-name">.flaticon-speaker</div>
<div class="author-name">Author: <a data-file="speaker" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-star"></div>
<div class="class-name">.flaticon-star</div>
<div class="author-name">Author: <a data-file="star" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-tag"></div>
<div class="class-name">.flaticon-tag</div>
<div class="author-name">Author: <a data-file="tag" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-upload"></div>
<div class="class-name">.flaticon-upload</div>
<div class="author-name">Author: <a data-file="upload" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-upload-1"></div>
<div class="class-name">.flaticon-upload-1</div>
<div class="author-name">Author: <a data-file="upload-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
<div class="glyph"><div class="glyph-icon flaticon-vector"></div>
<div class="class-name">.flaticon-vector</div>
<div class="author-name">Author: <a data-file="vector" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
</div>
</section>
<section class="attribution wrapper" style="text-align:center;">
<div class="title">License and attribution:</div><div class="attrDiv">Font generated by <a href="http://www.flaticon.com">flaticon.com</a>. <div><p>Under <a href="http://creativecommons.org/licenses/by/3.0/">CC</a>: <a data-file="envelope" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a></p> </div>
</div>
<div class="title">Copy the Attribution License:</div>
<textarea onclick="this.focus();this.select();">Font generated by &lt;a href=&quot;http://www.flaticon.com&quot;&gt;flaticon.com&lt;/a&gt;. <p>Under <a href="http://creativecommons.org/licenses/by/3.0/">CC</a>: <a data-file="envelope" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a></p>
</textarea>
</section>
<section class="iconsuse">
<div class="title">Examples:</div>
<div class="image">
<p>
<i class="glyph-icon flaticon-avatar"></i>
<span>&lt;i class=&quot;flaticon-avatar&quot;&gt;&lt;/i&gt;</span>
</p>
</div>
<div class="image">
<p>
<i class="glyph-icon flaticon-avatar-1"></i>
<span>&lt;i class=&quot;flaticon-avatar-1&quot;&gt;&lt;/i&gt;</span>
</p>
</div>
<div class="image">
<p>
<i class="glyph-icon flaticon-back"></i>
<span>&lt;i class=&quot;flaticon-back&quot;&gt;&lt;/i&gt;</span>
</p>
</div>
<div class="image">
<p>
<i class="glyph-icon flaticon-book"></i>
<span>&lt;i class=&quot;flaticon-book&quot;&gt;&lt;/i&gt;</span>
</p>
</div>
</div>
</section>
<div id="footer">
<div>Generated by <a href="http://www.flaticon.com">flaticon.com</a>
</div>
</div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-33
View File
@@ -1,33 +0,0 @@
{
"theme_color": "#276890",
"background_color": "#276890",
"display": "browser",
"scope": "/",
"start_url": "/",
"lang": "fr",
"name": "decp.info",
"short_name": "decp.info",
"description": "Outil d'exploration et de t\u00e9l\u00e9chargemetn des donn\u00e9es de la commande publique.",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/static/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
@@ -1,16 +0,0 @@
<script>
document.body.addEventListener('click', (ev) => {
/* Close any open details elements that this click is outside of */
var target = ev.target;
var detailsClickedWithin = null;
while (target && target.tagName != 'DETAILS') {
target = target.parentNode;
}
if (target && target.tagName == 'DETAILS') {
detailsClickedWithin = target;
}
Array.from(document.getElementsByTagName('details')).filter(
(details) => details.open && details != detailsClickedWithin
).forEach(details => details.open = false);
});
</script>
-14
View File
@@ -1,14 +0,0 @@
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
<script src="{{ base_url }}-/static/codemirror-5.57.0.min.js"></script>
<link rel="stylesheet" href="{{ base_url }}-/static/codemirror-5.57.0.min.css" />
<script src="{{ base_url }}-/static/codemirror-5.57.0-sql.min.js"></script>
<script src="{{ base_url }}-/static/cm-resize-1.0.1.min.js"></script>
<style>
.CodeMirror { height: auto; min-height: 70px; width: 80%; border: 1px solid #ddd; }
.cm-resize-handle {
background: url("data:image/svg+xml,%3Csvg%20aria-labelledby%3D%22cm-drag-to-resize%22%20role%3D%22img%22%20fill%3D%22%23ccc%22%20stroke%3D%22%23ccc%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%2216%22%20height%3D%2216%22%3E%0A%20%20%3Ctitle%20id%3D%22cm-drag-to-resize%22%3EDrag%20to%20resize%3C%2Ftitle%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M1%202.75A.75.75%200%20011.75%202h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%202.75zm0%205A.75.75%200%20011.75%207h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%207.75zM1.75%2012a.75.75%200%20100%201.5h12.5a.75.75%200%20100-1.5H1.75z%22%3E%3C%2Fpath%3E%0A%3C%2Fsvg%3E");
background-repeat: no-repeat;
box-shadow: none;
cursor: ns-resize;
}
</style>
-38
View File
@@ -1,38 +0,0 @@
<script>
window.onload = () => {
const sqlFormat = document.querySelector("button#sql-format");
const readOnly = document.querySelector("pre#sql-query");
const sqlInput = document.querySelector("textarea#sql-editor");
if (sqlFormat && !readOnly) {
sqlFormat.hidden = false;
}
if (sqlInput) {
var editor = CodeMirror.fromTextArea(sqlInput, {
lineNumbers: true,
mode: "text/x-sql",
lineWrapping: true,
});
editor.setOption("extraKeys", {
"Shift-Enter": function() {
document.getElementsByClassName("sql")[0].submit();
},
Tab: false
});
if (sqlFormat) {
sqlFormat.addEventListener("click", ev => {
editor.setValue(sqlFormatter.format(editor.getValue()));
})
}
cmResize(editor, {resizableWidth: false});
}
if (sqlFormat && readOnly) {
const formatted = sqlFormatter.format(readOnly.innerHTML);
if (formatted != readOnly.innerHTML) {
sqlFormat.hidden = false;
sqlFormat.addEventListener("click", ev => {
readOnly.innerHTML = formatted;
})
}
}
}
</script>
@@ -1,30 +0,0 @@
{% if metadata.description_html or metadata.description %}
<div class="metadata-description">
{% if metadata.description_html %}
{{ metadata.description_html|safe }}
{% else %}
{{ metadata.description }}
{% endif %}
</div>
{% endif %}
{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %}
<p>
{% if metadata.license or metadata.license_url %}Licence des données :
{% if metadata.license_url %}
<a href="{{ metadata.license_url }}">{{ metadata.license or metadata.license_url }}</a>
{% else %}
{{ metadata.license }}
{% endif %}
{% endif %}
{% if metadata.source or metadata.source_url %}{% if metadata.license or metadata.license_url %}&middot;{% endif %}
Source des données : {% if metadata.source_url %}
<a href="{{ metadata.source_url }}">
{% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}</a>{% endif %}
{% endif %}
{% if metadata.about or metadata.about_url %}{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %}&middot;{% endif %}
Plus d'informations : {% if metadata.about_url %}
<a href="{{ metadata.about_url }}">
{% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}</a>{% endif %}
{% endif %}
</p>
{% endif %}
-23
View File
@@ -1,23 +0,0 @@
Propulsé par <a href="https://datasette.io/" title="Datasette v{{ datasette_version }}">Datasette</a>
<!-- {% if query_ms %}&middot; Durée de la requ {{ query_ms|round(3) }}ms{% endif %} -->
{% if metadata %}
{% if metadata.license or metadata.license_url %}&middot; Licence des données :
{% if metadata.license_url %}
<a href="{{ metadata.license_url }}">{{ metadata.license or metadata.license_url }}</a>
{% else %}
{{ metadata.license }}
{% endif %}
{% endif %}
{% if metadata.source or metadata.source_url %}&middot;
Source des données : {% if metadata.source_url %}
<a href="{{ metadata.source_url }}">
{% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}</a>{% endif %}
{% endif %}
&middot; Code source : <a href="https://github.com/ColinMaudry/decp-table-schema-utils">Github</a>
{% if metadata.about or metadata.about_url %}&middot;
Plus d'informations : {% if metadata.about_url %}
<a href="{{ metadata.about_url }}">
{% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}</a>{% endif %}
{% endif %}
&middot; <a href="/mentions-legales">Mentions légales</a>
{% endif %}
-34
View File
@@ -1,34 +0,0 @@
{% if display_rows %}
<div class="table-wrapper">
<table class="rows-and-columns">
<thead>
<tr>
{% for column in display_columns %}
<th class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}">
{% if not column.sortable %}
{{ column.name }}
{% else %}
{% if column.name == sort %}
<a href="{{ path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None}) }}" rel="nofollow">{{ column.name }}&nbsp;</a>
{% else %}
<a href="{{ path_with_replaced_args(request, {'_sort': column.name, '_sort_desc': None, '_next': None}) }}" rel="nofollow">{{ column.name }}{% if column.name == sort_desc %}&nbsp;▲{% endif %}</a>
{% endif %}
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for cell in row %}
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}"><div>{% if cell.column == "titulaire.id" or cell.column == "acheteur.id" %}<a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ cell.value }}" target="_blank">{{ cell.value }}</a>{% elif cell.column == "codeAPE" %} <a href="https://www.insee.fr/fr/metadonnees/nafr2/sousClasse/{{ cell.value }}?champRecherche=true" target="_blank">{{ cell.value }}</a> {% else %}{{ cell.value }}{% endif %}</div></td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="zero-results">0 lignes</p>
{% endif %}
-3
View File
@@ -1,3 +0,0 @@
{% extends "base.html" %}
<h1>Coucou</h1>
-58
View File
@@ -1,58 +0,0 @@
{% extends "base.html" %}
{% block title %}Debug allow rules{% endblock %}
{% block extra_head %}
<style>
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
p.message-warning {
white-space: pre-wrap;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Debug allow rules</h1>
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
<form action="{{ urls.path('-/allow-debug') }}" method="get">
<div class="two-col">
<p><label>Allow block</label></p>
<textarea name="allow">{{ allow_input }}</textarea>
</div>
<div class="two-col">
<p><label>Actor</label></p>
<textarea name="actor">{{ actor_input }}</textarea>
</div>
<div style="margin-top: 1em;">
<input type="submit" value="Apply allow block to actor">
</div>
</form>
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
{% endblock %}
-85
View File
@@ -1,85 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="manifest" href="/static/manifest.webmanifest">
{% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %}
{% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
{% endfor %}
{% block extra_head %}{% endblock %}
<link rel="shortcut icon" href="/static/icons/icon-192x192.png" />
<!-- Matomo -->
<script type="text/javascript">
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="//analytics.maudry.com/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '14']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
</head>
<body class="{% block body_class %}{% endblock %}">
<header><nav>{% block nav %}
{% set links = menu_links() %}{% if links or show_logout %}
<details class="nav-menu">
<summary>Menu</summary>
<div class="nav-menu-inner">
{% if links %}
<ul>
{% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if show_logout %}
<form action="{{ urls.logout() }}" method="post">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<button class="button-as-link">Déconnexion</button>
</form>{% endif %}
</div>
</details>{% endif %}
{% if actor %}
<div class="actor">
<strong>{{ display_actor(actor) }}</strong>
</div>
{% endif %}
{% endblock %}</nav></header>
{% block messages %}
{% if show_messages %}
{% for message, message_type in show_messages() %}
<p class="message-{% if message_type == 1 %}info{% elif message_type == 2 %}warning{% elif message_type == 3 %}error{% endif %}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endblock %}
<section class="content">
{% block content %}
{% endblock %}
</section>
<footer class="ft">{% block footer %}{% include "_footer.html" %}{% endblock %}</footer>
{% include "_close_open_menus.html" %}
{% for body_script in body_scripts %}
<script{% if body_script.module %} type="module"{% endif %}>{{ body_script.script }}</script>
{% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
</body>
</html>
-113
View File
@@ -1,113 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ database }}{% endblock %}
{% block extra_head %}
{{ super() }}
{% include "_codemirror.html" %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">accueil</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<!-- <div class="page-header" style="border-color: #{{ database_color(database) }}">
<h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
{% set links = database_actions() %}{% if links %}
<details class="actions-menu-links">
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
style="color: #666" xmlns="http://www.w3.org/2000/svg"
width="28" height="28" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">Table actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg></summary>
<div class="dropdown-menu">
{% if links %}
<ul>
{% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</details>{% endif %}
</div> -->
<!-- {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} -->
{% if attached_databases %}
<div class="message-info">
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
<ul class="bullets">
{% for db_name in attached_databases %}
<li><strong>{{ db_name }}</strong> - <a href="?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<p class="db-presentation">decp.info vous permet d'explorer les données à travers différentes vues. Selon que vous soyez plutôt intéressé·e par le montant, les titulaires ou les caractériques d'un marché public, une vue vous sera plus utile qu'une autre.</p>
{% for table in tables %}
{% if show_hidden or not table.hidden %}
<div class="db-table">
<h2><a href="{{ urls.table(database, table.name) }}">{{ metadata.tables[table.name].title }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h2>
<p>Description : {{ metadata.tables[table.name].description_html | safe }} (<a target="_blank" href="{{ metadata.tables[table.name].download }}">CSV</a>)</p>
<p class="columns">Colonnes : {% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</p>
<p>{% if table.count is none %}{% else %}{{ "{:,}".format(table.count) }} ligne{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
</div>
{% endif %}
{% endfor %}
{% if hidden_count and not show_hidden %}
<p>... and <a href="{{ urls.database(database) }}?_show_hidden=1">{{ "{:,}".format(hidden_count) }} hidden table{% if hidden_count == 1 %}{% else %}s{% endif %}</a></p>
{% endif %}
{% if views %}
<h2 id="views">Views</h2>
<ul class="bullets">
{% for view in views %}
<li><a href="{{ urls.database(database) }}/{{ view.name|urlencode }}">{{ view.name }}</a>{% if view.private %} 🔒{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% if queries %}
<h2 id="queries">Queries</h2>
<ul class="bullets">
{% for query in queries %}
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% if allow_execute_sql %}
<form class="sql" action="{{ urls.database(database) }}" method="get">
<h3>Requête SQL personnalisée</h3>
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
<p>
<button id="sql-format" type="button" hidden>Formater le SQL</button>
<input type="submit" value="Exécuter">
</p>
</form>
{% endif %}
{% if allow_download %}
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
{% endif %}
{% include "_codemirror_foot.html" %}
{% endblock %}
-18
View File
@@ -1,18 +0,0 @@
{% extends "base.html" %}
{% block title %}{% if title %}{{ title }}{% else %}Error {{ status }}{% endif %}{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">accueil</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1>{% if title %}{{ title }}{% else %}Erreur {{ status }}{% endif %}</h1>
<div style="padding: 1em; margin: 1em 0; border: 3px solid red;">{{ error }}</div>
{% endblock %}
-38
View File
@@ -1,38 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ metadata.title or "Datasette" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %}
{% block body_class %}index{% endblock %}
{% block content %}
<h1>{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}</h1>
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<p><a class="explore nodec" href="/db"><i class="flaticon-magnifying-glass"></i></a> <a class="explore" href="/db">Explorer les données</a></p>
<p><a class="explore nodec" href="https://teamopendata.org/t/decp-info-les-donnees-de-la-commande-publique-pour-tous-questions-reponses-discussions/2948"><i class="flaticon-chat-2"></i></a> <a href="https://teamopendata.org/t/decp-info-les-donnees-de-la-commande-publique-pour-tous-questions-reponses-discussions/2948">Présentation / FAQ / discussions</a></p>
<p><a class="explore nodec" href="/versions"><i class="flaticon-copy"></i></a> <a href="/versions">Notes de version</a></p>
<p><a class="explore nodec" href="/inscription"><i class="flaticon-paper-plane"></i></a> <a href="/inscription">Se tenir informé des nouveautés de decp.info</a></p>
<p><a class="explore nodec" href="mailto:colin+decp@maudry.com"><i class="flaticon-envelope"></i></a> <a href="mailto:colin+decp@maudry.com">colin+decp@maudry.com</a></p>
<!-- {% for database in databases %}
<h2 style="padding-left: 10px; border-left: 10px solid #{{ database.color }}">
<a href="{{ urls.database(database.name) }}">{{ database.name }}</a>{% if database.private %} 🔒{% endif %}</h2>
<p>
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.tables_count and database.hidden_tables_count %}, {% endif -%}
{% if database.hidden_tables_count -%}
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
{% endif -%}
{% if database.views_count -%}
{% if database.tables_count or database.hidden_tables_count %}, {% endif -%}
{{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
{% endif %}
</p>
<p>{% for table in database.tables_and_views_truncated %}<a href="{{ urls.table(database.name, table.name) }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, <a href="{{ urls.database(database.name) }}">...</a>{% endif %}</p>
{% endfor %} -->
{% endblock %}
-25
View File
@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Log out{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ base_url }}">home</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1>Log out</h1>
<p>You are logged in as <strong>{{ display_actor(actor) }}</strong></p>
<form action="{{ urls.logout() }}" method="post">
<div>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Log out">
</div>
</form>
{% endblock %}
-27
View File
@@ -1,27 +0,0 @@
{% extends "base.html" %}
{% block title %}Debug messages{% endblock %}
{% block content %}
<h1>Debug messages</h1>
<p>Set a message:</p>
<form action="{{ urls.path('-/messages') }}" method="post">
<div>
<input type="text" name="message" style="width: 40%">
<div class="select-wrapper">
<select name="message_type">
<option>INFO</option>
<option>WARNING</option>
<option>ERROR</option>
<option>all</option>
</select>
</div>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Add message">
</div>
</form>
{% endblock %}
-240
View File
@@ -1,240 +0,0 @@
{% extends "base.html" %}
{% block title %}Se tenir informé{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">accueil</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1>Se tenir informé des nouveautés de decp.info</h1>
<p><a href="/mentions-legales">Mentions légales et politique de confidentialité</a></p>
<!-- Begin Sendinblue Form -->
<!-- START - We recommend to place the below code in head tag of your website html -->
<style>
@font-face {
font-display: block;
font-family: Roboto;
src: url(https://assets.sendinblue.com/font/Roboto/Latin/normal/normal/7529907e9eaf8ebb5220c5f9850e3811.woff2) format("woff2"), url(https://assets.sendinblue.com/font/Roboto/Latin/normal/normal/25c678feafdc175a70922a116c9be3e7.woff) format("woff")
}
@font-face {
font-display: fallback;
font-family: Roboto;
font-weight: 600;
src: url(https://assets.sendinblue.com/font/Roboto/Latin/medium/normal/6e9caeeafb1f3491be3e32744bc30440.woff2) format("woff2"), url(https://assets.sendinblue.com/font/Roboto/Latin/medium/normal/71501f0d8d5aa95960f6475d5487d4c2.woff) format("woff")
}
@font-face {
font-display: fallback;
font-family: Roboto;
font-weight: 700;
src: url(https://assets.sendinblue.com/font/Roboto/Latin/bold/normal/3ef7cf158f310cf752d5ad08cd0e7e60.woff2) format("woff2"), url(https://assets.sendinblue.com/font/Roboto/Latin/bold/normal/ece3a1d82f18b60bcce0211725c476aa.woff) format("woff")
}
#sib-container input:-ms-input-placeholder {
text-align: left;
font-family: "Helvetica", sans-serif;
color: #c0ccda;
}
#sib-container input::placeholder {
text-align: left;
font-family: "Helvetica", sans-serif;
color: #c0ccda;
}
#sib-container textarea::placeholder {
text-align: left;
font-family: "Helvetica", sans-serif;
color: #c0ccda;
}
</style>
<link rel="stylesheet" href="https://sibforms.com/forms/end-form/build/sib-styles.css">
<!-- END - We recommend to place the above code in head tag of your website html -->
<!-- START - We recommend to place the below code where you want the form in your website html -->
<div class="sib-form" style="text-align: center;
background-color: #EFF2F7; ">
<div id="sib-form-container" class="sib-form-container">
<div id="error-message" class="sib-form-message-panel" style="font-size:16px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;max-width:540px;">
<div class="sib-form-message-panel__text sib-form-message-panel__text--center">
<svg viewBox="0 0 512 512" class="sib-icon sib-notification__icon">
<path d="M256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm-11.49 120h22.979c6.823 0 12.274 5.682 11.99 12.5l-7 168c-.268 6.428-5.556 11.5-11.99 11.5h-8.979c-6.433 0-11.722-5.073-11.99-11.5l-7-168c-.283-6.818 5.167-12.5 11.99-12.5zM256 340c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28z"
/>
</svg>
<span class="sib-form-message-panel__inner-text">
Il y a eu un problème lors de votre inscription.
</span>
</div>
</div>
<div></div>
<div id="success-message" class="sib-form-message-panel" style="font-size:16px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#085229; background-color:#F8FAFB; border-radius:3px; border-color:#13ce66;max-width:540px;">
<div class="sib-form-message-panel__text sib-form-message-panel__text--center">
<svg viewBox="0 0 512 512" class="sib-icon sib-notification__icon">
<path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 464c-118.664 0-216-96.055-216-216 0-118.663 96.055-216 216-216 118.664 0 216 96.055 216 216 0 118.663-96.055 216-216 216zm141.63-274.961L217.15 376.071c-4.705 4.667-12.303 4.637-16.97-.068l-85.878-86.572c-4.667-4.705-4.637-12.303.068-16.97l8.52-8.451c4.705-4.667 12.303-4.637 16.97.068l68.976 69.533 163.441-162.13c4.705-4.667 12.303-4.637 16.97.068l8.451 8.52c4.668 4.705 4.637 12.303-.068 16.97z"
/>
</svg>
<span class="sib-form-message-panel__inner-text">
Votre inscription à la lettre d&#039;information de decp.info est confirmée !
</span>
</div>
</div>
<div></div>
<div id="sib-container" class="sib-container--large sib-container--vertical" style="text-align:center; background-color:rgba(255,255,255,1); max-width:540px; border-radius:3px; border-width:1px; border-color:#C0CCD9; border-style:solid;">
<form id="sib-form" method="POST" action="https://6254d9a3.sibforms.com/serve/MUIEAFh0B8R5fTSPBmKbkiziso8TBxEbUSvq4FaHCK9EL8CT6H41du2IXKnJZhVBXFYxxZgnOVqO7hribE8qPWpY5IE5yraVm7d7uKdlGqurkl0XgHUy4fILW8aPuGAcn60ZsZzJf7gxINtPUpSlrOsjZPcdl4vvWgsaAU51rM9cXCC8Mm_MrlvOkuZk_uah-YUNMjj6qLONk1WR"
data-type="subscription">
<div style="padding: 8px 0;">
<div class="sib-form-block" style="font-size:16px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#3C4858; background-color:transparent;">
<div class="sib-text-form-block">
<p>Inscrivez-vous à la lettre d'information de decp.info afin d'être informé·e de l'ajout de nouvelles fonctionnalités (2 mails par mois maximum).</p>
</div>
</div>
</div>
<div style="padding: 8px 0;">
<div class="sib-input sib-form-block">
<div class="form__entry entry_block">
<div class="form__label-row ">
<label class="entry__label" style="font-size:16px; text-align:left; font-weight:700; font-family:&quot;Helvetica&quot;, sans-serif; color:#3c4858;" for="EMAIL" data-required="*">
Votre adresse email :
</label>
<div class="entry__field">
<input class="input" type="text" id="EMAIL" name="EMAIL" autocomplete="off" data-required="true" required />
</div>
</div>
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
</label>
</div>
</div>
</div>
<div style="padding: 8px 0;">
<div class="sib-input sib-form-block">
<div class="form__entry entry_block">
<div class="form__label-row ">
<label class="entry__label" style="font-size:16px; text-align:left; font-weight:700; font-family:&quot;Helvetica&quot;, sans-serif; color:#3c4858;" for="NOM">
Votre nom :
</label>
<div class="entry__field">
<input class="input" maxlength="200" type="text" id="NOM" name="NOM" autocomplete="off" placeholder="Optionnel" />
</div>
</div>
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
</label>
</div>
</div>
</div>
<div style="padding: 8px 0;">
<div class="sib-input sib-form-block">
<div class="form__entry entry_block">
<div class="form__label-row ">
<label class="entry__label" style="font-size:16px; text-align:left; font-weight:700; font-family:&quot;Helvetica&quot;, sans-serif; color:#3c4858;" for="ORGANISME">
Votre organisme :
</label>
<div class="entry__field">
<input class="input" maxlength="200" type="text" id="ORGANISME" name="ORGANISME" autocomplete="off" placeholder="Optionnel" />
</div>
</div>
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
</label>
</div>
</div>
</div>
<div style="padding: 8px 0;">
<div class="sib-optin sib-form-block">
<div class="form__entry entry_mcq">
<div class="form__label-row ">
<div class="entry__choice">
<label>
<input type="checkbox" class="input_replaced" value="1" id="OPT_IN" name="OPT_IN" />
<span class="checkbox checkbox_tick_positive"></span><span style="font-size:14px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#3C4858; background-color:transparent;"><p>J'accepte de recevoir vos e-mails et confirme avoir pris connaissance de votre politique de confidentialité et mentions légales.</p></span> </label>
</div>
</div>
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
</label>
<label class="entry__specification" style="font-size:12px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#8390A4;">
Vous pouvez vous désinscrire à tout moment en cliquant sur le lien présent dans nos emails.
</label>
</div>
</div>
</div>
<div style="padding: 8px 0;">
<div class="sib-form__declaration">
<div class="declaration-block-icon">
<svg class="icon__SVG" width="0" height="0" version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="svgIcon-sphere" viewBox="0 0 63 63">
<path class="path1" d="M31.54 0l1.05 3.06 3.385-.01-2.735 1.897 1.05 3.042-2.748-1.886-2.738 1.886 1.044-3.05-2.745-1.897h3.393zm13.97 3.019L46.555 6.4l3.384.01-2.743 2.101 1.048 3.387-2.752-2.1-2.752 2.1 1.054-3.382-2.745-2.105h3.385zm9.998 10.056l1.039 3.382h3.38l-2.751 2.1 1.05 3.382-2.744-2.091-2.743 2.091 1.054-3.381-2.754-2.1h3.385zM58.58 27.1l1.04 3.372h3.379l-2.752 2.096 1.05 3.387-2.744-2.091-2.75 2.092 1.054-3.387-2.747-2.097h3.376zm-3.076 14.02l1.044 3.364h3.385l-2.743 2.09 1.05 3.392-2.744-2.097-2.743 2.097 1.052-3.377-2.752-2.117 3.385-.01zm-9.985 9.91l1.045 3.364h3.393l-2.752 2.09 1.05 3.393-2.745-2.097-2.743 2.097 1.05-3.383-2.751-2.1 3.384-.01zM31.45 55.01l1.044 3.043 3.393-.008-2.752 1.9L34.19 63l-2.744-1.895-2.748 1.891 1.054-3.05-2.743-1.9h3.384zm-13.934-3.98l1.036 3.364h3.402l-2.752 2.09 1.053 3.393-2.747-2.097-2.752 2.097 1.053-3.382-2.743-2.1 3.384-.01zm-9.981-9.91l1.045 3.364h3.398l-2.748 2.09 1.05 3.392-2.753-2.1-2.752 2.096 1.053-3.382-2.743-2.102 3.384-.009zM4.466 27.1l1.038 3.372H8.88l-2.752 2.097 1.053 3.387-2.743-2.09-2.748 2.09 1.053-3.387L0 30.472h3.385zm3.069-14.025l1.045 3.382h3.395L9.23 18.56l1.05 3.381-2.752-2.09-2.752 2.09 1.053-3.381-2.744-2.1h3.384zm9.99-10.056L18.57 6.4l3.393.01-2.743 2.1 1.05 3.373-2.754-2.092-2.751 2.092 1.053-3.382-2.744-2.1h3.384zm24.938 19.394l-10-4.22a2.48 2.48 0 00-1.921 0l-10 4.22A2.529 2.529 0 0019 24.75c0 10.47 5.964 17.705 11.537 20.057a2.48 2.48 0 001.921 0C36.921 42.924 44 36.421 44 24.75a2.532 2.532 0 00-1.537-2.336zm-2.46 6.023l-9.583 9.705a.83.83 0 01-1.177 0l-5.416-5.485a.855.855 0 010-1.192l1.177-1.192a.83.83 0 011.177 0l3.65 3.697 7.819-7.916a.83.83 0 011.177 0l1.177 1.191a.843.843 0 010 1.192z"
fill="#0092FF"></path>
</symbol>
</defs>
</svg>
<svg class="svgIcon-sphere" style="width:63px; height:63px;">
<use xlink:href="#svgIcon-sphere"></use>
</svg>
</div>
<p style="font-size:14px; text-align:left; font-family:&quot;Helvetica&quot;, sans-serif; color:#687484; background-color:transparent;">
Nous utilisons Sendinblue en tant que plateforme marketing. En soumettant ce formulaire, vous reconnaissez que les informations que vous allez fournir seront transmises à Sendinblue en sa qualité de processeur de données; et ce conformément à ses
<a target="_blank" class="clickable_link" href="https://fr.sendinblue.com/legal/termsofuse/">conditions générales d'utilisation</a>.
</p>
</div>
</div>
<div style="padding: 8px 0;">
<div class="sib-form-block" style="text-align: left">
<button class="sib-form-block__button sib-form-block__button-with-loader" style="font-size:16px; text-align:left; font-weight:700; font-family:&quot;Helvetica&quot;, sans-serif; color:#FFFFFF; background-color:#3E4857; border-radius:3px; border-width:0px;"
form="sib-form" type="submit">
<svg class="icon clickable__icon progress-indicator__icon sib-hide-loader-icon" viewBox="0 0 512 512">
<path d="M460.116 373.846l-20.823-12.022c-5.541-3.199-7.54-10.159-4.663-15.874 30.137-59.886 28.343-131.652-5.386-189.946-33.641-58.394-94.896-95.833-161.827-99.676C261.028 55.961 256 50.751 256 44.352V20.309c0-6.904 5.808-12.337 12.703-11.982 83.556 4.306 160.163 50.864 202.11 123.677 42.063 72.696 44.079 162.316 6.031 236.832-3.14 6.148-10.75 8.461-16.728 5.01z"
/>
</svg>
Je m&#039;inscris
</button>
</div>
</div>
<input type="text" name="email_address_check" value="" class="input--hidden">
<input type="hidden" name="locale" value="fr">
</form>
</div>
</div>
</div>
<!-- END - We recommend to place the below code where you want the form in your website html -->
<!-- START - We recommend to place the below code in footer or bottom of your website html -->
<script>
window.REQUIRED_CODE_ERROR_MESSAGE = 'Veuillez choisir un code pays';
window.EMAIL_INVALID_MESSAGE = window.SMS_INVALID_MESSAGE = "Les informations que vous avez fournies ne sont pas valides. Veuillez vérifier votre adresse email et réessayer.";
window.REQUIRED_ERROR_MESSAGE = "Ce champ est obligatoire pour vous inscrire. ";
window.GENERIC_INVALID_MESSAGE = "Les informations que vous avez fournies ne sont pas valides. Veuillez vérifier votre adresse email et réessayer.";
window.translation = {
common: {
selectedList: '{quantity} liste sélectionnée',
selectedLists: '{quantity} listes sélectionnées'
}
};
var AUTOHIDE = Boolean(0);
</script>
<script src="https://sibforms.com/forms/end-form/build/main.js"></script>
<!-- END - We recommend to place the above code in footer or bottom of your website html -->
<!-- End Sendinblue Form -->
{% endblock %}
@@ -1,53 +0,0 @@
{% extends "base.html" %}
{% block title %}Mentions légales{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">accueil</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<div style="max-width: 700px;margin:auto">
<h1 id="mentionslgales">Mentions légales</h1>
<p>Le site decp.info est édité par Colin Maudry, inscrit au répertoire SIRENE sous le numéro 812 231 132, dont le siège social est situé à Rennes, et dont l'adresse email est <a href="mailto:colin@maudry.com">colin@maudry.com</a>.</p>
<p>Le site est hébergé en France par :</p>
<p>Scaleway par ONLINE, SAS, au capital de 214 410,50 Euros<br/>
Siège social : 8 rue de la ville l'Evêque-75008 PARIS<br/>
RCS Paris B 433 115 904, TVA FR35433115904<br/>
<a href="https://www.scaleway.com" rel="nofollow">https://www.scaleway.com</a></p>
<p>Les icônes de la page d'accueil ont été dessinées par <a href="https://www.flaticon.com/authors/gregor-cresnar" title="Gregor Cresnar">Gregor Cresnar</a> de <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a></p>
<h1 id="vieprive">Vie privée</h1>
<h2>Les cookies</h2>
<p>Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez (<a href="https://fr.wikipedia.org/wiki/Cookie_(informatique)">Wikipédia</a>). Cela me permet de mesurer le nombre de visites et de distinguer les nouveaux visiteurs des utilisateurs réguliers.</p>
<div style="background-color: #ccc;"><iframe style="border: 0; height: 200px; width: 100%;" title="Opt-out du cookie de suivi" src="https://analytics.maudry.com/index.php?module=CoreAdminHome&amp;action=optOut&amp;language=fr&amp;backgroundColor=&amp;fontColor=&amp;fontSize=&amp;fontFamily="></iframe></div>
<h3 id="cesitenaffichepasdebanniredeconsentementauxcookiespourquoi">Ce site naffiche pas de bannière de consentement aux cookies, pourquoi ?</h3>
<p>Cest vrai, vous navez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes daccord avec le dépôt de cookies.</p>
<p>Rien dexceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi daudience, correctement configurés pour respecter la vie privée, sont exemptés dautorisation préalable.</p>
<p>Jutilise pour cela <a href="https://matomo.org/">Matomo</a>, un outil <a href="https://matomo.org/free-software/">libre</a>, paramétré pour être en conformité avec la <a href="https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience">recommandation « Cookies »</a> de la <a href="http://sigl.es/cnil">CNIL</a>. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il mest donc impossible dassocier vos visites sur ce site à votre personne.</p>
<h2>La lettre d'information et conformité RGPD</h2>
Ce site <a href="/inscription">vous propose</a> de vous inscrire à une lettre d'information. Pour ce faire, vous devez donner votre accord par deux fois : au moment de remplir le formulaire, et en cliquant sur un lien dans le mail de confirmation.
Tous les emails qui vous sont envoyés dans le cadre de cette lettre d'information comportent un lien de désinscription.
Votre adresse email est stockée en France chez Sendinblue, entreprise française.
</div>
{% endblock %}
-67
View File
@@ -1,67 +0,0 @@
{% extends "base.html" %}
{% block title %}Mentions légales{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">accueil</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<div style="max-width: 700px;margin:auto">
<h1>Notes de version</h1>
<h3 id="12028052021">1.2.0 (28/05/2021)</h3>
<ul>
<li>ajout d'une page "Notes de version"</li>
<li>meilleur lien pour la documentation des champs</li>
<li>déplacement du code de decp.info depuis <a href="https://github.com/ColinMaudry/decp-table-schema-utils">ColinMaudry/decp-table-schema-utils</a> vers <a href="https://github.com/ColinMaudry/decp.info">ColinMaudry/decp.info</a></li>
</ul>
<h3 id="11025052021">1.1.0 (25/05/2021)</h3>
<ul>
<li>ajout de nouvelles vues :
<ul>
<li>Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE</li>
<li>Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants</li></ul>
</li>
<li>amélioration de la page d'accueil</li>
<li>développement de la page "db" avec description des vues et liste des colonnes</li>
<li>les codes APE sont cliquables</li>
<li>ajout des mentions légales</li>
<li>ajout d'un formulatire d'inscription à une lettre d'information</li>
<li>correction de bugs :
<ul>
<li>correction du format de certaines dates dans les données</li></ul>
</li>
</ul>
<h3 id="100">1.0.0</h3>
<ul>
<li>publication sur https://decp.info</li>
<li>ajout d'une vue équivalente au format DECP réglementaire</li>
<li>personnalisation de datasette</li>
<li>script de conversion quotidien basé sur <a href="https://github.com/datahq/dataflows">dataflows</a></li>
</ul>
</div>
{% endblock %}
-495
View File
@@ -1,495 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Datasette: Pattern Portfolio</title>
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex">
<style></style>
</head>
<body>
<header><nav>
<p class="crumbs">
<a href="/">home</a>
</p>
<details class="nav-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" width="16" height="16">
<title id="nav-menu-svg-title">Menu</title>
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
</svg></summary>
<div class="nav-menu-inner">
<ul>
<li><a href="/-/databases">Databases</a></li>
<li><a href="/-/plugins">Installed plugins</a></li>
<li><a href="/-/versions">Version info</a></li>
</ul>
<form action="/-/logout" method="post">
<button class="button-as-link">Log out</button>
</form>
</div>
</details>
<div class="actor">
<strong>root</strong>
</div>
</nav></header>
<section class="content">
<h1>Pattern Portfolio</h1>
</section>
<h2 class="pattern-heading">Header for /database/table/row and Messages</h2>
<header>
<nav>
<p class="crumbs">
<a href="/">home</a> /
<a href="/fixtures">fixtures</a> /
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
</p>
<div class="actor">
<strong>testuser</strong>
</div>
</nav>
</header>
<p class="message-info">Example message</p>
<p class="message-warning">Example message</p>
<p class="message-error">Example message</p>
<h2 class="pattern-heading">.bd for /</h2>
<section class="content">
<h1>Datasette Fixtures</h1>
<div class="metadata-description">
An example SQLite database demonstrating Datasette
</div>
<p>
Data license:
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
&middot;
Data source:
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
tests/fixtures.py</a>
&middot;
About:
<a href="https://github.com/simonw/datasette">
About Datasette</a>
</p>
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="/fixtures">fixtures</a></h2>
<p>
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
</p>
<p><a href="/fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="/fixtures/sortable" title="201 rows">sortable</a>, <a href="/fixtures/facetable" title="15 rows">facetable</a>, <a href="/fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="/fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="/fixtures">...</a></p>
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="/data">data</a></h2>
<p>
6 rows in 2 tables
</p>
<p><a href="/data/names" title="6 rows">names</a>, <a href="/data/foo">foo</a></p>
</section>
<h2 class="pattern-heading">.bd for /database</h2>
<section class="content">
<div class="page-header" style="border-color: #ff0000">
<h1>fixtures</h1>
<details class="actions-menu-links">
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
style="color: #666" xmlns="http://www.w3.org/2000/svg"
width="28" height="28" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">Table actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg></summary>
<div class="dropdown-menu">
<ul>
<li><a href="#">Database action</a></li>
</ul>
</div>
</details>
</div>
<div class="metadata-description">
Test tables description
</div>
<p>
Data license:
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
&middot;
Data source:
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
tests/fixtures.py</a>
&middot;
About:
<a href="https://github.com/simonw/datasette">
About Datasette</a>
</p>
<form class="sql" action="/fixtures" method="get">
<h3>Custom SQL query</h3>
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
<p>
<button id="sql-format" type="button" hidden>Format SQL</button>
<input type="submit" value="Run SQL">
</p>
</form>
<div class="db-table">
<h2><a href="/fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
<p><em>content</em></p>
<p>0 rows</p>
</div>
<div class="db-table">
<h2><a href="/fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
<p><em>pk, content</em></p>
<p>0 rows</p>
</div>
<div class="db-table">
<h2><a href="/fixtures/attraction_characteristic">attraction_characteristic</a></h2>
<p><em>pk, name</em></p>
<p>2 rows</p>
</div>
</section>
<h2 class="pattern-heading">.bd for /database/table</h2>
<section class="content">
<div class="page-header" style="border-color: #ff0000">
<h1>roadside_attraction_characteristics</h1>
<details class="actions-menu-links">
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
style="color: #666" xmlns="http://www.w3.org/2000/svg"
width="28" height="28" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">Table actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg></summary>
<div class="dropdown-menu">
<ul>
<li><a href="#">Table action</a></li>
</ul>
</div>
</details>
</div>
<p>
Data license:
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
&middot;
Data source:
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
tests/fixtures.py</a>
&middot;
About:
<a href="https://github.com/simonw/datasette">
About Datasette</a>
</p>
<h3>3 rows
where characteristic_id = 2
</h3>
<form class="filters" action="/fixtures/roadside_attraction_characteristics" method="get">
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
<div class="filter-row">
<div class="select-wrapper">
<select name="_filter_column_1">
<option value="">- remove filter -</option>
<option>rowid</option>
<option>attraction_id</option>
<option selected>characteristic_id</option>
</select>
</div>
<div class="select-wrapper filter-op">
<select name="_filter_op_1">
<option value="exact" selected>=</option>
<option value="not">!=</option>
<option value="contains">contains</option>
<option value="endswith">ends with</option>
<option value="startswith">starts with</option>
<option value="gt">&gt;</option>
<option value="gte"></option>
<option value="lt">&lt;</option>
<option value="lte"></option>
<option value="like">like</option>
<option value="notlike">not like</option>
<option value="glob">glob</option>
<option value="in">in</option>
<option value="notin">not in</option>
<option value="arraycontains">array contains</option>
<option value="date">date</option>
<option value="isnull__1">is null</option>
<option value="notnull__1">is not null</option>
<option value="isblank__1">is blank</option>
<option value="notblank__1">is not blank</option>
</select>
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
</div>
<div class="filter-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
<option>rowid</option>
<option>attraction_id</option>
<option>characteristic_id</option>
</select>
</div>
<div class="select-wrapper filter-op">
<select name="_filter_op">
<option value="exact">=</option>
<option value="not">!=</option>
<option value="contains">contains</option>
<option value="endswith">ends with</option>
<option value="startswith">starts with</option>
<option value="gt">&gt;</option>
<option value="gte"></option>
<option value="lt">&lt;</option>
<option value="lte"></option>
<option value="like">like</option>
<option value="notlike">not like</option>
<option value="glob">glob</option>
<option value="in">in</option>
<option value="notin">not in</option>
<option value="arraycontains">array contains</option>
<option value="date">date</option>
<option value="isnull__1">is null</option>
<option value="notnull__1">is not null</option>
<option value="isblank__1">is blank</option>
<option value="notblank__1">is not blank</option>
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
<div class="select-wrapper small-screen-only">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
<option value="rowid" selected>Sort by rowid</option>
<option value="attraction_id">Sort by attraction_id</option>
<option value="characteristic_id">Sort by characteristic_id</option>
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply">
</div>
</form>
<div class="extra-wheres">
<h3>2 extra where clauses</h3>
<ul>
<li><code>planet_int=1</code> [<a href="/fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
<li><code>state='CA'</code> [<a href="/fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
</ul>
</div>
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where &#34;characteristic_id&#34; = :p0 order by rowid limit 101" href="/fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&amp;p0=2">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
<p class="export-links">This data as <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">json</a>, <a href="/fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&amp;_labels=on&amp;_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
<p class="suggested-facets">
Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_array=tags#facet-tags">tags</a> (array)
</p>
<div class="facet-results">
<div class="facet-info facet-fixtures-facetable-tags" id="facet-tags">
<p class="facet-info-name">
<strong>tags (array)</strong>
<a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created" class="cross"></a>
</p>
<ul>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;tags__arraycontains=tag1">tag1</a> 2</li>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;tags__arraycontains=tag2">tag2</a> 1</li>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;tags__arraycontains=tag3">tag3</a> 1</li>
</ul>
</div>
<div class="facet-info facet-fixtures-facetable-created" id="facet-created">
<p class="facet-info-name">
<strong>created</strong>
<a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet_array=tags" class="cross"></a>
</p>
<ul>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;created=2019-01-14+08%3A00%3A00">2019-01-14 08:00:00</a> 4</li>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;created=2019-01-15+08%3A00%3A00">2019-01-15 08:00:00</a> 4</li>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;created=2019-01-16+08%3A00%3A00">2019-01-16 08:00:00</a> 2</li>
</ul>
</div>
<div class="facet-info facet-fixtures-facetable-city_id" id="facet-city_id">
<p class="facet-info-name">
<strong>city_id</strong>
<a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=created&amp;_facet_array=tags" class="cross"></a>
</p>
<ul>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;city_id=1">San Francisco</a> 6</li>
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet_array=tags&amp;city_id=2">Los Angeles</a> 4</li>
</ul>
</div>
</div>
<table class="rows-and-columns">
<thead>
<tr>
<th class="col-Link" scope="col">
Link
</th>
<th class="col-rowid" scope="col">
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort_desc=rowid" rel="nofollow">rowid&nbsp;</a>
</th>
<th class="col-attraction_id" scope="col">
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=attraction_id" rel="nofollow">attraction_id</a>
</th>
<th class="col-characteristic_id" scope="col">
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=characteristic_id" rel="nofollow">characteristic_id</a>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/1">1</a></td>
<td class="col-rowid">1</td>
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/1">The Mystery Spot</a>&nbsp;<em>1</em></td>
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr>
<tr>
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/2">2</a></td>
<td class="col-rowid">2</td>
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/2">Winchester Mystery House</a>&nbsp;<em>2</em></td>
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr>
<tr>
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/3">3</a></td>
<td class="col-rowid">3</td>
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a>&nbsp;<em>4</em></td>
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr>
</tbody>
</table>
<div id="export" class="advanced-export">
<h3>Advanced export</h3>
<p>JSON shape:
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">default</a>,
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array">array</a>,
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array&amp;_nl=on">newline-delimited</a>
</p>
<form action="/fixtures/roadside_attraction_characteristics.csv" method="get">
<p>
CSV options:
<label><input type="checkbox" name="_dl"> download file</label>
<label><input type="checkbox" name="_labels" checked> expand labels</label>
<input type="submit" value="Export CSV">
<input type="hidden" name="characteristic_id" value="2">
<input type="hidden" name="_size" value="max">
</p>
</form>
</div>
<pre class="wrapped-sql">CREATE TABLE roadside_attraction_characteristics (
attraction_id INTEGER REFERENCES roadside_attractions(pk),
characteristic_id INTEGER REFERENCES attraction_characteristic(pk)
);</pre>
</section>
<h2 class="pattern-heading">.bd for /database/table/row</h2>
<section class="content">
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
<p>This data as <a href="/fixtures/roadside_attractions/2.json">json</a></p>
<table class="rows-and-columns">
<thead>
<tr>
<th class="col-pk" scope="col">
pk
</th>
<th class="col-name" scope="col">
name
</th>
<th class="col-address" scope="col">
address
</th>
<th class="col-latitude" scope="col">
latitude
</th>
<th class="col-longitude" scope="col">
longitude
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-pk">2</td>
<td class="col-name">Winchester Mystery House</td>
<td class="col-address">525 South Winchester Boulevard, San Jose, CA 95128</td>
<td class="col-latitude">37.3184</td>
<td class="col-longitude">-121.9511</td>
</tr>
</tbody>
</table>
<h2>Links from other tables</h2>
<ul>
<li>
<a href="/fixtures/roadside_attraction_characteristics?attraction_id=2">
1 row</a>
from attraction_id in roadside_attraction_characteristics
</li>
</ul>
</section>
<h2 class="pattern-heading">.ft</h2>
<footer class="ft">Powered by <a href="https://datasette.io/" title="Datasette v0+unknown">Datasette</a>
&middot; Data license:
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
&middot;
Data source:
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
tests/fixtures.py</a>
&middot;
About:
<a href="https://github.com/simonw/datasette">
About Datasette</a>
</footer>
{% include "_close_open_menus.html" %}
</body>
</html>
@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block title %}Debug permissions{% endblock %}
{% block extra_head %}
<style type="text/css">
.check-result-true {
color: green;
}
.check-result-false {
color: red;
}
.check h2 {
font-size: 1em
}
.check-action, .check-when, .check-result {
font-size: 1.3em;
}
</style>
{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ base_url }}">home</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1>Recent permissions checks</h1>
{% for check in permission_checks %}
<div class="check">
<h2>
<span class="check-action">{{ check.action }}</span>
checked at
<span class="check-when">{{ check.when }}</span>
{% if check.result %}
<span class="check-result check-result-true"></span>
{% else %}
<span class="check-result check-result-false"></span>
{% endif %}
{% if check.used_default %}
<span class="check-used-default">(used default)</span>
{% endif %}
</h2>
<p><strong>Actor:</strong> {{ check.actor|tojson }}</p>
{% if check.resource %}
<p><strong>Resource:</strong> {{ check.resource }}</p>
{% endif %}
</div>
{% endfor %}
{% endblock %}
-87
View File
@@ -1,87 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ database }}{% if query and query.sql %}: {{ query.sql }}{% endif %}{% endblock %}
{% block extra_head %}
{{ super() }}
{% if columns %}
<style>
@media only screen and (max-width: 576px) {
{% for column in columns %}
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column|escape_css_string }}"; }
{% endfor %}
}
</style>
{% endif %}
{% include "_codemirror.html" %}
{% endblock %}
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">home</a> /
<a href="{{ urls.database(database) }}">{{ database }}</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<form class="sql" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
{% if not hide_sql %}
{% if editable and allow_execute_sql %}
<p><textarea id="sql-editor" name="sql">{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
{% else %}
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
{% endif %}
{% else %}
<input type="hidden" name="sql" value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}">
<input type="hidden" name="_hide_sql" value="1">
{% endif %}
{% if named_parameter_values %}
<h3>Query parameters</h3>
{% for name, value in named_parameter_values.items() %}
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
{% endfor %}
{% endif %}
<p>
<button id="sql-format" type="button" hidden>Format SQL</button>
{% if canned_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
<input type="submit" value="Run SQL">
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
</p>
</form>
{% if display_rows %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
<div class="table-wrapper"><table class="rows-and-columns">
<thead>
<tr>
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for column, td in zip(columns, row) %}
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
{% if not canned_write %}
<p class="zero-results">0 results</p>
{% endif %}
{% endif %}
{% include "_codemirror_foot.html" %}
{% endblock %}
-49
View File
@@ -1,49 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ database }}: {{ table }}{% endblock %}
{% block extra_head %}
{{ super() }}
<style>
@media only screen and (max-width: 576px) {
{% for column in columns %}
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column|escape_css_string }}"; }
{% endfor %}
}
</style>
{% endblock %}
{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">accueil</a> /
<a href="{{ urls.database(database) }}">{{ database }}</a> /
<a href="{{ urls.table(database, table) }}">{{ table }}</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ table }}: {{ ', '.join(primary_key_values) }}</h1>
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<p>Télcharger ces données au format {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}</p>
{% include custom_table_templates %}
{% if foreign_key_tables %}
<h2>Liens depuis d'autres tables</h2>
<ul>
{% for other in foreign_key_tables %}
<li>
<a href="{{ urls.table(database, other.other_table) }}?{{ other.other_column }}={{ ', '.join(primary_key_values) }}">
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
from {{ other.other_column }} in {{ other.other_table }}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
-19
View File
@@ -1,19 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ filename }}{% endblock %}
{% block body_class %}show-json{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">home</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1>{{ filename }}</h1>
<pre>{{ data_json }}</pre>
{% endblock %}
-217
View File
@@ -1,217 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ database }}: {{ table }}: {% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} ligne{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}
{% if human_description_en %}où {{ human_description_en }}{% endif %}{% endblock %}
{% block extra_head %}
{{ super() }}
<script src="{{ urls.static('table.js') }}" defer></script>
<style>
@media only screen and (max-width: 576px) {
{% for column in display_columns -%}
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column.name|escape_css_string }}"; }
{% endfor %}}
</style>
{% endblock %}
{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">accueil</a> /
<a href="{{ urls.database(database) }}">{{ database }}</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<div class="page-header" style="border-color: #{{ database_color(database) }}">
<h1>{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
{% set links = table_actions() %}{% if links %}
<details class="actions-menu-links">
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
style="color: #666" xmlns="http://www.w3.org/2000/svg"
width="28" height="28" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">Actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg></summary>
<div class="dropdown-menu">
{% if links %}
<ul>
{% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</details>{% endif %}
</div>
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if filtered_table_rows_count or human_description_en %}
<h4>{% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} ligne{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}
{% if human_description_en %}{{ human_description_en }}{% endif %}
</h4>
{% endif %}
<form class="filters" action="{{ urls.table(database, table) }}" method="get">
{% if supports_search %}
<div class="search-row"><label for="_search">Rechercher :</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %}
{% for column, lookup, value in filters.selections() %}
<div class="filter-row">
<div class="select-wrapper">
<select name="_filter_column_{{ loop.index }}">
<option value="">- supprimer le filtre -</option>
{% for c in filter_columns %}
<option{% if c == column %} selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div><div class="select-wrapper filter-op">
<select name="_filter_op_{{ loop.index }}">
{% for key, display, no_argument in filters.lookups() %}
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ display }}</option>
{% endfor %}
</select>
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
</div>
{% endfor %}
<div class="filter-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- colonne -</option>
{% for column in filter_columns %}
<option>{{ column }}</option>
{% endfor %}
</select>
</div><div class="select-wrapper filter-op">
<select name="_filter_op">
{% for key, display, no_argument in filters.lookups() %}
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ display }}</option>
{% endfor %}
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
{% if is_sortable %}
<div class="select-wrapper small-screen-only">
<select name="_sort" id="sort_by">
<option value="">Trier...</option>
{% for column in display_columns %}
{% if column.sortable %}
<option value="{{ column.name }}"{% if column.name == sort or column.name == sort_desc %} selected{% endif %}>Trier par {{ column.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> décroissant</label>
{% endif %}
{% for key, value in form_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<input type="submit" value="Filtrer">
</div>
</form>
{% if extra_wheres_for_ui %}
<div class="extra-wheres">
<h3>{{ extra_wheres_for_ui|length }} extra where clause{% if extra_wheres_for_ui|length != 1 %}s{% endif %}</h3>
<ul>
{% for extra_where in extra_wheres_for_ui %}
<li><code>{{ extra_where.text }}</code> [<a href="{{ extra_where.remove_url }}">supprimer</a>]</li>
{% endfor %}
</ul>
</div>
{% endif %}
<p class="export-links">Télécharger ces données au format <a href="{{ url_csv | replace('.csv','.xlsx')}}&_dl=1">Excel</a> ou <a href="{{ url_csv }}&_dl=1">CSV</a>{% if filtered_table_rows_count > 50000 %} (50 000 premières lignes){% endif %}.
{% if query.sql and allow_execute_sql %}
- <a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">Voir et éditer le SQL</span></a>
{% endif %}
{% if table == "decp-titulaires" %}Veuillez patienter pendant le chargement de la carte ci-dessous...{% endif %}
</p>
<!-- {% if suggested_facets %}
<p class="suggested-facets">
Facettes suggérées : {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.type %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
</p>
{% endif %}
{% if facets_timed_out %}
<p class="facets-timed-out">Ces facettes ont pris trop temps à être générées : {{ ", ".join(facets_timed_out) }}</p>
{% endif %}
{% if facet_results %}
<div class="facet-results">
{% for facet_info in sorted_facet_results %}
<div class="facet-info facet-{{ database|to_css_class }}-{{ table|to_css_class }}-{{ facet_info.name|to_css_class }}" id="facet-{{ facet_info.name|to_css_class }}">
<p class="facet-info-name">
<strong>{{ facet_info.name }}{% if facet_info.type != "column" %} ({{ facet_info.type }}){% endif %}</strong>
{% if facet_info.hideable %}
<a href="{{ facet_info.toggle_url }}" class="cross">&#x2716;</a>
{% endif %}
</p>
<ul class="tight-bullets">
{% for facet_value in facet_info.results %}
{% if not facet_value.selected %}
<li><a href="{{ facet_value.toggle_url }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
{% else %}
<li>{{ facet_value.label or "-" }} &middot; {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">&#x2716;</a></li>
{% endif %}
{% endfor %}
{% if facet_info.truncated %}
<li>...</li>
{% endif %}
</ul>
</div>
{% endfor %}
</div>
{% endif %}-->
{% include custom_table_templates %}
{% if next_url %}
<p><a href="{{ next_url }}">Page suivante</a></p>
{% endif %}
<!-- {% if display_rows %}
<div id="export" class="advanced-export">
<h3>Export avancé</h3>
<p>JSON shape:
<a href="{{ renderers['json'] }}">par défaut</a>,
<a href="{{ append_querystring(renderers['json'], '_shape=array') }}">array</a>,
<a href="{{ append_querystring(renderers['json'], '_shape=array&_nl=on') }}">newline-delimited</a>{% if primary_keys %},
<a href="{{ append_querystring(renderers['json'], '_shape=object') }}">objet</a>
{% endif %}
</p>
<form action="{{ url_csv_path }}" method="get">
<p>
Options CSV :
<label><input type="checkbox" name="_dl"> télécharger le fichier</label>
{% if expandable_columns %}<label><input type="checkbox" name="_labels" checked> récupérer les libellés</label>{% endif %}
{% if next_url and config.allow_csv_stream %}<label><input type="checkbox" name="_stream"> stream de lignes</label>{% endif %}
<input type="submit" value="Exporter le CSV">
{% for key, value in url_csv_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
</p>
</form>
</div>
{% endif %} -->
<!-- {% if table_definition %}
<pre class="wrapped-sql">{{ table_definition }}</pre>
{% endif %}
{% if view_definition %}
<pre class="wrapped-sql">{{ view_definition }}</pre>
{% endif %} -->
{% endblock %}
@@ -0,0 +1,473 @@
# Observatoire Link from Search & Tableau Results — 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:** Let users jump from search/tableau results to the observatoire page, pre-filtered for a given organization, via a 📊 link in the `_nom` columns.
**Architecture:** Modify `add_links()` in `src/utils.py` to append an observatoire link to `_nom` columns. Add two callbacks to `src/pages/observatoire.py` for bidirectional URL ↔ filter sync using the existing `dcc.Location(id="dashboard_url")`. Add a share URL input and clipboard button to the observatoire layout.
**Tech Stack:** Dash 3.4, Polars, `urllib.parse`, `dcc.Location`, `dcc.Clipboard`
**Spec:** `docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md`
---
### Task 1: Add observatoire link to `acheteur_nom` in `add_links()`
**Files:**
- Modify: `src/utils.py:82-91` (the `acheteur_` block inside `add_links()`)
- Test: `tests/test_main.py`
**Context:** The `add_links()` function loops over column names. The `if col.startswith("acheteur_")` block (lines 82-91) currently wraps both `acheteur_nom` and `acheteur_id` in a detail page link. We must only append the observatoire link when `col == "acheteur_nom"`.
- [ ] **Step 1: Write a unit test for the observatoire link in acheteur_nom**
In `tests/test_main.py`, add a test that calls `add_links()` on a minimal DataFrame and checks the `acheteur_nom` column contains both the detail link and the observatoire link, while `acheteur_id` does NOT contain the observatoire link.
```python
def test_004_add_links_observatoire_acheteur():
import polars as pl
from src.utils import add_links
dff = pl.DataFrame(
{
"acheteur_id": ["a1"],
"acheteur_nom": ["ACHETEUR 1"],
}
)
result = add_links(dff)
nom_value = result["acheteur_nom"][0]
id_value = result["acheteur_id"][0]
# acheteur_nom should contain detail link + observatoire link
assert "/acheteurs/a1" in nom_value
assert "ACHETEUR 1" in nom_value
assert '/observatoire?acheteur_id=a1' in nom_value
assert "📊" in nom_value
# acheteur_id should NOT contain observatoire link
assert "/observatoire" not in id_value
```
- [ ] **Step 2: Run test to verify it fails**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
Expected: FAIL — `'/observatoire?acheteur_id=a1'` not found in the output string.
- [ ] **Step 3: Implement the observatoire link for acheteur_nom**
In `src/utils.py`, modify the `if col.startswith("acheteur_")` block (lines 82-91). Gate the observatoire link append on `col == "acheteur_nom"`:
```python
if col.startswith("acheteur_"):
detail_link = (
'<a href = "/acheteurs/'
+ pl.col("acheteur_id")
+ '">'
+ pl.col(col)
+ "</a>"
)
if col == "acheteur_nom":
detail_link = (
detail_link
+ ' <a href="/observatoire?acheteur_id='
+ pl.col("acheteur_id")
+ '" title="Voir dans l\'observatoire">📊</a>'
)
dff = dff.with_columns(detail_link.alias(col))
```
- [ ] **Step 4: Run test to verify it passes**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
Expected: PASS
- [ ] **Step 5: Update `test_001` to account for the new emoji in cell text**
The existing `test_001` asserts `result_table.find_element(...).text == name` for `acheteur_nom`. The cell text now includes "📊" from the observatoire link. Update the assertion in `tests/test_main.py` to use `startswith` instead of exact match:
```python
assert result_table.find_element(
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
).text.startswith(
name
), f"The search result should have the right {org_type} name"
```
- [ ] **Step 6: Run `test_001` to verify it still passes**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_001_logo_and_search -v`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add src/utils.py tests/test_main.py
git commit -m "Ajout du lien observatoire dans acheteur_nom via add_links() #65"
```
---
### Task 2: Add observatoire link to `titulaire_nom` in `add_links()`
**Files:**
- Modify: `src/utils.py:64-81` (the `titulaire_` block inside `add_links()`)
- Test: `tests/test_main.py`
**Context:** The `titulaire_` block (lines 64-81) uses a `pl.when().then().otherwise()` pattern because it guards on `titulaire_typeIdentifiant` being SIRET or null. The observatoire link must be appended inside the `.then()` branch, and only when `col == "titulaire_nom"`. Note: this block requires `titulaire_typeIdentifiant` to be present in the DataFrame.
- [ ] **Step 1: Write a unit test for the observatoire link in titulaire_nom**
```python
def test_005_add_links_observatoire_titulaire():
import polars as pl
from src.utils import add_links
dff = pl.DataFrame(
{
"titulaire_id": ["t1"],
"titulaire_nom": ["TITULAIRE 1"],
"titulaire_typeIdentifiant": ["SIRET"],
}
)
result = add_links(dff)
nom_value = result["titulaire_nom"][0]
id_value = result["titulaire_id"][0]
# titulaire_nom should contain detail link + observatoire link
assert "/titulaires/t1" in nom_value
assert "TITULAIRE 1" in nom_value
assert '/observatoire?titulaire_id=t1' in nom_value
assert "📊" in nom_value
# titulaire_id should NOT contain observatoire link
assert "/observatoire" not in id_value
```
- [ ] **Step 2: Run test to verify it fails**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
Expected: FAIL — `'/observatoire?titulaire_id=t1'` not found.
- [ ] **Step 3: Implement the observatoire link for titulaire_nom**
In `src/utils.py`, modify the `if col.startswith("titulaire_")` block (lines 64-81). The `.then()` branch must build the link differently when `col == "titulaire_nom"`:
```python
if col.startswith("titulaire_"):
detail_link = (
'<a href = "/titulaires/'
+ pl.col("titulaire_id")
+ '">'
+ pl.col(col)
+ "</a>"
)
if col == "titulaire_nom":
detail_link = (
detail_link
+ ' <a href="/observatoire?titulaire_id='
+ pl.col("titulaire_id")
+ '" title="Voir dans l\'observatoire">📊</a>'
)
dff = dff.with_columns(
pl.when(
pl.Expr.or_(
pl.col("titulaire_typeIdentifiant").is_null(),
pl.col("titulaire_typeIdentifiant") == "SIRET",
)
)
.then(detail_link)
.otherwise(pl.col(col))
.alias(col)
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
Expected: PASS
- [ ] **Step 5: Run all tests so far to check for regressions**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
Expected: both PASS
- [ ] **Step 6: Commit**
```bash
git add src/utils.py tests/test_main.py
git commit -m "Ajout du lien observatoire dans titulaire_nom via add_links() #65"
```
---
### Task 3: Observatoire Callback A — URL → Inputs (page load)
**Files:**
- Modify: `src/pages/observatoire.py` (add import + new callback after line 281)
- Test: `tests/test_main.py`
**Context:** The existing `dcc.Location(id="dashboard_url")` is in the observatoire layout. A new callback reads `dashboard_url.search` on page load, parses query params, and sets `dashboard_acheteur_id.value` and/or `dashboard_titulaire_id.value`. It also clears `dashboard_url.search` to `""` to prevent re-triggering. Two imports must be added: `import urllib.parse` at the top of the file, and `no_update` to the existing `from dash import ...` line (currently: `from dash import ALL, Input, Output, State, callback, ctx, dcc, html, register_page` — add `no_update` to this).
- [ ] **Step 1: Write a Selenium test for URL → Input sync**
This test navigates to `/observatoire?acheteur_id=a1` and verifies the SIRET input gets populated.
```python
def test_006_observatoire_url_to_input(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)
# Navigate to observatoire with acheteur_id query param
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
dash_duo.wait_for_text_to_equal(
"#dashboard_acheteur_id", "", timeout=4
) # Wait for callback
import time
time.sleep(1) # Allow callback chain to complete
assert acheteur_input.get_attribute("value") == "a1", (
"acheteur_id input should be populated from URL param"
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
Expected: FAIL — the input value is empty because no callback reads URL params yet.
- [ ] **Step 3: Implement Callback A**
Add `import urllib.parse` to the imports at the top of `src/pages/observatoire.py` (after line 1). Also add `no_update` to the existing dash import line:
```python
from dash import ALL, Input, Output, State, callback, ctx, dcc, html, no_update, register_page
```
Add the callback after the `layout` list ends, before existing callbacks:
```python
@callback(
Output("dashboard_acheteur_id", "value"),
Output("dashboard_titulaire_id", "value"),
Output("dashboard_url", "search"),
Input("dashboard_url", "search"),
)
def restore_filters_from_url(search):
if not search:
return no_update, no_update, no_update
params = urllib.parse.parse_qs(search.lstrip("?"))
acheteur_id = params.get("acheteur_id", [None])[0] or no_update
titulaire_id = params.get("titulaire_id", [None])[0] or no_update
return acheteur_id, titulaire_id, ""
```
- [ ] **Step 4: Run test to verify it passes**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/pages/observatoire.py tests/test_main.py
git commit -m "Callback URL → filtres sur la page observatoire #65"
```
---
### Task 4: Observatoire Callback B — Inputs → shareable URL + layout
**Files:**
- Modify: `src/pages/observatoire.py` (add layout components + new callback)
- Test: `tests/test_main.py`
**Context:** Following the tableau.py pattern (lines 237-238 for layout, lines 399-450 for callback), add a hidden `share-url` input and a `copy-container` div to the observatoire layout. The callback listens to the ID inputs and builds a shareable URL. Component IDs must be unique across the app, so use `observatoire-share-url` and `observatoire-copy-container` to avoid collisions with tableau's `share-url` and `copy-container`.
- [ ] **Step 1: Write a test for the shareable URL generation**
```python
def test_007_observatoire_share_url(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)
# Navigate to observatoire with acheteur_id query param
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
dash_duo.wait_for_element("#observatoire-share-url", timeout=4)
import time
time.sleep(1) # Allow callback chain to complete
share_url_input = dash_duo.find_element("#observatoire-share-url")
share_url_value = share_url_input.get_attribute("value")
assert "acheteur_id=a1" in share_url_value, (
f"Share URL should contain acheteur_id param, got: {share_url_value}"
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
Expected: FAIL — `#observatoire-share-url` element does not exist yet.
- [ ] **Step 3: Add layout components to observatoire**
In `src/pages/observatoire.py`, add the share URL input and copy container inside the filters column (after the download button, before the closing `]` of the `id="filters"` children list, around line 264):
```python
dcc.Input(
id="observatoire-share-url",
readOnly=True,
style={"display": "none"},
),
html.Div(id="observatoire-copy-container"),
```
- [ ] **Step 4: Implement Callback B**
Add after Callback A in `src/pages/observatoire.py`:
```python
@callback(
Output("observatoire-share-url", "value"),
Output("observatoire-copy-container", "children"),
Input("dashboard_acheteur_id", "value"),
Input("dashboard_titulaire_id", "value"),
State("dashboard_url", "href"),
prevent_initial_call=True,
)
def sync_observatoire_share_url(acheteur_id, titulaire_id, href):
if not href:
return no_update, no_update
base_url = href.split("?")[0]
params = {}
if acheteur_id:
params["acheteur_id"] = acheteur_id
if titulaire_id:
params["titulaire_id"] = titulaire_id
query_string = urllib.parse.urlencode(params)
full_url = f"{base_url}?{query_string}" if query_string else base_url
copy_button = dcc.Clipboard(
id="btn-copy-observatoire-url",
target_id="observatoire-share-url",
title="Copier l'URL de cette vue",
style={
"display": "inline-block",
"fontSize": 20,
"verticalAlign": "top",
"cursor": "pointer",
},
className="fa fa-link",
children=[
dbc.Button(
"Partager",
className="btn btn-primary mt-2",
title="Copier l'adresse de cette vue filtrée pour la partager.",
)
],
)
return full_url, copy_button
```
- [ ] **Step 5: Run test to verify it passes**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
Expected: PASS
- [ ] **Step 6: Run all tests to check for regressions**
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
Expected: all tests PASS
- [ ] **Step 7: Commit**
```bash
git add src/pages/observatoire.py tests/test_main.py
git commit -m "URL partageable pour la page observatoire #65"
```
---
### Task 5: End-to-end integration test
**Files:**
- Test: `tests/test_main.py`
**Context:** Verify the full flow: search for an organization on the homepage, see the 📊 link in results, click it, arrive on the observatoire with the correct input populated.
- [ ] **Step 1: Write end-to-end test**
```python
def test_008_search_to_observatoire(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)
# Search for an acheteur
search_bar = dash_duo.find_element("#search")
search_bar.send_keys("ACHETEUR 1")
search_bar.send_keys(Keys.ENTER)
dash_duo.wait_for_element("#results_acheteur_datatable", timeout=2)
# Find the observatoire link in acheteur_nom column
observatoire_link = dash_duo.find_element(
'#results_acheteur_datatable td[data-dash-column="acheteur_nom"] a[href*="observatoire"]'
)
assert "📊" in observatoire_link.text
# Click the observatoire link
observatoire_link.click()
# Wait for observatoire page to load
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
import time
time.sleep(1) # Allow callback chain to complete
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
assert acheteur_input.get_attribute("value") == "a1", (
"acheteur_id input should be populated after navigating from search"
)
```
- [ ] **Step 2: Run end-to-end test**
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_008_search_to_observatoire -v`
Expected: PASS
- [ ] **Step 3: Run the full test suite**
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
Expected: all tests PASS
- [ ] **Step 4: Commit**
```bash
git add tests/test_main.py
git commit -m "Test e2e : recherche → observatoire #65"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,838 @@
# Tableau prepare_table_data Cache 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:** Make page navigation, sort changes, and repeated filter visits in the `/tableau` page near-instant by memoizing the expensive filter+sort+post-process pipeline inside `prepare_table_data`.
**Architecture:** Extract a memoized inner function `_load_filter_sort_postprocess(filter_query, sort_by_key)` that performs the heavy work (load full data, filter, sort, collect, cast-to-string, fill-null, add HTML links, format values) and returns a fully post-processed Polars DataFrame. The outer `prepare_table_data` becomes a thin wrapper that handles non-deterministic side effects (`track_search`, `uuid.uuid4()` for cleanup trigger, `data_timestamp + 1`) and pagination. The memoized helper only runs when no `data` argument is passed (i.e., the Tableau path). Other callers (`acheteur`, `titulaire`, `observatoire`) keep the current uncached path because they pass an externally-provided LazyFrame that is not safely hashable for cache keys.
**Tech Stack:** Polars (LazyFrame, DataFrame), Flask-Caching (`@cache.memoize()` on `FileSystemCache` already configured in `src/app.py:38`), pytest for unit tests.
**Git**: the issue id is #72, add the reference in commit messages.
---
## Background and constraints
Read these before starting; they explain why the design takes the shape it does.
1. **Cache infrastructure is already wired.** `src/cache.py` defines `cache = Cache()`. `src/app.py:38-48` initializes it with `FileSystemCache`, default 24h timeout, `CACHE_THRESHOLD=300`. The cache directory is wiped on every restart (`rmtree` at `src/app.py:36`), so cache always starts empty.
2. **Existing pattern to mirror.** `src/pages/observatoire.py:650-660` already uses `@cache.memoize()` plus a `_normalize_filter_params` helper that converts a dict of filters into a hashable tuple. This plan applies the same idiom to `sort_by` (which is a `list[dict]` from Dash DataTable).
3. **Non-deterministic outputs that MUST stay outside the memoized function:**
- `data_timestamp + 1` (increments each call; would freeze if cached)
- `trigger_cleanup = str(uuid.uuid4())` (intentionally unique per call to fire the clientside filter-cleanup callback)
- `track_search(filter_query, source_table)` — Matomo HTTP POST, currently called inside `filter_table_data` at `src/utils/table.py:214`. Must fire on every user action including cache hits.
4. **Tracking call site move.** `track_search` must move OUT of `filter_table_data` and into each caller, otherwise cache hits would silently skip Matomo tracking. Current callers of `filter_table_data` to update:
- `src/utils/table.py:402` (inside `prepare_table_data`)
- `src/pages/tableau.py:325` (`download_data` callback)
- `src/pages/acheteur.py:427` (`download_data_acheteur` callback)
- `src/pages/titulaire.py:443` (`download_data_titulaire` callback)
5. **Why Tableau-only caching.** `prepare_table_data` is also called from `acheteur.py`, `titulaire.py`, `observatoire.py`. Those callers pass a pre-filtered LazyFrame or list-of-dicts as `data`. Hashing arbitrary LazyFrames or large lists for memoization is impractical. The fix gates on `data is None` (the Tableau path) and leaves the other paths byte-for-byte identical.
6. **Cache key composition.** The memoized function takes only `(filter_query, sort_by_key)`. `page_current` and `page_size` are intentionally NOT in the key — pagination happens in the outer wrapper after retrieving the cached, fully post-processed frame. This means every page click and page-size change is a cache hit (the whole point of the change).
7. **Pickling.** Flask-Caching pickles arguments to form keys and pickles return values to disk. Polars `DataFrame` pickles cleanly. `LazyFrame` does not — so the memoized function must `.collect()` before returning.
8. **File path expectations.** All paths below are relative to repo root `/home/colin/git/decp.info`. Run all commands from there.
---
## File Structure
- **Modify** `src/utils/table.py` — extract memoized helper, refactor `prepare_table_data`, remove `track_search` call from `filter_table_data`.
- **Modify** `src/pages/tableau.py` — add explicit `track_search` call in `download_data`.
- **Modify** `src/pages/acheteur.py` — add explicit `track_search` call in `download_data_acheteur`.
- **Modify** `src/pages/titulaire.py` — add explicit `track_search` call in `download_data_titulaire`.
- **Create** `tests/test_table.py` — unit tests for new helpers and refactored `prepare_table_data`.
---
## Task 1: Set up unit tests for table.py
**Files:**
- Create: `tests/test_table.py`
This task scaffolds a non-Selenium pytest module so subsequent tasks can do TDD without booting a Dash server. The conftest already writes a small `tests/test.parquet` fixture (see `tests/conftest.py:10`); reuse it.
- [ ] **Step 1: Write the failing test**
Create `tests/test_table.py` with:
```python
import os
import polars as pl
import pytest
@pytest.fixture
def sample_lff():
"""Small LazyFrame with the columns needed by add_links / format_values."""
return pl.LazyFrame(
[
{
"uid": "u1",
"id": "u1",
"acheteur_id": "12345678900011",
"acheteur_nom": "Mairie de Test",
"titulaire_id": "98765432100022",
"titulaire_nom": "Entreprise Test",
"titulaire_typeIdentifiant": "SIRET",
"objet": "Travaux divers",
"montant": 12500.0,
"dateNotification": "2025-03-15",
"codeCPV": "45000000",
"dureeRestanteMois": 6,
"titulaire_distance": 42.0,
}
]
)
def test_table_module_imports():
from src.utils import table
assert hasattr(table, "prepare_table_data")
```
- [ ] **Step 2: Run test to verify it passes (sanity check)**
Run: `uv run pytest tests/test_table.py -v`
Expected: PASS for `test_table_module_imports`. (Selenium is not invoked because no `dash_duo` fixture is used.)
- [ ] **Step 3: Commit**
```bash
git add tests/test_table.py
git commit -m "test: scaffold unit tests for table utilities"
```
---
## Task 2: Move track_search out of filter_table_data
**Files:**
- Modify: `src/utils/table.py:210-274` (remove `track_search` import usage at line 214)
- Modify: `src/pages/tableau.py:317-334` (`download_data` callback)
- Modify: `src/pages/acheteur.py:425-430` area (`download_data_acheteur` callback)
- Modify: `src/pages/titulaire.py:441-446` area (`download_data_titulaire` callback)
- Modify: `tests/test_table.py` (add a test that confirms `filter_table_data` no longer calls Matomo)
`track_search` must move out so that the soon-to-be-memoized helper does not swallow tracking on cache hits. We do this BEFORE introducing caching so that the diff is small and verifiable on its own.
- [ ] **Step 1: Write the failing test**
Append to `tests/test_table.py`:
```python
def test_filter_table_data_does_not_call_track_search(monkeypatch, sample_lff):
from src.utils import table
calls = []
monkeypatch.setattr(table, "track_search", lambda *a, **kw: calls.append(a))
result = table.filter_table_data(
sample_lff, "{objet} icontains travaux", "tableau"
).collect()
assert calls == []
assert result.height == 1
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_table.py::test_filter_table_data_does_not_call_track_search -v`
Expected: FAIL (`assert calls == []` fails because `filter_table_data` currently calls `track_search` at line 214).
- [ ] **Step 3: Remove the track_search call from filter_table_data**
Edit `src/utils/table.py` — find this block:
```python
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(" && ")
```
Remove the `track_search(filter_query, filter_source)` line. Result:
```python
def filter_table_data(
lff: pl.LazyFrame, filter_query: str, filter_source: str
) -> pl.LazyFrame:
_schema = lff.collect_schema()
filtering_expressions = filter_query.split(" && ")
```
The `filter_source` parameter remains in the signature (avoids changing all callers in this task). It becomes unused; that is acceptable since callers will pass it again later if needed. Do NOT remove the `from src.utils.tracking import track_search` import yet — `prepare_table_data` will use it in Task 5.
- [ ] **Step 4: Add explicit track_search calls in download callbacks**
In `src/pages/tableau.py`, find:
```python
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff: pl.LazyFrame = query_marches().lazy()
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
lff = filter_table_data(lff, filter_query, "tab download")
```
Insert a `track_search` call so behavior is preserved. First add the import at the top of `src/pages/tableau.py` next to other `src.utils` imports:
```python
from src.utils.tracking import track_search
```
Then change the body:
```python
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff: pl.LazyFrame = query_marches().lazy()
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
track_search(filter_query, "tab download")
lff = filter_table_data(lff, filter_query, "tab download")
```
Repeat the same pattern in `src/pages/acheteur.py` (search for `filter_table_data(lff, filter_query, "ach download")`):
Add import:
```python
from src.utils.tracking import track_search
```
Wrap the call:
```python
if filter_query:
track_search(filter_query, "ach download")
lff = filter_table_data(lff, filter_query, "ach download")
```
Repeat in `src/pages/titulaire.py` (search for `filter_table_data(lff, filter_query, "titu download")`):
Add import:
```python
from src.utils.tracking import track_search
```
Wrap the call:
```python
if filter_query:
track_search(filter_query, "titu download")
lff = filter_table_data(lff, filter_query, "titu download")
```
- [ ] **Step 5: Run test to verify it passes**
Run: `uv run pytest tests/test_table.py::test_filter_table_data_does_not_call_track_search -v`
Expected: PASS.
- [ ] **Step 6: Run full unit test file to verify no regressions**
Run: `uv run pytest tests/test_table.py -v`
Expected: All tests in `test_table.py` PASS.
- [ ] **Step 7: Commit**
```bash
git add src/utils/table.py src/pages/tableau.py src/pages/acheteur.py src/pages/titulaire.py tests/test_table.py
git commit -m "refactor: move track_search out of filter_table_data into callers"
```
---
## Task 3: Add normalize_sort_by helper
**Files:**
- Modify: `src/utils/table.py` (add helper near other utility functions, e.g. after `dates_to_strings`)
- Modify: `tests/test_table.py` (add tests)
A cache key must be hashable. Dash DataTable's `sort_by` is a `list[dict]` like `[{"column_id": "montant", "direction": "asc"}, ...]`, which is not hashable. We mirror the `_normalize_filter_params` idiom from `src/pages/observatoire.py:650-657`.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_table.py`:
```python
def test_normalize_sort_by_handles_empty():
from src.utils.table import normalize_sort_by
assert normalize_sort_by(None) == ()
assert normalize_sort_by([]) == ()
def test_normalize_sort_by_returns_hashable_tuple():
from src.utils.table import normalize_sort_by
sort_by = [
{"column_id": "montant", "direction": "desc"},
{"column_id": "dateNotification", "direction": "asc"},
]
key = normalize_sort_by(sort_by)
assert key == (("montant", "desc"), ("dateNotification", "asc"))
# Must be hashable so that flask-caching can build a cache key from it
hash(key)
def test_normalize_sort_by_preserves_order():
"""Order matters for sort: [A, B] != [B, A]."""
from src.utils.table import normalize_sort_by
a_then_b = normalize_sort_by(
[{"column_id": "a", "direction": "asc"}, {"column_id": "b", "direction": "asc"}]
)
b_then_a = normalize_sort_by(
[{"column_id": "b", "direction": "asc"}, {"column_id": "a", "direction": "asc"}]
)
assert a_then_b != b_then_a
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_table.py -v -k normalize_sort_by`
Expected: FAIL with `ImportError` for `normalize_sort_by`.
- [ ] **Step 3: Implement normalize_sort_by**
Edit `src/utils/table.py`. Add this function immediately after the `dates_to_strings` function (around line 148):
```python
def normalize_sort_by(sort_by) -> tuple:
"""Convert Dash DataTable sort_by (list[dict]) into a hashable tuple
suitable for use as a cache key. Order is preserved because it determines
sort precedence."""
if not sort_by:
return ()
return tuple((entry["column_id"], entry["direction"]) for entry in sort_by)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_table.py -v -k normalize_sort_by`
Expected: 3 PASS.
- [ ] **Step 5: Commit**
```bash
git add src/utils/table.py tests/test_table.py
git commit -m "feat: add normalize_sort_by hashable cache-key helper"
```
---
## Task 4: Extract memoized post-process helper
**Files:**
- Modify: `src/utils/table.py` (add `_load_filter_sort_postprocess`, decorate with `@cache.memoize()`, import `cache`)
- Modify: `tests/test_table.py` (add tests)
Introduce the function whose result will live in the FileSystemCache. Inputs: `(filter_query, sort_by_key)`. Output: a fully post-processed, unpaginated Polars DataFrame ready to slice and convert to dicts.
This task does NOT yet wire the helper into `prepare_table_data` — that happens in Task 5. Splitting these tasks keeps each diff small and testable.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_table.py`:
```python
@pytest.fixture(autouse=True)
def reset_cache():
"""Ensure the flask-caching backend is empty between tests so that
cache-hit assertions are meaningful. Falls back to no-op when no
Flask app context is active (NullCache)."""
from utils.cache import cache
try:
cache.clear()
except RuntimeError:
# No app context — cache is NullCache, nothing to clear
pass
yield
def test_load_filter_sort_postprocess_returns_dataframe(monkeypatch, sample_lff):
from src.utils import table
monkeypatch.setattr(
table, "query_marches", lambda: sample_lff.collect()
)
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
assert isinstance(df, pl.DataFrame)
assert df.height == 1
# All values must be strings after post-processing
for col in df.columns:
assert df.schema[col] == pl.String
def test_load_filter_sort_postprocess_applies_filter(monkeypatch, sample_lff):
from src.utils import table
monkeypatch.setattr(
table, "query_marches", lambda: sample_lff.collect()
)
df = table._load_filter_sort_postprocess(
filter_query="{objet} icontains travaux", sort_by_key=()
)
assert df.height == 1
df_empty = table._load_filter_sort_postprocess(
filter_query="{objet} icontains nonexistent", sort_by_key=()
)
assert df_empty.height == 0
def test_load_filter_sort_postprocess_adds_links(monkeypatch, sample_lff):
from src.utils import table
monkeypatch.setattr(
table, "query_marches", lambda: sample_lff.collect()
)
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
# add_links injects an <a href> wrapper around uid, acheteur_nom, titulaire_nom
assert "<a href" in df["uid"][0]
assert "<a href" in df["acheteur_nom"][0]
assert "<a href" in df["titulaire_nom"][0]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_table.py -v -k load_filter_sort_postprocess`
Expected: FAIL with `AttributeError: module 'src.utils.table' has no attribute '_load_filter_sort_postprocess'`.
- [ ] **Step 3: Implement the helper**
Edit `src/utils/table.py`. Add this import near the top, with the other `src.` imports:
```python
from utils.cache import cache
```
Then add the helper function. Place it ABOVE `prepare_table_data` (around line 370, just before `def prepare_table_data`):
```python
@cache.memoize()
def _load_filter_sort_postprocess(filter_query, sort_by_key):
"""Memoized core of the Tableau page pipeline.
Loads the full marchés dataset, applies filter and sort, materializes,
then runs the per-row post-processing (cast to string, fill nulls, add
HTML links, format values). Returns an unpaginated Polars DataFrame.
Inputs MUST be hashable: filter_query is str|None, sort_by_key is the
tuple produced by normalize_sort_by(). Pagination intentionally lives
in the outer wrapper so that page changes are cache hits.
"""
logger.debug(f"Cache miss — recomputing for filter={filter_query!r} sort={sort_by_key!r}")
lff: pl.LazyFrame = query_marches().lazy()
if filter_query:
lff = filter_table_data(lff, filter_query, "tableau")
if sort_by_key:
sort_by = [
{"column_id": col, "direction": direction}
for col, direction in sort_by_key
]
lff = sort_table_data(lff, sort_by)
# The remaining steps are cheap per-row operations that we run ONCE here
# so that pagination in the outer function is a pure slice + to_dicts.
lff = lff.cast(pl.String)
lff = lff.fill_null("")
dff: pl.DataFrame = lff.collect()
dff = add_links(dff)
if "sourceFile" in dff.columns:
dff = add_resource_link(dff)
if dff.height > 0:
dff = format_values(dff)
return dff
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_table.py -v -k load_filter_sort_postprocess`
Expected: 3 PASS.
- [ ] **Step 5: Run the full test_table.py to catch regressions**
Run: `uv run pytest tests/test_table.py -v`
Expected: All PASS.
- [ ] **Step 6: Commit**
```bash
git add src/utils/table.py tests/test_table.py
git commit -m "feat: add memoized _load_filter_sort_postprocess helper"
```
---
## Task 5: Wire the memoized helper into prepare_table_data
**Files:**
- Modify: `src/utils/table.py` — replace the body of `prepare_table_data` so the Tableau path uses the cache
- Modify: `tests/test_table.py` — add tests covering the new flow
The outer function keeps its signature unchanged so callers in `acheteur.py`, `titulaire.py`, `observatoire.py`, `tableau.py` need no updates. When `data is None` (the Tableau case), use the memoized helper; otherwise fall through to the original logic.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_table.py`:
```python
def test_prepare_table_data_returns_expected_tuple(monkeypatch, sample_lff):
from src.utils import table
monkeypatch.setattr(
table, "query_marches", lambda: sample_lff.collect()
)
result = table.prepare_table_data(
data=None,
data_timestamp=5,
filter_query=None,
page_current=0,
page_size=20,
sort_by=[],
source_table="tableau",
)
# Same arity as before: 9 outputs
assert len(result) == 9
dicts, columns, tooltip, ts, nb_rows, dl_disabled, dl_text, dl_title, cleanup = result
assert isinstance(dicts, list)
assert ts == 6 # data_timestamp + 1 must still increment
assert "1 lignes" in nb_rows
def test_prepare_table_data_calls_track_search_on_filter(monkeypatch, sample_lff):
from src.utils import table
calls = []
monkeypatch.setattr(
table, "query_marches", lambda: sample_lff.collect()
)
monkeypatch.setattr(table, "track_search", lambda *a, **kw: calls.append(a))
table.prepare_table_data(
data=None,
data_timestamp=0,
filter_query="{objet} icontains travaux",
page_current=0,
page_size=20,
sort_by=[],
source_table="tableau",
)
assert calls == [("{objet} icontains travaux", "tableau")]
def test_prepare_table_data_paginates_without_recomputing(monkeypatch, sample_lff):
"""Two calls with same filter+sort but different pages must invoke
the inner heavy work only once."""
from src.utils import table
call_count = {"n": 0}
real_query = sample_lff.collect()
def counting_query():
call_count["n"] += 1
return real_query
monkeypatch.setattr(table, "query_marches", counting_query)
# First call: cache miss
table.prepare_table_data(
data=None,
data_timestamp=0,
filter_query=None,
page_current=0,
page_size=10,
sort_by=[],
source_table="tableau",
)
first_count = call_count["n"]
# Second call, different page: cache hit, query_marches must NOT fire again
table.prepare_table_data(
data=None,
data_timestamp=0,
filter_query=None,
page_current=1,
page_size=10,
sort_by=[],
source_table="tableau",
)
assert call_count["n"] == first_count, (
"query_marches was called again — pagination triggered cache miss"
)
def test_prepare_table_data_cleanup_trigger_for_non_tableau(monkeypatch, sample_lff):
"""Non-tableau pages still get a fresh uuid trigger, not no_update."""
from dash import no_update
from src.utils import table
monkeypatch.setattr(
table, "query_marches", lambda: sample_lff.collect()
)
result = table.prepare_table_data(
data=None,
data_timestamp=0,
filter_query="{objet} icontains travaux",
page_current=0,
page_size=20,
sort_by=[],
source_table="acheteur",
)
cleanup = result[8]
assert cleanup is not no_update
assert isinstance(cleanup, str)
assert len(cleanup) >= 32 # uuid4 hex string
def test_prepare_table_data_with_external_data_does_not_use_cache(
monkeypatch, sample_lff
):
"""When a caller passes data (acheteur/titulaire/observatoire path),
bypass the memoized helper entirely."""
from src.utils import table
sentinel = {"called": False}
def should_not_be_called(*a, **kw):
sentinel["called"] = True
raise AssertionError("Memoized helper must not be called when data is provided")
monkeypatch.setattr(
table, "_load_filter_sort_postprocess", should_not_be_called
)
table.prepare_table_data(
data=sample_lff, # external LazyFrame
data_timestamp=0,
filter_query=None,
page_current=0,
page_size=20,
sort_by=[],
source_table="acheteur",
)
assert sentinel["called"] is False
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_table.py -v -k prepare_table_data`
Expected: At least the cache-hit (`paginates_without_recomputing`) and `track_search`-routing tests FAIL because the current `prepare_table_data` re-runs the full pipeline on every call and routes tracking through `filter_table_data` (which Task 2 already neutralized — so tracking would be lost without the new explicit call).
- [ ] **Step 3: Refactor prepare_table_data**
Edit `src/utils/table.py`. Replace the entire `prepare_table_data` function body with:
```python
def prepare_table_data(
data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table
):
"""
Préparation des données pour les datatables.
Pour la page Tableau (data is None), le calcul lourd (chargement complet,
filtre, tri, post-traitement) est mémorisé via _load_filter_sort_postprocess.
Les changements de page deviennent ainsi des cache hits.
Pour les autres pages (data fourni), le chemin original est conservé : la
LazyFrame externe n'est pas hashable et le coût de filtre/tri y est déjà
minime puisque les données sont pré-restreintes.
"""
logger.debug(" + + + + + + + + + + + + + + + + + + ")
# Side effect non-cacheable : le tracking doit firer sur chaque action
# utilisateur, y compris sur cache hit.
if filter_query:
track_search(filter_query, source_table)
# Trigger uuid pour les pages autres que tableau (clientside cleanup)
trigger_cleanup = (
no_update if source_table == "tableau" else str(uuid.uuid4())
)
if data is None:
# Tableau path : utilise le cache
sort_by_key = normalize_sort_by(sort_by)
dff: pl.DataFrame = _load_filter_sort_postprocess(
filter_query=filter_query, sort_by_key=sort_by_key
)
else:
# acheteur / titulaire / observatoire path : code original, non caché
if isinstance(data, list):
lff: pl.LazyFrame = pl.LazyFrame(
data, strict=False, infer_schema_length=5000
)
elif isinstance(data, pl.LazyFrame):
lff = data
else:
lff = query_marches().lazy()
if filter_query:
lff = filter_table_data(lff, filter_query, source_table)
if sort_by and len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
dff = lff.collect()
dff = dff.cast(pl.String)
dff = dff.fill_null("")
dff = add_links(dff)
if "sourceFile" in dff.columns:
dff = add_resource_link(dff)
if dff.height > 0:
dff = format_values(dff)
height = dff.height
if height > 0:
nb_rows = (
f"{format_number(height)} lignes "
f"({format_number(dff.select('uid').unique().height)} marchés)"
)
else:
nb_rows = "0 lignes (0 marchés)"
# Pagination — toujours hors cache pour rester sur des cache hits
start_row = page_current * page_size
dff = dff.slice(start_row, page_size)
table_columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
download_disabled, download_text, download_title = get_button_properties(height)
return (
dicts,
table_columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
trigger_cleanup,
)
```
Notes on what changed vs the original at `src/utils/table.py:372-458`:
- `track_search` now called explicitly at the top, on every invocation (not via `filter_table_data`).
- `data is None` branch delegates the heavy work to the memoized helper.
- `data is not None` branch is functionally identical to the original (pagination still happens after collect+post-process).
- The post-processing (`cast`, `fill_null`, `add_links`, `add_resource_link`, `format_values`) is now done in BOTH branches before `nb_rows` calculation. In the cached branch this was already done inside `_load_filter_sort_postprocess`; in the uncached branch we keep doing it inline. This means `nb_rows` and `dff.select('uid').unique().height` operate on the post-processed frame in both branches, matching the original semantics.
- [ ] **Step 4: Run all unit tests**
Run: `uv run pytest tests/test_table.py -v`
Expected: All PASS, including `test_prepare_table_data_paginates_without_recomputing`.
- [ ] **Step 5: Run the full repo test suite to catch regressions**
Run: `uv run pytest -v`
Expected: All PASS. Selenium tests (`tests/test_main.py`) require Chrome/Chromium; if the executor lacks a browser, those tests will error/skip — note the failures and rerun in an environment with Chrome before declaring done.
- [ ] **Step 6: Commit**
```bash
git add src/utils/table.py tests/test_table.py
git commit -m "perf(tableau): memoize filter+sort+postprocess pipeline"
```
---
## Task 6: Manual smoke test in the browser
**Files:** none modified.
Type checks and unit tests cannot validate that page navigation actually feels faster. This task is explicitly a hands-on verification.
- [ ] **Step 1: Start the dev server**
Run: `uv run run.py`
Wait for `Dash is running on http://...`.
- [ ] **Step 2: Open the Tableau page and warm the cache**
1. Open `http://localhost:8050/tableau` (or whatever port the dev server prints).
2. With no filter applied, wait for the first page to load fully. This is the cold-cache load (slow expected).
3. Open the browser devtools Network panel.
- [ ] **Step 3: Verify pagination is fast**
1. Click "page 2" / "page 3" / "page 4" in the table footer in quick succession.
2. Each navigation should return data in well under 1 second (in the original code each took several seconds).
3. In the dev server logs, look for the line `Cache miss — recomputing for filter=...` from `_load_filter_sort_postprocess`. It should appear ONCE for the initial load and NOT appear again as you change pages.
- [ ] **Step 4: Verify a new filter triggers exactly one cache miss**
1. In the table, type a filter into one of the columns (e.g. `paris` in `acheteur_commune_nom`) and press Enter.
2. The dev log should show ONE new `Cache miss — recomputing` line.
3. Change page within the filtered view — no new cache miss line should appear.
- [ ] **Step 5: Verify filter cleanup trigger still fires**
1. Open `http://localhost:8050/acheteur?id=<some_acheteur_id>` (use any valid id from the dataset).
2. Apply a filter on the embedded table.
3. The clientside callback for filter cleanup (`src/assets/dash_clientside.js` `clean_filters`) should still rewrite the filter operators (e.g. `contains``icontains`). If it doesn't fire, the `trigger_cleanup` uuid is broken — investigate.
- [ ] **Step 6: Verify download still works**
1. On the Tableau page, click "Télécharger au format Excel" (the button must be enabled — apply a filter that brings the row count under 65,000).
2. The downloaded XLSX must open and contain the filtered rows.
- [ ] **Step 7: Stop the dev server**
Ctrl-C.
- [ ] **Step 8: If all checks pass, this completes the implementation**
No commit — this task is verification only. Report results to the user.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,951 @@
# Observatoire — filtrage natif DuckDB — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer le filtrage Polars sur LazyFrame dans `prepare_dashboard_data` par un requêtage natif DuckDB, pour ne matérialiser que le sous-ensemble utile au lieu de l'intégralité de la table `decp` (~1,5 M lignes).
**Architecture:** Nouveau helper pur `dashboard_filters_to_sql(**filter_params) -> (where_sql, params)` dans `src/utils/table_sql.py` (modèle de `filter_query_to_sql`). `prepare_dashboard_data` devient une fonction fine qui appelle `query_marches(where_sql, params)` et retourne une `pl.DataFrame`. Les 3 appelants dans `src/pages/observatoire.py` sont adaptés à la nouvelle signature.
**Tech Stack:** Python 3.12, Polars, DuckDB, Dash, pytest.
**Spec:** `docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md`.
---
## File Structure
**À créer :**
- `tests/test_dashboard_filters_to_sql.py` — tests unitaires du nouveau helper SQL (cas vide + cas par filtre).
- `tests/test_prepare_dashboard_data.py` — test d'intégration léger (appel DuckDB réel sur `tests/test.parquet`).
**À modifier :**
- `src/utils/table_sql.py` — ajouter `dashboard_filters_to_sql` + import `datetime`/`timedelta`.
- `src/utils/data.py` — réécrire `prepare_dashboard_data` (signature et implémentation), ajouter `query_marches` aux imports `from src.db`.
- `src/pages/observatoire.py` — adapter 3 sites d'appel (lignes ~668, ~791, ~882) ; retirer `query_marches` de l'import `from src.db` (plus utilisé).
- `tests/test_main.py` — supprimer `test_010_observatoire_montant_filter` (migré en test unitaire du helper).
---
## Task 1: Tests unitaires — cas par défaut + filtre année
**Files:**
- Create: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Write the failing tests**
Create `tests/test_dashboard_filters_to_sql.py`:
```python
from datetime import datetime, timedelta
from src.utils.table_sql import dashboard_filters_to_sql
def test_no_filters_uses_default_365_day_window():
where_sql, params = dashboard_filters_to_sql()
assert where_sql == '"dateNotification" > ?'
assert len(params) == 1
assert isinstance(params[0], datetime)
expected = datetime.now() - timedelta(days=365)
assert abs((params[0] - expected).total_seconds()) < 2
def test_year_filter_overrides_default_window():
where_sql, params = dashboard_filters_to_sql(dashboard_year="2025")
assert where_sql == 'YEAR("dateNotification") = ?'
assert params == [2025]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: FAIL with `ImportError: cannot import name 'dashboard_filters_to_sql'`.
- [ ] **Step 3: Implement the helper**
Add to the top of `src/utils/table_sql.py` (below existing imports):
```python
from datetime import datetime, timedelta
```
Append this function at the end of `src/utils/table_sql.py`:
```python
def dashboard_filters_to_sql(
dashboard_year=None,
dashboard_acheteur_id=None,
dashboard_acheteur_categorie=None,
dashboard_acheteur_departement_code=None,
dashboard_titulaire_id=None,
dashboard_titulaire_categorie=None,
dashboard_titulaire_departement_code=None,
dashboard_marche_type=None,
dashboard_marche_objet=None,
dashboard_marche_code_cpv=None,
dashboard_marche_considerations_sociales=None,
dashboard_marche_considerations_environnementales=None,
dashboard_marche_techniques=None,
dashboard_marche_innovant=None,
dashboard_marche_sous_traitance_declaree=None,
dashboard_montant_min=None,
dashboard_montant_max=None,
) -> tuple[str, list]:
"""Traduit les filtres du tableau de bord en (where_clause, params) DuckDB."""
clauses: list[str] = []
params: list = []
if dashboard_year:
clauses.append('YEAR("dateNotification") = ?')
params.append(int(dashboard_year))
else:
clauses.append('"dateNotification" > ?')
params.append(datetime.now() - timedelta(days=365))
return " AND ".join(clauses), params
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): squelette de dashboard_filters_to_sql (#72)"
```
---
## Task 2: Filtres d'égalité simples (catégorie, type, innovant, sous-traitance)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_marche_type_equality():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_type="Marché",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?'
assert params == [2025, "Marché"]
def test_innovant_value_all_is_skipped():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_innovant="all",
)
assert where_sql == 'YEAR("dateNotification") = ?'
assert params == [2025]
def test_innovant_value_oui_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_innovant="oui",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?'
assert params == [2025, "oui"]
def test_sous_traitance_value_non_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_sous_traitance_declaree="non",
)
assert (
where_sql
== 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?'
)
assert params == [2025, "non"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 4 new tests FAIL (missing clauses).
- [ ] **Step 3: Extend the helper**
Insert the following block in `dashboard_filters_to_sql`, **after** the `if dashboard_year / else` block and **before** `return " AND ".join(clauses), params`:
```python
if dashboard_marche_type:
clauses.append('"type" = ?')
params.append(dashboard_marche_type)
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
clauses.append('"marcheInnovant" = ?')
params.append(dashboard_marche_innovant)
if (
dashboard_marche_sous_traitance_declaree
and dashboard_marche_sous_traitance_declaree != "all"
):
clauses.append('"sousTraitanceDeclaree" = ?')
params.append(dashboard_marche_sous_traitance_declaree)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (6 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres d'égalité simples dans dashboard_filters_to_sql (#72)"
```
---
## Task 3: Filtres LIKE/ILIKE (ids, objet, cpv)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_acheteur_id_uses_like_wildcards():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_id="12345678900010",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
assert params == [2025, "%12345678900010%"]
def test_titulaire_id_uses_like_wildcards():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_id="999",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
assert params == [2025, "%999%"]
def test_marche_objet_uses_case_insensitive_ilike():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_objet="travaux",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "objet" ILIKE ?'
assert params == [2025, "%travaux%"]
def test_code_cpv_uses_prefix_like():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_code_cpv="4521",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?'
assert params == [2025, "4521%"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 4 new tests FAIL.
- [ ] **Step 3: Extend the helper**
Insert the following block, **just after** the year/default block and **before** the `if dashboard_marche_type` block:
```python
if dashboard_acheteur_id:
clauses.append('"acheteur_id" LIKE ?')
params.append(f"%{dashboard_acheteur_id}%")
if dashboard_titulaire_id:
clauses.append('"titulaire_id" LIKE ?')
params.append(f"%{dashboard_titulaire_id}%")
```
Insert in the "marché" block, **after** `dashboard_marche_type` and **before** `dashboard_marche_innovant`:
```python
if dashboard_marche_objet:
clauses.append('"objet" ILIKE ?')
params.append(f"%{dashboard_marche_objet}%")
if dashboard_marche_code_cpv:
clauses.append('"codeCPV" LIKE ?')
params.append(f"{dashboard_marche_code_cpv}%")
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (10 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres LIKE/ILIKE dans dashboard_filters_to_sql (#72)"
```
---
## Task 4: Filtre IN (départements) + skip conditionnel par ID
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_acheteur_departement_multiple_uses_in_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_departement_code=["75", "92", "93"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
'AND "acheteur_departement_code" IN (?, ?, ?)'
)
assert params == [2025, "75", "92", "93"]
def test_acheteur_categorie_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_categorie="Commune",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?'
assert params == [2025, "Commune"]
def test_titulaire_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_categorie="PME",
dashboard_titulaire_departement_code=["35"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
'AND "titulaire_categorie" = ? '
'AND "titulaire_departement_code" IN (?)'
)
assert params == [2025, "PME", "35"]
def test_acheteur_id_present_skips_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_id="123",
dashboard_acheteur_categorie="Commune",
dashboard_acheteur_departement_code=["75"],
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
assert params == [2025, "%123%"]
def test_titulaire_id_present_skips_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_id="999",
dashboard_titulaire_categorie="PME",
dashboard_titulaire_departement_code=["35"],
)
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
assert params == [2025, "%999%"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 5 new tests FAIL.
- [ ] **Step 3: Refactor the helper with conditional skip**
Replace the two simple `if dashboard_acheteur_id` / `if dashboard_titulaire_id` blocks added in Task 3 with the nested form:
```python
if dashboard_acheteur_id:
clauses.append('"acheteur_id" LIKE ?')
params.append(f"%{dashboard_acheteur_id}%")
else:
if dashboard_acheteur_categorie:
clauses.append('"acheteur_categorie" = ?')
params.append(dashboard_acheteur_categorie)
if dashboard_acheteur_departement_code:
placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code))
clauses.append(f'"acheteur_departement_code" IN ({placeholders})')
params.extend(dashboard_acheteur_departement_code)
if dashboard_titulaire_id:
clauses.append('"titulaire_id" LIKE ?')
params.append(f"%{dashboard_titulaire_id}%")
else:
if dashboard_titulaire_categorie:
clauses.append('"titulaire_categorie" = ?')
params.append(dashboard_titulaire_categorie)
if dashboard_titulaire_departement_code:
placeholders = ", ".join(
["?"] * len(dashboard_titulaire_departement_code)
)
clauses.append(f'"titulaire_departement_code" IN ({placeholders})')
params.extend(dashboard_titulaire_departement_code)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (15 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): IN départements et skip conditionnel par ID (#72)"
```
---
## Task 5: Filtre liste (techniques, considérations sociales/environnementales)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_marche_techniques_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_techniques=["Enchère", "Accord-cadre"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Enchère", "Accord-cadre"]]
def test_considerations_sociales_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_considerations_sociales=["Clause sociale"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Clause sociale"]]
def test_considerations_environnementales_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_considerations_environnementales=["Clause env."],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Clause env."]]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 3 new tests FAIL.
- [ ] **Step 3: Extend the helper**
Insert the following block in `dashboard_filters_to_sql`, **after** the `dashboard_marche_sous_traitance_declaree` block and **before** `return " AND ".join(clauses), params`:
```python
if dashboard_marche_techniques:
clauses.append(
"list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_techniques))
if dashboard_marche_considerations_sociales:
clauses.append(
"list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_considerations_sociales))
if dashboard_marche_considerations_environnementales:
clauses.append(
"list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_considerations_environnementales))
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (18 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres liste via list_has_any (#72)"
```
---
## Task 6: Filtres montant min/max (incluant 0)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_montant_min_only():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=1000,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
assert params == [2025, 1000]
def test_montant_max_only():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_max=500,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?'
assert params == [2025, 500]
def test_montant_zero_is_a_valid_lower_bound():
# 0 est falsy mais reste un filtre valide (distinct de None)
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=0,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
assert params == [2025, 0]
def test_montant_min_and_max_combined():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=100,
dashboard_montant_max=1000,
)
assert where_sql == (
'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?'
)
assert params == [2025, 100, 1000]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 4 new tests FAIL.
- [ ] **Step 3: Extend the helper**
Insert at the very end of `dashboard_filters_to_sql`, **just before** `return " AND ".join(clauses), params`:
```python
if dashboard_montant_min is not None:
clauses.append('"montant" >= ?')
params.append(dashboard_montant_min)
if dashboard_montant_max is not None:
clauses.append('"montant" <= ?')
params.append(dashboard_montant_max)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (22 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres montant min/max (#72)"
```
---
## Task 7: Réécriture de `prepare_dashboard_data`
**Files:**
- Modify: `src/utils/data.py`
- Modify: `tests/test_main.py` (supprimer `test_010_observatoire_montant_filter`)
- [ ] **Step 1: Remove the obsolete Polars-based test**
Delete the function `test_010_observatoire_montant_filter` from `tests/test_main.py` (lines ~218-256). La couverture du filtre montant est déjà assurée par les tests unitaires `test_montant_*` de la Task 6.
- [ ] **Step 2: Rewrite `prepare_dashboard_data`**
Replace the entire `prepare_dashboard_data` function in `src/utils/data.py` (lines ~86-194) with:
```python
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
"""Exécute la requête DuckDB filtrée pour le tableau de bord.
Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble
correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame
appellent `.lazy()` sur le résultat.
"""
from src.utils.table_sql import dashboard_filters_to_sql
where_sql, params = dashboard_filters_to_sql(**filter_params)
return query_marches(where_sql=where_sql, params=params)
```
Update the import at the top of `src/utils/data.py`:
```python
from src.db import get_cursor, query_marches, schema
```
Remove the now-unused import in `src/utils/data.py`:
```python
from datetime import datetime, timedelta
```
(Si `datetime` n'est plus référencé dans `data.py` hors de `prepare_dashboard_data`, sinon garder.)
**Vérification rapide à effectuer avant de supprimer `datetime`/`timedelta`** :
```bash
rtk grep -n "datetime\|timedelta" src/utils/data.py
```
Si d'autres occurrences existent, conserver les imports.
- [ ] **Step 3: Run the full test suite**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py tests/test_main.py -v -k "not selenium and not dash_duo"`
Ou, si filter n'est pas pratique :
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (22 tests).
- [ ] **Step 4: Commit**
```bash
rtk pre-commit run --files src/utils/data.py tests/test_main.py
rtk git add src/utils/data.py tests/test_main.py
rtk git commit -m "refactor(observatoire): prepare_dashboard_data utilise DuckDB (#72)"
```
---
## Task 8: Adaptation des 3 appelants dans `observatoire.py`
**Files:**
- Modify: `src/pages/observatoire.py`
- [ ] **Step 1: Update `_compute_dashboard_children`**
Remplacer dans `src/pages/observatoire.py` (autour des lignes 660-670) :
```python
@cache.memoize()
def _compute_dashboard_children(filter_params_normalized: tuple):
logger.debug("Cache miss — computing dashboard")
filter_params = {
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
}
lff: pl.LazyFrame = query_marches().lazy()
lff = prepare_dashboard_data(lff=lff, **filter_params)
dff = lff.collect(engine="streaming")
```
Par :
```python
@cache.memoize()
def _compute_dashboard_children(filter_params_normalized: tuple):
logger.debug("Cache miss — computing dashboard")
filter_params = {
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
}
dff = prepare_dashboard_data(**filter_params)
lff = dff.lazy()
```
Le reste de la fonction (à partir de `df_per_uid = ...`) est inchangé.
- [ ] **Step 2: Update `download_observatoire`**
Remplacer dans `src/pages/observatoire.py` (autour des lignes 789-800) :
```python
def download_observatoire(_n_clicks, filter_params, hidden_columns):
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
if hidden_columns:
lff = lff.drop(hidden_columns)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
```
Par :
```python
def download_observatoire(_n_clicks, filter_params, hidden_columns):
dff = prepare_dashboard_data(**(filter_params or {}))
if hidden_columns:
dff = dff.drop(hidden_columns)
def to_bytes(buffer):
dff.write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
```
- [ ] **Step 3: Update `populate_preview_table`**
Remplacer dans `src/pages/observatoire.py` (autour des lignes 879-892) :
```python
if not is_open:
return (no_update,) * 9
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
return prepare_table_data(
lff,
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"observatoire-preview",
)
```
Par :
```python
if not is_open:
return (no_update,) * 9
dff = prepare_dashboard_data(**(filter_params or {}))
return prepare_table_data(
dff.lazy(),
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"observatoire-preview",
)
```
- [ ] **Step 4: Remove unused `query_marches` import**
Dans `src/pages/observatoire.py`, ligne ~19 :
```python
from src.db import query_marches, schema
```
Devient :
```python
from src.db import schema
```
Vérifier avant de committer :
```bash
rtk grep -n "query_marches" src/pages/observatoire.py
```
Expected: aucun résultat (ou uniquement des commentaires).
- [ ] **Step 5: Smoke test**
Démarrer l'app et naviguer sur `/observatoire`, vérifier à la main que :
- Les cartes s'affichent.
- Un filtre année se propage.
- Un filtre acheteur par SIRET partiel fonctionne.
- Un filtre département (multi-valeur) fonctionne.
- Un filtre montant_min fonctionne.
- Le bouton « Télécharger au format Excel » génère un fichier non vide.
- Le bouton « Voir les données » ouvre l'offcanvas et peuple la table.
Run: `python run.py`
Expected: app démarre sans erreur ; les filtres se comportent comme avant.
- [ ] **Step 6: Commit**
```bash
rtk pre-commit run --files src/pages/observatoire.py
rtk git add src/pages/observatoire.py
rtk git commit -m "refactor(observatoire): appelants utilisent la nouvelle signature (#72)"
```
---
## Task 9: Test d'intégration — `prepare_dashboard_data` sur `tests/test.parquet`
**Files:**
- Create: `tests/test_prepare_dashboard_data.py`
- [ ] **Step 1: Write the failing test**
Le but : vérifier que la fonction s'exécute réellement contre DuckDB, retourne une `pl.DataFrame`, et applique bien les filtres simples. `conftest.py` construit `tests/test.parquet` avec un jeu de données d'une ligne : acheteur_id `123`, acheteur_departement_code `75`, dateNotification `2025-01-01`, montant `10`.
Create `tests/test_prepare_dashboard_data.py`:
```python
import polars as pl
def test_returns_dataframe_with_year_filter():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(dashboard_year="2025")
assert isinstance(dff, pl.DataFrame)
assert dff.height == 1
def test_year_mismatch_returns_empty():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(dashboard_year="2024")
assert isinstance(dff, pl.DataFrame)
assert dff.height == 0
def test_acheteur_id_partial_match():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(
dashboard_year="2025",
dashboard_acheteur_id="12",
)
assert dff.height == 1
def test_departement_in_clause():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(
dashboard_year="2025",
dashboard_acheteur_departement_code=["75", "92"],
)
assert dff.height == 1
def test_montant_min_above_value_excludes_row():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(
dashboard_year="2025",
dashboard_montant_min=1000,
)
assert dff.height == 0
```
- [ ] **Step 2: Run the test**
Run: `rtk pytest tests/test_prepare_dashboard_data.py -v`
Expected: PASS (5 tests).
- [ ] **Step 3: Commit**
```bash
rtk pre-commit run --files tests/test_prepare_dashboard_data.py
rtk git add tests/test_prepare_dashboard_data.py
rtk git commit -m "test(observatoire): intégration DuckDB pour prepare_dashboard_data (#72)"
```
---
## Task 10: Vérification finale
**Files:** (aucune modification)
- [ ] **Step 1: Run the full test suite**
Run: `rtk pytest -v`
Expected: tous les tests unitaires passent. Les tests Selenium peuvent échouer si Chrome n'est pas disponible — ce n'est pas bloquant s'ils étaient déjà rouges avant.
- [ ] **Step 2: Check for leftover references**
Run: `rtk grep -rn "prepare_dashboard_data(lff" src/ tests/`
Expected: aucun résultat (plus d'appels avec l'ancienne signature).
Run: `rtk grep -rn "query_marches().lazy()" src/`
Expected: aucun résultat (ou uniquement dans `src/utils/table.py:prepare_table_data` pour le fallback).
- [ ] **Step 3: Confirm `datetime`/`timedelta` in data.py if needed**
Run: `rtk grep -n "datetime\|timedelta" src/utils/data.py`
Si aucune occurrence hors imports, vérifier que les imports inutiles ont bien été retirés dans Task 7.
- [ ] **Step 4: Manual timing sanity check (optionnel)**
Si possible, comparer informellement le temps de `_compute_dashboard_children` sur un filtre sélectif (ex. un département) avant/après. Pas de benchmark formel attendu.
- [ ] **Step 5: Push (manuel, à l'initiative de l'utilisateur)**
Conformément aux consignes projet, ne jamais `git push`. Laisser l'utilisateur pousser la branche `feature/72_observatoire_duckdb_filters` et ouvrir la PR.
@@ -0,0 +1,108 @@
# Plan: Ajouter des cartes de localisation aux pages acheteur et titulaire
## Date: 2026-04-28
## Statut: Approuvé
## Objectif: Ajouter des cartes interactives montrant la localisation des organisations sur les pages acheteur et titulaire
## Contexte
- Les pages acheteur et titulaire ont déjà des placeholders pour les cartes (`acheteur_map` et `titulaire_map`)
- La fonction `point_on_map()` existe déjà dans `src/figures.py` mais utilise un centrage fixe sur la France
- Les données de localisation proviennent de l'API Annuaire des Entreprises
- Les codes départementaux sont disponibles et plus fiables que les coordonnées pour la détection de région
## Exigences
### 1. Carte interactive
- **Localisation**: Colonne de droite dans la section d'informations sur l'organisation
- **Taille**: 400px de largeur × 300px de hauteur (fixe)
- **Contenu**: Carte centrée sur la France ou le département d'outre-mer approprié avec un point rouge à l'emplacement de l'organisation
- **Niveau de zoom**: Approprié pour montrer l'Hexagone ou le département d'outre-mer spécifique
- **Style**: Fond de carte clair avec point rouge visible
- **Interactivité**: Carte zoomable et déplaçable (pas de configuration statique)
### 2. Sources de données
- Utiliser les colonnes `acheteur_latitude` et `acheteur_longitude` pour les pages acheteur
- Utiliser les colonnes `titulaire_latitude` et `titulaire_longitude` pour les pages titulaire
- Utiliser les codes départementaux (`acheteur_departement_code`, `titulaire_departement_code`) pour la détection de région
- Solution de repli: Si les coordonnées ou codes départementaux sont manquants ou invalides, afficher une div vide
### 3. Détection de région
- **Départements métropolitains**: Codes à 2 caractères (ex: "75" pour Paris) → Carte Hexagone
- **Départements d'outre-mer**:
- "971" → Guadeloupe
- "972" → Martinique
- "973" → Guyane
- "974" → La Réunion
- "976" → Mayotte
- **Code département manquant**: Retourner une div vide (pas de détection basée sur les coordonnées)
### 4. Gestion des erreurs
- Coordonnées invalides → div vide
- Code département manquant → div vide
- Échec de l'API Annuaire → div vide (comportement existant)
- Format de code département invalide → div vide
## Implémentation
### Fichiers à modifier
#### 1. `src/figures.py` - Améliorer la fonction `point_on_map()`
**Ligne 178-209**: Remplacer la fonction existante par une version améliorée avec:
- Détection de région basée sur les codes départementaux
- Configuration de carte interactive (zoomable)
- Point plus grand (size=15)
- Commentaires en français
#### 2. `src/pages/acheteur.py` - Mettre à jour le callback
**Ligne 249-297**: Modifier `update_acheteur_infos()` pour:
- Extraire le code département du code postal
- Passer le code département à `point_on_map()`
- Ajouter des commentaires en français
#### 3. `src/pages/titulaire.py` - Mettre à jour le callback
**Ligne 259-297**: Modifier `update_titulaire_infos()` pour:
- Extraire le code département du code postal
- Passer le code département à `point_on_map()`
- Ajouter des commentaires en français
## Plan de Test
### Cas de test prioritaires
1. **Organisation métropolitaine**: Code département "75" (Paris) → Carte Hexagone
2. **Organisation à La Réunion**: Code département "974" → Carte centrée sur La Réunion
3. **Code département manquant**: Retourne une div vide
4. **Coordonnées invalides**: Retourne une div vide
5. **Interactivité**: Vérifier zoom et déplacement
### Critères d'acceptation
- [ ] Cartes fonctionnelles avec codes départementaux valides
- [ ] Div vide pour codes manquants/invalides
- [ ] Cartes correctement centrées et zoomées
- [ ] Interactivité (zoom et déplacement)
- [ ] Point de localisation visible (size=15)
## Approbation
Plan approuvé avec spécifications:
- Réutiliser et améliorer `point_on_map`
- Retourner div vide sans code département
- Point légèrement plus grand
- Cartes zoomables
- Utiliser codes départementaux pour détection de région
- Commentaires en français
@@ -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,64 @@
# Distance Histogram — Design Spec
**Date:** 2026-03-18
**Branch:** feature/65_observatoire
## Goal
Display the distribution of distances (in km) between buyers and winning contractors, to help users assess whether a buyer or contractor tends to deal locally or at a national scale.
## Data
- Column: `titulaire_distance` (`Int64`, km)
- Measured at address level — values are always > 0, no zero-handling needed
- Already selected in the observatoire LazyFrame via `cs.starts_with("titulaire")`
- Already available on acheteur and titulaire detail pages
## Figure Function
**Location:** `src/figures.py`
**Signature:**
```python
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
```
**Behaviour:**
- Collects `titulaire_distance` from the LazyFrame, drops nulls
- If the resulting DataFrame is empty after dropping nulls, `px.histogram` produces a blank figure without errors — no guard logic needed. The order of operations must be: drop nulls → log-transform → histogram
- Drop nulls first, then pre-log-transform the column (`pl.col("titulaire_distance").log(10)`) so bins are truly equal-width on a log scale. Use `px.histogram` with `nbins=50` on the transformed values
- Set custom X-axis tick values at powers of 10 (1, 10, 100, 1000, 10000) with km labels, using `fig.update_xaxes(tickvals=[0,1,2,3,4], ticktext=["1","10","100","1 000","10 000"])`
- Y axis: count of contracts
- French axis labels: x = `"Distance (km)"`, y = `"Nombre de marchés"`
- Returns a `dcc.Graph`
## Integration
### Observatoire (`src/pages/observatoire.py`)
- `get_distance_histogram` imported and called inside `udpate_dashboard_cards`
- Result wrapped in `make_card(title="Distance acheteurtitulaire", subtitle="en nombre de marchés, échelle logarithmique", fig=...)`
- Card appended to the `cards` list alongside existing donuts and charts
- No changes to the data pipeline — `titulaire_distance` is already in the LazyFrame
### Acheteur page (`src/pages/acheteur.py`)
The acheteur page uses a `dcc.Store` (`acheteur_data`) that holds serialised contract rows as a list of dicts. The integration follows the existing pattern used by other chart callbacks on this page:
- Add a new `html.Div(id="acheteur-distance-histogram")` placeholder in the layout
- Add a new callback with `Input("acheteur_data", "data")` that:
- Reconstructs `pl.LazyFrame(data)` from the store
- Calls `get_distance_histogram(lff)`
- Wraps the result in `make_card(...)` and returns it to the placeholder div
### Titulaire page (`src/pages/titulaire.py`)
Same pattern as acheteur: `dcc.Store` (`titulaire_data`) → new callback → `html.Div` placeholder.
## Out of Scope
- Filtering by distance range (could be a future filter on the observatoire page)
- Showing distance on a map or as a trend over time
- Bucket-based (named zone) grouping
@@ -0,0 +1,78 @@
# Observatoire Link from Search & Tableau Results
## Problem
Users searching for an organization (acheteur or titulaire) on the search page or browsing the tableau cannot jump directly to the observatoire page filtered for that organization. They must manually navigate and re-enter the identifier.
## Solution
Extend `add_links()` in `src/utils.py` to append an observatoire link (📊 emoji) to `_nom` columns, and add bidirectional URL parameter sync to the observatoire page.
## Changes
### 1. `src/utils.py` — `add_links()` modification
The existing `add_links()` loop iterates over `["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]`. The `if col.startswith("acheteur_")` and `if col.startswith("titulaire_")` blocks match both `_nom` and `_id` columns. The observatoire link must only be appended to `_nom` columns, so it must be gated on `col == "acheteur_nom"` or `col == "titulaire_nom"` explicitly.
For `acheteur_nom`, append an observatoire link after the existing detail page link:
```
Before: <a href="/acheteurs/12345678901234">Ville de Paris</a>
After: <a href="/acheteurs/12345678901234">Ville de Paris</a> <a href="/observatoire?acheteur_id=12345678901234" title="Voir dans l'observatoire">📊</a>
```
For `titulaire_nom`, same pattern but only when the existing `typeIdentifiant` guard passes (SIRET or null):
```
Before: <a href="/titulaires/12345678901234">Entreprise X</a>
After: <a href="/titulaires/12345678901234">Entreprise X</a> <a href="/observatoire?titulaire_id=12345678901234" title="Voir dans l'observatoire">📊</a>
```
The identifier used in the observatoire link (`acheteur_id` / `titulaire_id`) is the same `pl.col("acheteur_id")` / `pl.col("titulaire_id")` column value already used for the detail page link.
The `_id` and `uid` columns are unchanged.
### 2. `src/pages/observatoire.py` — URL parameter handling
#### Callback A: URL → Inputs (page load)
- Trigger: `Input("dashboard_url", "search")`
- Outputs: `Output("dashboard_acheteur_id", "value")`, `Output("dashboard_titulaire_id", "value")`, `Output("dashboard_url", "search")` (to clear it)
- `prevent_initial_call=False` (must fire on page load to read URL params)
- If `search` is empty or None: return `no_update` for all outputs
- Otherwise: parse query params with `urllib.parse.parse_qs`
- Set `dashboard_acheteur_id` from `?acheteur_id=` param, or `no_update` if absent
- Set `dashboard_titulaire_id` from `?titulaire_id=` param, or `no_update` if absent
- Return `""` for `dashboard_url.search` to clear the URL and prevent re-triggering
- No validation of param values — consistent with existing input handling in the observatoire callbacks
#### Callback B: Inputs → shareable URL
- Trigger: `Input("dashboard_acheteur_id", "value")`, `Input("dashboard_titulaire_id", "value")`
- State: `State("dashboard_url", "href")` for base URL
- `prevent_initial_call=True` (avoid generating URL on initial empty state)
- Build query string with `urllib.parse.urlencode`, omitting empty values
- Write full URL to a new `share-url` input component
- Render a `dcc.Clipboard` + share button (same pattern as tableau.py)
#### Callback chain
When navigating from search with `?acheteur_id=123`: Callback A fires on page load, sets input values, clears URL search. The input value changes then trigger both the existing `udpate_dashboard_cards` callback and Callback B. Dash handles this chaining deterministically — no race condition.
#### Layout additions
- A `dcc.Input(id="share-url", ...)` (hidden or read-only) to hold the shareable URL
- A `dcc.Clipboard` share/copy button near the filters
### 3. Reuse of existing `dcc.Location`
The existing `dcc.Location(id="dashboard_url")` component is reused — no new Location component needed.
## Future extension
The bidirectional URL sync pattern is designed to extend to all observatoire filters (year, categories, departments, market type, etc.) by adding more params to both callbacks.
## Files touched
- `src/utils.py` — modify `add_links()`
- `src/pages/observatoire.py` — add 2 callbacks, add share-url + clipboard to layout
@@ -0,0 +1,121 @@
# Observatoire: Full URL Sharing for All Filters
## Problem
The "Partager" button on `/observatoire` currently only encodes `acheteur_id` and `titulaire_id` in the shareable URL. The other 15 filter parameters are lost, so a shared link does not reproduce the sender's filtered view.
## Goal
Extend URL sharing so that **all 17 filter parameters** are encoded in the URL and restored when a recipient opens it. The recipient sees exactly what the sender intended — URL params replace all local filter state.
## Approach
Flat query parameters with short, readable keys. Multi-value filters use repeated keys (native to `urllib.parse`). Only non-default values appear in the URL.
## URL Parameter Mapping
| Component ID | URL key | Type | Default (omitted) |
| -------------------------------------------------- | ---------------- | --------------- | ----------------- |
| `dashboard_year` | `annee` | single | `None` |
| `dashboard_acheteur_id` | `acheteur_id` | single | `None` |
| `dashboard_acheteur_categorie` | `acheteur_cat` | single | `None` |
| `dashboard_acheteur_departement_code` | `acheteur_dept` | multi | `[]`/`None` |
| `dashboard_titulaire_id` | `titulaire_id` | single | `None` |
| `dashboard_titulaire_categorie` | `titulaire_cat` | single | `None` |
| `dashboard_titulaire_departement_code` | `titulaire_dept` | multi | `[]`/`None` |
| `dashboard_marche_type` | `type` | single | `None` |
| `dashboard_marche_objet` | `objet` | single | `None` |
| `dashboard_marche_code_cpv` | `cpv` | single | `None` |
| `dashboard_montant_min` | `montant_min` | single (number) | `None` |
| `dashboard_montant_max` | `montant_max` | single (number) | `None` |
| `dashboard_marche_techniques` | `techniques` | multi | `[]`/`None` |
| `dashboard_marche_innovant` | `innovant` | single | `"all"` |
| `dashboard_marche_sousTraitanceDeclaree` | `sous_traitance` | single | `"all"` |
| `dashboard_marche_considerationsSociales` | `social` | multi | `[]`/`None` |
| `dashboard_marche_considerationsEnvironnementales` | `env` | multi | `[]`/`None` |
Example URL:
```
/observatoire?annee=2024&acheteur_id=12345678901234&acheteur_dept=75&acheteur_dept=13&montant_min=10000&innovant=oui
```
## Data Structure
A list of tuples defines the mapping, used by both callbacks to avoid scattered string literals:
```python
FILTER_PARAMS = [
# (component_id, url_key, is_multi, default_value)
("dashboard_year", "annee", False, None),
("dashboard_acheteur_id", "acheteur_id", False, None),
("dashboard_acheteur_categorie", "acheteur_cat", False, None),
("dashboard_acheteur_departement_code", "acheteur_dept", True, None),
("dashboard_titulaire_id", "titulaire_id", False, None),
("dashboard_titulaire_categorie", "titulaire_cat", False, None),
("dashboard_titulaire_departement_code", "titulaire_dept", True, None),
("dashboard_marche_type", "type", False, None),
("dashboard_marche_objet", "objet", False, None),
("dashboard_marche_code_cpv", "cpv", False, None),
("dashboard_montant_min", "montant_min", False, None),
("dashboard_montant_max", "montant_max", False, None),
("dashboard_marche_techniques", "techniques", True, None),
("dashboard_marche_innovant", "innovant", False, "all"),
("dashboard_marche_sousTraitanceDeclaree", "sous_traitance", False, "all"),
("dashboard_marche_considerationsSociales", "social", True, None),
("dashboard_marche_considerationsEnvironnementales", "env", True, None),
]
```
## Callback Changes
### 1. `sync_observatoire_share_url` (line 575)
**Current:** Takes `acheteur_id` and `titulaire_id` as Inputs.
**New:** Takes all 17 filter values as Inputs (same as `udpate_dashboard_cards`). Builds the URL using `FILTER_PARAMS`, skipping default values. Uses `urllib.parse.urlencode(params, doseq=True)` for multi-value params.
### 2. `restore_filters` (line 539)
**Current:** Extracts only `acheteur_id` and `titulaire_id` from URL.
**New:**
- Iterates over `FILTER_PARAMS` to extract all values from `parse_qs`
- For multi-value params: reads the full list from `parse_qs` (returns lists natively)
- For number params (`montant_min`, `montant_max`): casts to `float`
- The guard condition changes from `if acheteur_id or titulaire_id` to "if any URL param is present" — this is necessary so URLs like `?annee=2024&montant_min=10000` (without an ID) work correctly
- When **any** URL param is present: returns explicit values for all 17 outputs — the URL value for params present, `None`/default for params absent. This ensures "URL replaces all" semantics.
- When **no** URL params are present: returns `(no_update,) * 17` (preserving local persistence)
- Radio buttons (`innovant`, `sous_traitance`): value from URL if present, otherwise `"all"` (their default)
### 3. Layout bug fix
Remove the duplicate `dcc.Input(id="observatoire-share-url")` (lines 413-422 — two identical elements).
## Backward Compatibility
Old URLs with only `?acheteur_id=...` or `?titulaire_id=...` continue to work — the new `restore_filters` will read those keys and reset all others to defaults, which is the same effective behavior as before.
Links generated by `add_links()` in `src/utils.py` (used on search results to link to `/observatoire?acheteur_id=...`) are unaffected.
## Test Changes
### Fix broken test `test_010_observatoire_montant_filter`
This test imports `_apply_filters` from `pages.observatoire`, which no longer exists (replaced by `prepare_dashboard_data` in `src/utils.py`). Fix:
- Replace import with `from src.utils import prepare_dashboard_data`
- Update the call to match `prepare_dashboard_data`'s signature: rename `marche_type` keyword to `type`, and add missing params `objet`, `code_cpv`, `techniques`, `marche_innovant`, `sous_traitance_declaree` (all as `None`)
### New test: multi-param URL round-trip
Add a test that navigates to `/observatoire?annee=2024&acheteur_id=<test_id>&montant_min=10000` and verifies that:
- `dashboard_year` dropdown shows "2024"
- `dashboard_acheteur_id` input contains the test ID
- `dashboard_montant_min` input contains "10000"
### Update existing tests
Tests `test_006` and `test_007` validate `acheteur_id` round-trip. These should continue to pass without changes since `acheteur_id` keeps the same URL key.
@@ -0,0 +1,196 @@
# DuckDB migration — design spec
**Date:** 2026-04-15
**Branch:** dev
**Status:** Approved, ready for planning
## Goal
Replace the global Polars dataframes that `src/utils.py` materializes at import time (`df` and the five derived frames, lines 891913) with a DuckDB database on disk. The main table holds ~1.5M rows from `decp_prod.parquet`. Per-request queries pull only what each page needs, dramatically reducing steady-state RSS memory.
Polars stays the primary API for small result sets and post-processing. DuckDB carries the heavy filtering, joining, and aggregation.
## Approach summary
- **Approach A — compatibility layer.** A new `src/db.py` module exposes a `query_marches(where_sql, params, columns, ...)` helper that runs SQL and returns a `pl.DataFrame`. Most existing `df.filter(pl.col(...) == x)` call sites translate mechanically to `query_marches("col = ?", (x,))`. The shape of downstream Polars code is unchanged.
- **Two small helpers stay in memory.** `df_acheteurs` and `df_titulaires` (tens of thousands of rows, consumed by the autocomplete search on every keystroke) are kept as module-level Polars frames. They are populated from DuckDB at import time, not from Parquet.
- **Four derived tables live in DuckDB**, built at startup alongside the main table: `acheteurs_marches`, `titulaires_marches`, `acheteurs_departement`, `titulaires_departement`.
- **Connection model.** One read-only `duckdb.connect(..., read_only=True)` at module load, shared across the process. `conn.cursor()` per Dash callback for thread-safety. The read-write connection is short-lived and only used during the startup build phase.
## Cache invalidation rule
At startup, rebuild the DuckDB file if:
1. **The DB file does not exist**, OR
2. **`decp_prod.parquet.mtime > duckdb.mtime`**, **unless** `DEVELOPMENT=true` and `REBUILD_DUCKDB != true` — in which case the DB stays as-is (fast dev reloads).
Production auto-rebuilds when the source Parquet is newer. Development keeps a stable DB across reloads unless the developer explicitly sets `REBUILD_DUCKDB=true` to force a rebuild.
## Concurrency
Multi-worker Gunicorn startup and crashed-mid-build scenarios are handled by a file lock, not by polling for the tmp file's existence:
```python
with open(DB_PATH.with_suffix(".duckdb.lock"), "w") as lock_fd:
fcntl.flock(lock_fd, fcntl.LOCK_EX) # blocks if another worker is building
if should_rebuild(DB_PATH, PARQUET_PATH):
build_database(DB_PATH, PARQUET_PATH)
conn = duckdb.connect(str(DB_PATH), read_only=True)
```
- Worker A acquires the lock, builds, atomically renames tmp → final, releases the lock.
- Worker B blocks on `flock`, then re-checks `should_rebuild`, sees the fresh DB, skips building.
- `fcntl.flock` is auto-released on process death, so a crash never deadlocks the next worker.
- `build_database` unlinks any pre-existing tmp file before starting (safe because it holds the lock) — handles an abandoned tmp from a crashed previous build.
## Build logic
The build keeps **one source of truth** for transforms by reusing the existing Polars pipeline:
```python
def build_database(db_path, parquet_path):
tmp_path = db_path.with_suffix(".duckdb.tmp")
if tmp_path.exists():
tmp_path.unlink()
frame = get_decp_data() # existing function in utils.py
with duckdb.connect(str(tmp_path)) as w:
w.register("frame", frame)
w.execute("CREATE TABLE decp AS SELECT * FROM frame")
w.execute("CREATE TABLE acheteurs_marches AS "
"SELECT DISTINCT uid, objet, acheteur_id FROM decp "
"ORDER BY acheteur_id")
w.execute("CREATE TABLE titulaires_marches AS "
"SELECT DISTINCT uid, objet, titulaire_id FROM decp "
"ORDER BY titulaire_id")
w.execute("CREATE TABLE acheteurs_departement AS "
"SELECT DISTINCT acheteur_id, acheteur_nom, acheteur_departement_code "
"FROM decp ORDER BY acheteur_nom")
w.execute("CREATE TABLE titulaires_departement AS "
"SELECT DISTINCT titulaire_id, titulaire_nom, titulaire_departement_code "
"FROM decp ORDER BY titulaire_nom")
os.replace(tmp_path, db_path)
```
Why Polars, not SQL, for the row-level transforms:
- `booleans_to_strings` is not a simple cast — it replaces `true`/`false` with `"oui"`/`"non"` on every boolean column. Reimplementing in SQL risks drifting from the Polars version.
- The null-name replacement (`acheteur_nom`, `titulaire_nom``"[Identifiant non reconnu dans la base INSEE]"`) is also easier to keep identical in Polars.
- `w.register("frame", frame)` is zero-copy. The memory spike is one-time during build and released when the write connection closes.
`os.replace` is atomic on POSIX — the read-only connection that opens next always sees a complete DB.
## Module layout
### New: `src/db.py`
```python
conn: duckdb.DuckDBPyConnection # read-only, module-level
schema: pl.Schema # from conn.execute("SELECT * FROM decp LIMIT 0").pl().schema
def get_cursor() -> duckdb.DuckDBPyConnection: ...
def query_marches(where_sql: str = "TRUE",
params: tuple = (),
columns: list[str] | None = None,
order_by: str | None = None,
limit: int | None = None) -> pl.DataFrame: ...
def should_rebuild(db_path: Path, parquet_path: Path) -> bool: ...
def build_database(db_path: Path, parquet_path: Path) -> None: ...
```
Only imports: `polars`, `duckdb`, `os`, `fcntl`, `pathlib`, `logging`. No app modules — prevents circular imports.
### Changes to `src/utils.py`
- `df: pl.DataFrame = get_decp_data()`**removed** (after migration).
- `df_acheteurs`, `df_titulaires`**kept as Polars globals**, populated via DuckDB at import time. The query mirrors today's `get_org_data(df, org_type)`: select all columns whose name starts with `acheteur_` (or `titulaire_`) except the `_latitude` / `_longitude` pair, plus `COUNT(*) AS "Marchés"`, grouped by the same set. Implementation can either:
- enumerate the columns by filtering `schema.names()` at import time and build the `SELECT` / `GROUP BY` strings, or
- call `get_org_data()` once against a small Polars frame returned by `SELECT <org_ cols> FROM decp`.
Feeds `search_org` unchanged.
- `df_acheteurs_marches`, `df_titulaires_marches`, `df_acheteurs_departement`, `df_titulaires_departement`**removed** as Python globals. Call sites query the corresponding DuckDB tables.
- `schema` — imported from `src/db.py` (stays a `pl.Schema` — so `schema.names()` and dtype lookups both work, no call-site changes beyond `acheteur.py:303`).
- `columns` — replaced with `schema.names()`.
- `get_decp_data()`**kept** (used by `build_database`).
- `get_org_data()` — can be removed once `df_acheteurs` / `df_titulaires` are populated from DuckDB directly.
### Call-site translations
| Before (Polars global) | After |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `df.filter(pl.col("acheteur_id") == aid)` | `query_marches("acheteur_id = ?", (aid,))` |
| `df.filter(pl.col("uid") == uid).row(0, named=True)` | `query_marches("uid = ?", (uid,)).row(0, named=True)` |
| `df.select("uid","objet","acheteur_id").filter(...)` | `query_marches("...", (...), columns=["uid","objet","acheteur_id"])` |
| `df.columns` | `schema.names()` |
| `df_acheteurs_marches.filter(...)` | `get_cursor().execute("SELECT ... FROM acheteurs_marches WHERE ...", [...]).pl()` |
| `pl.DataFrame(schema=df.collect_schema())` (acheteur.py:303) | `pl.DataFrame(schema=schema)` |
Heavy dashboard aggregations (observatoire, tableau full-scan) use raw SQL via `get_cursor().execute(...).pl()` rather than the helper.
## Configuration
- **`DATA_FILE_PARQUET_PATH`** — unchanged.
- **DuckDB file location** — computed: `Path(DATA_FILE_PARQUET_PATH).parent / "decp.duckdb"`. No new env var.
- **`REBUILD_DUCKDB`** — new, optional, default `false`. In development, setting this to `true` forces a rebuild when the parquet is newer.
- **`DEVELOPMENT`** — unchanged; now also gates the auto-rebuild behavior per the rule above.
## Testing
- `tests/conftest.py` (or a startup hook in `src/db.py`) ensures the test run builds the DuckDB in a temp directory derived from the parquet path — `tests/test.parquet``tests/decp.duckdb`. This file is added to `.gitignore`.
- Tests already set `DEVELOPMENT=true`; they must also set `REBUILD_DUCKDB=true` on cold test runs to force a fresh build from the test parquet.
- The existing Selenium suite exercises every page and is the primary acceptance signal.
## Migration order
Incremental — `df` global coexists with `src/db.py` until every page is migrated.
1. **Add `src/db.py`** (build, lock, `query_marches`, `schema`). `df` global unchanged.
2. **Migrate `marche.py`** — single-row lookup by `uid`, one call site.
3. **Migrate `acheteur.py`, `titulaire.py`** — filter by id.
4. **Migrate `arbre/departement.py`, `arbre/liste_marches_org.py`** — use the new derived DuckDB tables.
5. **Migrate `tableau.py`** — may need raw SQL.
6. **Migrate `observatoire.py`** — heaviest aggregations, most likely raw SQL.
7. **Migrate `figures.py`** — uses `df` in chart generation.
8. **Remove** `df`, `df_*_marches`, `df_*_departement` globals, `get_org_data()`, and the `df = get_decp_data()` call from `utils.py`. Move `schema` / `columns` exports to `src/db.py`.
### Verification gates
- `uv run pytest` green after every page migration.
- Manual smoke test via `uv run run.py` of the migrated page before proceeding.
- RSS memory measurement (`ps -o rss`) of a cold `gunicorn app:server` with the prod parquet, before and after, to confirm the memory reduction.
## Out of scope
- Changes to `src/cache.py` (flask-caching stays).
- The in-progress observatoire-localstorage-filters work on `dev`.
- Schema changes to the parquet.
- SQL views beyond the four derived tables.
- Multi-database or replication setups.
## Risks and mitigations
| Risk | Mitigation |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `booleans_to_strings` reimplemented in SQL and drifts from Polars version | Transforms stay in Polars via `w.register("frame", frame)`. One source of truth. |
| Two Gunicorn workers rebuild concurrently | `fcntl.flock` serializes the build; second worker re-checks and skips. |
| Crashed build leaves stale `.tmp` file | Build unlinks any pre-existing tmp before starting (safe under lock). |
| `schema` shape change breaks `acheteur.py:303` | `schema` stays a `pl.Schema` object, not a list. One call site (`collect_schema()` → module `schema`) updated. |
| Test runs inherit a stale DuckDB from a previous run with a different parquet | Tests force `REBUILD_DUCKDB=true` on cold runs; test DB added to `.gitignore`. |
| Read-only connection opened before build finishes in another worker | Lock held across build + rename; read-only `connect` happens after lock release. Atomic `os.replace` guarantees a complete file. |
## Outcome
### Memory impact
Memory measurement against the production parquet (`decp_prod.parquet`, ~1.5M rows) requires a running gunicorn process with access to the production data file. The measurement was deferred to the post-merge smoke test on the staging server (test.decp.info).
**Expected reduction:** The removed globals (`df`, `df_acheteurs_departement`, `df_titulaires_departement`, `df_acheteurs_marches`, `df_titulaires_marches`) previously materialised the full 1.5M-row Parquet in memory as multiple Polars frames. At ~300 bytes/row × 5 frames, steady-state RSS reduction is estimated at **12 GB per worker**. The retained `df_acheteurs` and `df_titulaires` (autocomplete search) represent only the distinct-organisation subset (~tens of thousands of rows) and are negligible.
**What remains in memory:**
- `df_acheteurs` — distinct acheteurs with Marchés count (populated from DuckDB at startup)
- `df_titulaires` — same for titulaires
- DuckDB's own page cache (disk-backed, grows under load, evicted by OS)
All per-request data is fetched from DuckDB and discarded after the callback returns.
@@ -0,0 +1,206 @@
# Observatoire — filtrage natif DuckDB
## Contexte
La page `/observatoire` construit ses cartes, ses téléchargements et sa prévisualisation
tabulaire à partir de la fonction `prepare_dashboard_data` (dans `src/utils/data.py`).
Aujourd'hui, cette fonction prend une `pl.LazyFrame` — typiquement obtenue par
`query_marches().lazy()` — et applique une série de filtres côté Polars.
`query_marches()` matérialise l'intégralité de la table `decp` (~1,5 M lignes) en
DataFrame Polars, même lorsqu'un utilisateur applique des filtres restrictifs. Les
filtres sont ensuite appliqués sur cet ensemble déjà matérialisé.
Le pattern utilisé par `_fetch_page_sql` (dans `src/utils/table.py`) montre comment
déléguer le filtrage à DuckDB :
1. Un traducteur (`filter_query_to_sql`, dans `src/utils/table_sql.py`) transforme le
DSL utilisateur en `(where_sql, params)`.
2. `query_marches(where_sql=..., params=...)` ne matérialise que le sous-ensemble utile.
Ce spec décrit comment appliquer ce même pattern aux filtres de l'observatoire.
## Objectifs
- Réduire la consommation mémoire et le temps de chaque callback de l'observatoire
en poussant le filtrage au niveau DuckDB.
- Conserver strictement la sémantique des filtres actuels (pas de régression
fonctionnelle).
- Garder une frontière claire : un helper pur `dashboard_filters_to_sql` qui ne
touche pas à la base, et une `prepare_dashboard_data` fine qui appelle DuckDB.
## Non-objectifs
- Pas de refonte de l'UI de filtres.
- Pas d'optimisation ou de cache supplémentaire autour de
`_compute_dashboard_children` (déjà `@cache.memoize()`).
- Pas de changement du comportement par défaut (365 derniers jours quand aucune
année n'est sélectionnée).
## Architecture
### Nouveau helper — `src/utils/table_sql.py`
```python
def dashboard_filters_to_sql(
dashboard_year=None,
dashboard_acheteur_id=None,
dashboard_acheteur_categorie=None,
dashboard_acheteur_departement_code=None,
dashboard_titulaire_id=None,
dashboard_titulaire_categorie=None,
dashboard_titulaire_departement_code=None,
dashboard_marche_type=None,
dashboard_marche_objet=None,
dashboard_marche_code_cpv=None,
dashboard_marche_considerations_sociales=None,
dashboard_marche_considerations_environnementales=None,
dashboard_marche_techniques=None,
dashboard_marche_innovant=None,
dashboard_marche_sous_traitance_declaree=None,
dashboard_montant_min=None,
dashboard_montant_max=None,
) -> tuple[str, list]:
"""Traduit les filtres du tableau de bord en (where_clause, params) DuckDB."""
```
Fonction pure, sans accès à la base. Même signature que `prepare_dashboard_data`
actuelle (hors `lff`). Retourne `("TRUE", [])` si aucun filtre n'est actif.
### Réécriture — `prepare_dashboard_data` (`src/utils/data.py`)
```python
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
where_sql, params = dashboard_filters_to_sql(**filter_params)
return query_marches(where_sql=where_sql, params=params)
```
- **Signature** : suppression du paramètre `lff`. Retour `pl.DataFrame` (et non plus
`pl.LazyFrame`).
- Les appelants qui ont besoin d'une LazyFrame appellent `.lazy()` sur le résultat.
### Appelants — `src/pages/observatoire.py`
Trois sites d'appel à adapter :
1. **`_compute_dashboard_children`** (ligne ~668) — on remplace
```python
lff: pl.LazyFrame = query_marches().lazy()
lff = prepare_dashboard_data(lff=lff, **filter_params)
dff = lff.collect(engine="streaming")
```
par
```python
dff = prepare_dashboard_data(**filter_params)
lff = dff.lazy()
```
Les appels existants à `make_donut`, `get_distance_histogram`, `get_top_org_table`,
`get_barchart_sources` continuent de recevoir `lff` ; `get_geographic_maps`
continue de recevoir `dff`. `df_per_uid` est calculé à partir de `dff`.
2. **`download_observatoire`** (ligne ~791) —
```python
dff = prepare_dashboard_data(**(filter_params or {}))
if hidden_columns:
dff = dff.drop(hidden_columns)
def to_bytes(buffer):
dff.write_excel(buffer, worksheet="DECP")
```
3. **`populate_preview_table`** (ligne ~882) —
```python
dff = prepare_dashboard_data(**(filter_params or {}))
return prepare_table_data(
dff.lazy(), # prepare_table_data accepte une LazyFrame
...
)
```
## Traduction des filtres
| Filtre | Actuel (Polars) | Cible (SQL DuckDB) |
| --------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------- |
| `dashboard_year` (présent) | `dt.year() == int(year)` | `YEAR("dateNotification") = ?` |
| `dashboard_year` (absent) — comportement par défaut | `> now - 365j` | `"dateNotification" > ?` (datetime calculé à l'appel) |
| `dashboard_acheteur_id` | `str.contains(val)` | `"acheteur_id" LIKE ?` avec `%val%` |
| `dashboard_acheteur_categorie` | `== val` (skip si acheteur_id présent) | `"acheteur_categorie" = ?` |
| `dashboard_acheteur_departement_code` | `is_in(list)` (skip si acheteur_id présent) | `"acheteur_departement_code" IN (?, ?, ...)` |
| `dashboard_titulaire_id` | idem acheteur | idem |
| `dashboard_titulaire_categorie` | idem | idem |
| `dashboard_titulaire_departement_code` | idem | idem |
| `dashboard_marche_type` | `== val` | `"type" = ?` |
| `dashboard_marche_objet` | `str.contains("(?i)val")` | `"objet" ILIKE ?` avec `%val%` |
| `dashboard_marche_code_cpv` | `str.starts_with(val)` | `"codeCPV" LIKE ?` avec `val%` |
| `dashboard_marche_techniques` | `str.split(", ").list.set_intersection(xs).list.len() > 0` | `list_has_any(string_split("techniques", ', '), ?::VARCHAR[])` |
| `dashboard_marche_considerations_sociales` | idem | idem sur `"considerationsSociales"` |
| `dashboard_marche_considerations_environnementales` | idem | idem sur `"considerationsEnvironnementales"` |
| `dashboard_marche_innovant` (`"oui"`/`"non"`, sinon skip) | `== val` | `"marcheInnovant" = ?` |
| `dashboard_marche_sous_traitance_declaree` | idem | `"sousTraitanceDeclaree" = ?` |
| `dashboard_montant_min` | `>= val` | `"montant" >= ?` |
| `dashboard_montant_max` | `<= val` | `"montant" <= ?` |
**Logique conditionnelle conservée** : si `dashboard_acheteur_id` est fourni, les filtres
`categorie` et `departement_code` acheteur sont ignorés (même chose pour titulaire).
**Traitement des valeurs spéciales** :
- `dashboard_marche_innovant` / `dashboard_marche_sous_traitance_declaree` : valeur
`"all"` ou falsy → aucun filtre ajouté.
- `dashboard_year` : converti en `int` avant injection.
- `dashboard_montant_min` / `_max` : `None` → aucun filtre (distinct de `0`, qui reste
un filtre valide via `>=` ou `<=`).
**Sécurité SQL** : toutes les valeurs utilisateurs passent par DuckDB en paramètres liés
(`?`). Seuls des noms de colonnes statiques (contrôlés par le code) sont injectés dans le
fragment SQL via `f"..."`. Pas de différence avec le pattern existant de
`filter_query_to_sql`.
## Tests
### Unitaires (nouveaux)
Nouveau fichier `tests/test_dashboard_filters_to_sql.py` :
- Cas vide → `("TRUE", [])`.
- Un seul filtre simple (année, type, etc.) → fragment SQL et params attendus.
- Filtre montant min/max (migration de l'actuel `test_010_observatoire_montant_filter`).
- Filtre liste (techniques, considerationsSociales) → usage de `list_has_any`.
- Filtre acheteur_id fourni → catégorie/département acheteur ignorés.
- Filtre `"all"` / `None` sur innovant/sous_traitance → aucun fragment ajouté.
- Comportement par défaut sans année → fragment `"dateNotification" > ?` avec un param
datetime à ~365 j dans le passé (tolérance de quelques secondes).
### Intégration (nouveau, léger)
Un test qui appelle `prepare_dashboard_data` contre `tests/test.parquet` avec un ou
deux filtres connus, vérifie le `height` et la bonne nature du retour (`pl.DataFrame`).
### Test Selenium existant
`test_009_observatoire_filter_persistence` et `test_008_observatoire_navigation_from_search`
ne touchent pas à la signature ; ils doivent continuer à passer.
## Risques et migration
- **Risque sémantique** : la fonction Polars `str.contains` utilisée pour les IDs est
un regex. Les utilisateurs attendent probablement un contains littéral sur un SIRET
(14 chiffres). Le passage à `LIKE '%val%'` est neutre si la valeur ne contient pas de
caractère spécial regex — ce qui est le cas pour des SIRET. **Hypothèse** acceptée :
le contenu `dashboard_acheteur_id`/`dashboard_titulaire_id` est alphanumérique.
- **Risque de drift du cache** : la date "365 derniers jours" n'est pas incluse dans
la clé de cache de `_compute_dashboard_children`. C'est un comportement pré-existant
; non traité par ce spec.
- **Import circulaire** : `src/utils/data.py` importe déjà depuis `src/db.py`.
`src/utils/table_sql.py` importe depuis `src/utils/table.py`. Pas de nouveau cycle.
## Succès
- Les 3 callbacks de l'observatoire restent fonctionnellement équivalents.
- Les tests unitaires et d'intégration passent.
- Une inspection manuelle confirme un temps d'exécution réduit sur un filtre
sélectif (par ex. un département + une année).
@@ -0,0 +1,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`.
+47
View File
@@ -0,0 +1,47 @@
[project]
name = "decp.info"
description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.7.9"
requires-python = ">= 3.10"
authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }]
dependencies = [
"dash==3.4.0",
"dash[compress]",
"polars",
"gunicorn",
"dash-bootstrap-components",
"python-dotenv",
"xlsxwriter",
"plotly[express]",
"httpx",
"pandas", # utilisé pour la création de certains graphiques
"unidecode",
"dash-leaflet",
"dash-extensions",
"duckdb",
"flask-caching",
"pyarrow>=23.0.1",
"flask-cors>=6.0.2",
]
[dependency-groups]
dev = [
"pytest",
"pytest-env",
"pre-commit",
"selenium",
"webdriver-manager",
"dash[testing]",
"fastexcel",
]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
env = [
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
"DEVELOPMENT=true",
"REBUILD_DUCKDB=true",
"DATA_SCHEMA_LOCAL=/home/colin/git/decp-processing/dist/schema.json",
]
addopts = "-p no:warnings"
+11
View File
@@ -0,0 +1,11 @@
from flask_cors import CORS
from src.app import app
# To use `gunicorn run:server` (prod)
server = app.server
CORS(server)
# To use `python run.py` (dev)
if __name__ == "__main__":
app.run(debug=True)
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
echo "Get data..."
wget -nv https://www.data.gouv.fr/fr/datasets/r/c6b08d03-7aa4-4132-b5b2-fd76633feecc -O datasette/db.db
datasette inspect datasette/*.db --inspect-file=datasette/inspect-data.json
echo "Starting datasette..."
datasette datasette/ --port 9090 --cors | grep -v "/static/"
View File
+219
View File
@@ -0,0 +1,219 @@
import os
from shutil import rmtree
import dash_bootstrap_components as dbc
import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads
import tomllib
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
from dotenv import load_dotenv
from flask import Flask, Response
from src.utils import DEVELOPMENT
from src.utils.cache import cache
load_dotenv()
# if os.getenv("PYTEST_CURRENT_TEST"):
# os.environ["DATA_FILE_PARQUET_PATH"]
META_TAGS = [
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
{
"name": "keywords",
"content": "commande publique, decp, marchés publics, données essentielles",
},
]
if DEVELOPMENT:
META_TAGS.append({"name": "robots", "content": "noindex"})
# 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")
if os.path.exists(cache_dir):
rmtree(cache_dir)
cache.init_app(
server,
config={
"CACHE_TYPE": "FileSystemCache",
"CACHE_DIR": cache_dir,
"CACHE_DEFAULT_TIMEOUT": int(
os.getenv("CACHE_DEFAULT_TIMEOUT", 3600 * 24)
), # 24h par défaut
"CACHE_THRESHOLD": 300,
},
)
app: Dash = Dash(
server=server,
title="decp.info",
use_pages=True,
compress=True,
meta_tags=META_TAGS,
)
# robots.txt
@app.server.route("/robots.txt")
def robots():
text = """User-agent: *
Allow: /
"""
return Response(text, mimetype="text/plain")
@app.server.route("/sitemap.xml")
def sitemap():
base_url = "https://decp.info"
pages = [
"/",
"/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'
for page in pages:
xml += " <url>\n"
xml += f" <loc>{base_url}{page}</loc>\n"
xml += " </url>\n"
xml += "</urlset>"
return Response(xml, mimetype="text/xml")
with open("./pyproject.toml", "rb") as f:
pyproject = tomllib.load(f)
version = "v" + pyproject["project"]["version"]
app.index_string = """
<!DOCTYPE html>
<html lang="fr">
<head>
{%metas%}
<title>{%title%}</title>
{%favicon%}
{%css%}
<!-- canonical link -->
</head>
<body>
{%app_entry%}
<footer>
{%config%}
{%scripts%}
{%renderer%}
</footer>
<script type="application/javascript">
console.log("Matomo");
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="//analytics.maudry.com/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '14']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
</body>
</html>
"""
navbar = dbc.Navbar(
dbc.Container(
fluid=True,
children=[
dbc.NavItem(
children=[
html.Div(
[
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
html.P(
[
html.A(
version,
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
)
],
className="version",
),
],
className="logo-wrapper",
)
],
style={"minWidth": "230px"},
),
dbc.Nav(
children=[
dcc.Markdown(
os.getenv("ANNOUNCEMENTS"),
id="announcements",
dangerously_allow_html=True,
),
],
style={
"maxWidth": "1200px",
"display": "inline-block",
},
navbar=True,
id="announcements-nav",
),
dbc.NavbarToggler(id="navbar-toggler"),
dbc.Collapse(
dbc.Nav(
[
dbc.NavItem(
dbc.NavLink(
page["name"].replace(" ", " "),
href=page["relative_path"],
active="exact",
)
)
for page in page_registry.values()
if page["name"]
in ["Recherche", "À propos", "Tableau", "Observatoire"]
],
className="ms-auto",
navbar=True,
),
id="navbar-collapse",
navbar=True,
),
],
),
color="light",
dark=False,
className="mb-4",
expand="lg",
)
app.layout = html.Div(
[
navbar,
dbc.Container(
page_container,
fluid=True,
id="page-content-container",
className="mb-4",
),
]
)
@app.callback(
Output("navbar-collapse", "is_open"),
[Input("navbar-toggler", "n_clicks")],
[State("navbar-collapse", "is_open")],
)
def toggle_navbar_collapse(n, is_open):
if n:
return not is_open
return is_open
+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="copy" class="svg-inline--fa fa-copy fa-w-14 " role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M433.941 65.941l-51.882-51.882A48 48 0 0 0 348.118 0H176c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48v-48h80c26.51 0 48-21.49 48-48V99.882a48 48 0 0 0-14.059-33.941zM266 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h74v224c0 26.51 21.49 48 48 48h96v42a6 6 0 0 1-6 6zm128-96H182a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h106v88c0 13.255 10.745 24 24 24h88v202a6 6 0 0 1-6 6zm6-256h-64V48h9.632c1.591 0 3.117.632 4.243 1.757l48.368 48.368a6 6 0 0 1 1.757 4.243V112z"></path></svg>

After

Width:  |  Height:  |  Size: 738 B

File diff suppressed because it is too large Load Diff
+763
View File
@@ -0,0 +1,763 @@
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600);
/* ==========================================================================
Variables
========================================================================== */
:root {
--bs-font-monospace: "Fira Code";
--primary-color: rgb(179, 56, 33);
--primary-color-text: #b33821;
}
/* ==========================================================================
Base & Reset
========================================================================== */
body {
font-family: "Inter", sans-serif;
font-weight: 400;
background-color: rgb(255 240 240 / 40%);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-smooth: always;
font-display: swap;
}
strong,
b {
font-weight: 600 !important;
}
h1,
h2,
h3,
h4,
h5 {
font-weight: 600;
}
h3 {
margin: 36px 0 20px 0;
}
/* Base Button Styles
button {
font-weight: 400;
background-color: #fff;
border-radius: 3px;
appearance: auto;
border: solid var(--primary-color) 1px;
} */
button.btn.btn-primary,
button.show-hide {
display: block;
border-radius: 3px;
outline: 0;
color: #fff;
border: 0;
height: 30px;
padding-top: 2px;
background-image: linear-gradient(
rgb(209, 96, 73),
rgb(179, 56, 33) 26%,
rgb(159, 36, 22)
);
}
button.btn.btn-primary:hover,
button.show-hide:hover {
background-image: linear-gradient(
rgb(239, 126, 103),
rgb(209, 86, 63) 26%,
rgb(189, 66, 52)
);
}
button[disabled] {
border-color: #ccc;
color: #666;
}
button:hover:not([disabled]) {
background-color: #fee;
}
/* Global Link Styles */
#_pages_content a {
color: #993333;
}
/* ==========================================================================
Layout
========================================================================== */
#_pages_content {
padding: 28px 24px 0 24px;
}
#header > * {
margin: 0 0 20px 0px;
}
/* ==========================================================================
Components
========================================================================== */
/* --- Navigation & Header --- */
a.logo {
color: black;
text-decoration: none;
}
a.logo > h1 {
font-weight: 400;
margin: 0;
line-height: 1;
}
.logo-wrapper {
display: flex;
align-items: baseline;
}
p.version {
margin: 0 0 0 12px;
font-family: "Fira Code";
font-size: 0.9rem;
}
p.version > a {
text-decoration: none;
margin-top: 10px;
}
.navbar-brand {
margin-right: 2px;
}
.navbar-nav .nav-link.active {
font-weight: 600;
}
#announcements {
margin: 25px 40px 0 60px;
font-size: 90%;
max-width: 900px;
}
#announcements p {
margin-bottom: 0.2rem;
}
.seeBorder {
border: dotted 1px green;
}
/* --- Search Page --- */
.tagline {
text-align: center;
font-size: 120%;
display: block;
margin-top: 50px;
}
#search {
margin: 30px auto 0px auto;
width: 500px;
font-size: 16px;
height: 30px;
display: block;
}
.search_options {
margin: 16px auto;
width: 450px;
}
.search_options input {
margin-right: 12px;
}
/* --- Dashboard inputs --- */
.Select--multi .Select-value {
color: var(--primary-color) !important;
background-color: rgba(255, 240, 240, 0.4) !important;
}
#filters .row > * {
margin-bottom: 6px;
}
#filters input[type="text"],
#filters input[type="number"] {
border: 1px #ccc solid;
border-radius: 3px;
padding-left: 8px;
}
/* --- Tables (Dash & Custom) --- */
/* Table Menu (Exports etc) */
.table-menu {
font-size: 16px;
margin: 12px 0 12px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.table-menu > * {
margin: 8px 16px 8px 0;
}
#source_table {
margin-bottom: 25px;
}
#source_table p {
line-height: 1.5;
}
/* Dash Table Overrides */
.column-header--sort {
margin-left: 3px;
}
dash-table-container dash-spreadsheet-menu table.cell-table {
margin-right: 8px;
margin-lef: 8px;
}
table.cell-table,
table.cell-table tr {
border-color: #fff;
padding: 0;
border-collapse: separate !important;
/* Required for border-radius */
border-spacing: 0;
}
table.cell-table th {
border-collapse: separate !important;
border-spacing: 0;
}
.dash-table-container p {
margin-bottom: 0;
}
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
th.dash-header,
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
th.dash-select-header {
margin: 0;
color: white;
font-family: "Inter", sans-serif;
text-align: left;
font-weight: 600;
padding: 2px 12px 4px 2px;
border: 1px solid rgb(179, 56, 33) !important;
background-color: rgb(179, 56, 33);
border-bottom: none !important;
height: 32px;
}
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
table.cell-table
tr:first-of-type
th.dash-header:first-of-type,
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
table.cell-table
tr:first-of-type
th.dash-select-header:first-of-type {
border-top-left-radius: 3px !important;
}
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
table.cell-table
tr:first-of-type
th.dash-header:last-of-type {
border-top-right-radius: 3px !important;
}
/* Dash Filters */
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
.cell-table
.dash-filter
input[type="text"] {
border-color: #ccc;
border-style: solid;
border-width: 1px;
border-radius: 3px;
height: 28px;
font-family: "Fira Code";
caret-color: #000;
background-color: rgb(250 250 250);
text-align: left !important;
padding: 1px 2px 0 2px;
vertical-align: center;
}
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
.cell-table
.dash-filter
input[type="text"]::placeholder {
color: #999;
}
.dash-filter--case {
display: none;
}
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
.cell-table
th.dash-filter {
background-color: #ccc;
}
/* Custom Marches Table */
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
.cell-table
td {
padding-left: 5px;
padding-right: 5px;
}
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
td
div.dash-cell-value.cell-markdown {
font-family: "Inter", sans-serif !important;
font-weight: 400;
}
.marches_table.stuck {
position: relative;
right: 200px;
}
.marches_table .cell-table tr:nth-child(even) td {
background-color: rgb(255 240 240 / 40%);
}
/* Column Visibility Menu */
.column-actions {
margin-right: 8px;
}
.column-header--hide {
display: none;
}
button.show-hide {
position: relative;
width: 180px;
margin: 0 0 10px 0;
display: none;
}
/*
.show-hide::before {
background: inherit;
content: "Colonnes affichées";
position: absolute;
left: 5px;
right: 5px;
#column_list .show-hide,
#table .show-hide {
display: none;
}
.show-hide-menu-item > input {
margin-right: 10px;
} */
#btn-copy-url:before {
}
/* Dropdowns */
.Select-placeholder {
color: #333 !important;
}
/* Checkboxes */
input[type="checkbox"] {
height: 17px;
width: 17px;
}
/* Tooltips */
.dash-tooltip,
.dash-table-tooltip {
color: #333;
width: 400px !important;
max-width: 400px !important;
height: 150px !important;
max-height: 150px !important;
overflow: hidden;
}
.dash-tooltip pre,
.dash-tooltip code {
overflow: hidden;
height: 150px;
text-wrap: wrap;
font-family: "Inter", sans-serif;
}
/* --- Organization Cards (Grid Items) --- */
#cards .card {
margin-bottom: 16px;
}
.org_infos > p {
margin: 8px 0;
}
/* --- About Page (A Propos) --- */
.a-propos-container {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
position: relative;
max-width: 1200px;
margin: 0 auto;
}
.a-propos-content {
flex: 1 1 70%;
max-width: 75%;
padding-right: 40px;
}
.a-propos-toc {
flex: 0 0 25%;
max-width: 25%;
/* Keeps it from growing too large */
position: sticky;
top: 40px;
/* Sticks 40px from the top of the viewport */
border-left: 2px solid #333;
/* Dark vertical line like hedgedoc */
padding-left: 15px;
margin-top: 40px;
background-color: #fff;
/* Aligns visually with the first header */
}
/* TOC Links */
.toc-link {
display: block;
color: #666;
text-decoration: none;
font-size: 0.9em;
padding: 2px 0;
transition: color 0.2s, font-weight 0.2s;
line-height: 1.4;
}
.toc-link:hover {
color: #000;
text-decoration: none;
}
.toc-active {
color: #000;
font-weight: bold;
}
.toc-level-2 {
margin-left: 15px;
font-size: 0.85em;
}
.toc-header {
font-weight: bold;
margin-bottom: 10px;
display: block;
color: #333;
}
/* --- Misc & Utility --- */
#instructions {
max-width: 1000px;
}
details > div {
padding-top: 24px;
}
summary > h4 {
margin: 0;
display: inline;
}
/* ==========================================================================
Media Queries
========================================================================== */
@media (max-width: 992px) {
/* Navigation */
#announcements-nav {
display: none !important;
}
/* About Page */
.a-propos-content {
max-width: 100%;
padding-right: 0;
flex: 1 1 100%;
}
.a-propos-toc {
display: none;
}
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
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;
}
}
+132
View File
@@ -0,0 +1,132 @@
window.dash_clientside = Object.assign({}, window.dash_clientside, {
leaflet: {
pointToLayer: function (feature, latlng, context) {
return L.circleMarker(latlng, {
radius: 5,
fillColor: feature.properties.marker_color,
color: "white",
weight: 1,
opacity: 1,
fillOpacity: 0.8,
}).bindTooltip(feature.properties.tooltip);
},
clusterToLayer: function (feature, latlng, index, context) {
console.log(feature);
console.log(index);
console.log(context);
const count = feature.properties.point_count;
const size = count < 100 ? 30 : count < 1000 ? 40 : 50;
const color = "#555"; // Default cluster color
const icon = L.divIcon({
html: `<div style="background-color: ${context.fillColor}; width: ${size}px; height: ${size}px; border-radius: 50%; display: flex; align-items:center; justify-content:center; color: white; border: 2px solid white; font-weight: bold;">${count}</div>`,
className: "marker-cluster",
iconSize: L.point(size, size),
});
return L.marker(latlng, { icon: icon });
},
},
clientside: {
clean_filters: function (trigger) {
if (!trigger) {
return window.dash_clientside.no_update;
}
// Helper to set value on a React text input
const setNativeValue = (element, value) => {
const valueSetter = Object.getOwnPropertyDescriptor(
element,
"value"
).set;
const prototype = Object.getPrototypeOf(element);
const prototypeValueSetter = Object.getOwnPropertyDescriptor(
prototype,
"value"
).set;
if (valueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter.call(element, value);
} else {
valueSetter.call(element, value);
}
element.dispatchEvent(new Event("input", { bubbles: true }));
};
const cleanInputs = () => {
const inputs = document.querySelectorAll(
'.dash-filter input[type="text"]'
);
inputs.forEach((input) => {
let val = input.value;
let original = val;
// Remove "icontains " prefix
if (/^icontains\s+/i.test(val)) {
val = val.replace(/^icontains\s+/i, "");
// Check for surrounding quotes (single or double) and remove them
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.substring(1, val.length - 1);
}
}
// Handle relational operators (i<, s>, i<=, etc.)
else if (/^[is][<>]=?/i.test(val)) {
val = val.substring(1);
}
if (val !== original) {
try {
// Try setting it the React-friendly way
setNativeValue(input, val);
} catch (e) {
// Fallback to direct assignment if fancy way fails
input.value = val;
}
}
});
};
// Use MutationObserver to wait for table to appear/update
const observer = new MutationObserver((mutations) => {
cleanInputs();
});
const target = document.querySelector(".dash-table-container");
if (target) {
observer.observe(target, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["value"],
});
// Disconnect after 5 seconds
setTimeout(() => {
observer.disconnect();
}, 5000);
// Also try immediately just in case
cleanInputs();
} else {
// Poll briefly if container not found yet
const checkInterval = setInterval(() => {
const t = document.querySelector(".dash-table-container");
if (t) {
clearInterval(checkInterval);
observer.observe(t, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 5000);
cleanInputs();
}
}, 200);
// Stop polling after 2s if still nothing
setTimeout(() => clearInterval(checkInterval), 2000);
}
return window.dash_clientside.no_update;
},
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

+182
View File
@@ -0,0 +1,182 @@
import fcntl
import os
from pathlib import Path
from time import sleep
import duckdb
import polars as pl
import polars.selectors as cs
from polars.exceptions import ComputeError
from src.utils import get_last_modified, logger
def should_rebuild(db_path: Path, parquet_path: str) -> bool:
db_path = Path(db_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
last_modified: float = get_last_modified(parquet_path)
return last_modified > db_path.stat().st_mtime
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:
logger.info("Lecture du parquet échouée, nouvelle tentative dans 10s...")
sleep(10)
lff = pl.scan_parquet(str(parquet_path))
lff = lff.sort(by=["dateNotification", "uid"], descending=True, nulls_last=True)
lff = lff.filter(pl.col("donneesActuelles")).drop("donneesActuelles")
# booleans_to_strings: true → "oui", false → "non"
lff = lff.with_columns(
pl.col(cs.Boolean)
.cast(pl.String)
.str.replace("true", "oui")
.str.replace("false", "non")
)
for col in ["acheteur_nom", "titulaire_nom"]:
lff = lff.with_columns(
pl.when(pl.col(col).is_null())
.then(pl.lit("[Identifiant non reconnu dans la base INSEE]"))
.otherwise(pl.col(col))
.name.keep()
)
return lff.collect()
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)
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 {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)
frame.write_parquet(str(staging_parquet))
try:
with duckdb.connect(str(tmp_path)) as w:
w.execute(
f"CREATE TABLE decp AS SELECT * FROM read_parquet('{staging_parquet}')"
)
w.execute(
"CREATE TABLE acheteurs_marches AS "
"SELECT DISTINCT uid, objet, acheteur_id FROM decp "
"ORDER BY acheteur_id"
)
w.execute(
"CREATE TABLE titulaires_marches AS "
"SELECT DISTINCT uid, objet, titulaire_id FROM decp "
"ORDER BY titulaire_id"
)
w.execute(
"CREATE TABLE acheteurs_departement AS "
"SELECT DISTINCT acheteur_id, acheteur_nom, acheteur_departement_code "
"FROM decp ORDER BY acheteur_nom"
)
w.execute(
"CREATE TABLE titulaires_departement AS "
"SELECT DISTINCT titulaire_id, titulaire_nom, titulaire_departement_code "
"FROM decp ORDER BY titulaire_nom"
)
finally:
if staging_parquet.exists():
staging_parquet.unlink()
os.replace(tmp_path, db_path)
logger.info(f"Base DuckDB construite : {db_path}")
def _ensure_database() -> Path:
db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb"))
parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "")
lock_path = db_path.with_suffix(".duckdb.lock")
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)
else:
logger.debug("Base de données déjà disponible et à jour.")
return db_path
DB_PATH = _ensure_database()
conn: duckdb.DuckDBPyConnection = duckdb.connect(str(DB_PATH), read_only=True)
schema: pl.Schema = conn.execute("SELECT * FROM decp LIMIT 0").pl().schema
def get_cursor() -> duckdb.DuckDBPyConnection:
"""Return a per-request cursor that shares the process-wide connection."""
return conn.cursor()
def query_marches(
where_sql: str = "TRUE",
params: tuple | list = (),
columns: list[str] | None = None,
order_by: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> pl.DataFrame:
"""Run a parameterized SELECT against the decp table and return Polars.
`where_sql` and `order_by` are trusted SQL fragments (callers are internal
code, never user input). `params` values are passed through DuckDB's
parameter binding.
"""
cols = ", ".join(columns) if columns else "*"
sql = f"SELECT {cols} FROM decp WHERE {where_sql}"
if order_by:
sql += f" ORDER BY {order_by}"
if limit is not None:
sql += f" LIMIT {int(limit)}"
if offset is not None:
sql += f" OFFSET {int(offset)}"
logger.debug("query_marches: " + sql.replace("?", "{}").format(*params))
return get_cursor().execute(sql, list(params)).pl()
def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
"""Retourne le nombre de lignes correspondant à where_sql."""
sql = f"SELECT COUNT(*) FROM decp WHERE {where_sql}"
logger.debug("count_marches: " + sql.replace("?", "{}").format(*params))
result = get_cursor().execute(sql, list(params)).fetchone()
return int(result[0]) if result else 0
def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
"""Retourne le nombre de uid distincts correspondant à where_sql."""
sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}"
logger.debug("count_unique_marches: " + sql.replace("?", "{}").format(*params))
result = get_cursor().execute(sql, list(params)).fetchone()
return int(result[0]) if result else 0
+901
View File
@@ -0,0 +1,901 @@
from datetime import datetime
from typing import Literal
from urllib.error import HTTPError, URLError
import dash_bootstrap_components as dbc
import dash_leaflet as dl
import dash_leaflet.express as dlx
import numpy as np
import plotly.express as px
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
def get_yearly_statistics(statistics, today_str) -> html.Div:
# Build DataFrame from statistics
years = list(reversed(range(2018, int(today_str.split("/")[-1]) + 1)))
data = []
for year in years:
year_str = str(year)
stat = statistics[year_str]
data.append(
{
"Année": year_str,
"Marchés et accord-cadres": format_number(
stat["nb_notifications_marches"]
),
"Acheteurs": format_number(stat["nb_acheteurs_uniques"]),
"Titulaires": format_number(stat["nb_titulaires_uniques"]),
}
)
dff = pl.DataFrame(data)
# Create Dash DataTable
table = dash_table.DataTable(
data=dff.to_dicts(),
columns=[
{"name": "Année", "id": "Année"},
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
{"name": "Acheteurs", "id": "Acheteurs"},
{"name": "Titulaires", "id": "Titulaires"},
],
page_size=10,
sort_action="none",
filter_action="none",
style_header={"fontFamily": "Inter", "fontSize": "16px"},
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
)
return html.Div(children=table, className="marches_table")
def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
labels = {
"dateNotification": "notification",
"datePublicationDonnees": "publication des données",
}
now_year = datetime.now().year
lff = lff.select("uid", type_date, "sourceDataset")
lff = lff.unique("uid")
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
.then(pl.lit("plateformes atexo"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
.then(pl.lit("aws"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
lff = lff.with_columns(pl.col(type_date).dt.year().alias("annee"))
lff = lff.filter(
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, now_year)
)
lff = lff.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
lff = (
lff.group_by([type_date, "sourceDataset"])
.len()
.sort(by=[type_date, "len"], descending=True)
)
lff = lff.sort(by=["sourceDataset"], descending=False)
dff: pl.DataFrame = lff.collect(engine="streaming")
fig = px.bar(
dff,
x=type_date,
y="len",
color="sourceDataset",
labels={
"len": "Nombre de marchés",
type_date: f"Mois de {labels[type_date]}",
"sourceDataset": "Source de données",
},
)
graph = dcc.Graph(figure=fig)
return graph
def get_sources_tables(source_path) -> html.Div:
try:
dff = pl.read_csv(source_path)
except (URLError, HTTPError):
return html.Div("Erreur de connexion")
dff = dff.with_columns(
(
pl.lit('<a href = "')
+ pl.col("url")
+ pl.lit('">')
+ pl.col("nom")
+ pl.lit("</a>")
).alias("nom")
)
dff = dff.drop("url", "unique")
dff = dff.sort(by=["nb_marchés"], descending=True)
columns = {
"nom": "Nom de la source",
"organisation": "Responsable de publication",
"nb_marchés": "Nb de marchés",
"nb_acheteurs": "Nb d'acheteurs",
"code": "Code",
}
datatable = dash_table.DataTable(
id="source_table",
data=dff.to_dicts(),
columns=[
{
"name": columns[i],
"id": i,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
}
for i in dff.schema.names()
],
style_cell_conditional=[
{
"if": {"column_id": ["nom", "organisation"]},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
sort_action="native",
markdown_options={"html": True},
style_header={"fontFamily": "Inter", "fontSize": "16px"},
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
)
return html.Div(children=datatable)
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)
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],
zoom=settings["zoom"],
)
fig.update_traces(marker=dict(size=10))
# Configuration de la carte (interactive - zoomable)
fig.update_layout(
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,
)
return html.Div(
dcc.Graph(figure=fig, config={"displayModeBar": False}),
)
class DataTable(dash_table.DataTable):
def __init__(
self,
dtid: str,
hidden_columns: list[str] | None = None,
data: list[dict[str, str | int | float | bool]] | None = None,
columns: list[dict[str, str]] | None = None,
page_size: int = 20,
page_action: Literal["native", "custom", "none"] = "native",
sort_action: Literal["native", "custom", "none"] = "native",
filter_action: Literal["native", "custom", "none"] = "native",
style_cell_conditional: list | None = None,
style_cell: dict | None = None,
**kwargs,
):
# Styles de base
style_cell_conditional_common = [
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_id"},
"minWidth": "160px",
"overflow": "hidden",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
]
style_cell_common = {"fontFamily": "Inter", "fontSize": "16px"}
for key in DATA_SCHEMA.keys():
field = DATA_SCHEMA[key]
if field["type"] in ["number", "integer"]:
rule = {
"if": {"column_id": field["name"]},
"textAlign": "right",
# "fontFamily": "Fira Code",
}
style_cell_conditional_common.append(rule)
style_cell_conditional = (
style_cell_conditional or []
) + style_cell_conditional_common
if style_cell:
style_cell.update(style_cell_common)
else:
style_cell = style_cell_common
style_header = style_cell
# Initialisation de la classe parente avec les arguments
super().__init__(
id=dtid,
data=data,
columns=columns,
cell_selectable=False,
page_size=page_size,
filter_action=filter_action,
page_action=page_action,
filter_options={
"case": "insensitive",
"placeholder_text": "Filtre de colonne...",
},
sort_action=sort_action,
sort_mode="multi",
row_deletable=False,
page_current=0,
style_cell_conditional=style_cell_conditional,
data_timestamp=0,
markdown_options={"html": True},
style_header=style_header,
style_cell=style_cell,
tooltip_duration=8000,
tooltip_delay=350,
hidden_columns=hidden_columns,
**kwargs, # Possibilité de remplacer des arguments
)
def get_duplicate_matrix() -> dcc.Graph:
"""
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
:return:
"""
lff = pl.scan_parquet(
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
).sort("sourceDataset")
lff = lff.select(
["sourceDataset", "unique"] + sorted(lff.collect_schema().names()[2:])
)
dff = lff.collect()
# Extract data
z_data = dff.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
x_labels = dff.columns[1:] # columns after "sourceDataset"
y_labels = dff["sourceDataset"].to_list()
# Create heatmap
fig = go.Figure(
data=go.Heatmap(
z=z_data,
x=x_labels,
y=y_labels,
colorscale=[
[0.0, "white"], # 0% → white
[0.10, "lightsalmon"], # 10% → light warm tone
[1.0, "darkred"], # 100% → deep red
],
zmin=0,
zmax=1,
hoverongaps=False,
showscale=True,
hovertemplate=(
"<b>%{z:.0%}</b> des marchés présents dans <b>%{y}</b> sont également présents dans <b>%{x}</b>"
),
)
)
# Update layout: make it wider and taller
fig.update_layout(
title="",
xaxis_title="Sources de données",
yaxis_title="Sources de données",
yaxis=dict(autorange="reversed"),
xaxis=dict(tickangle=45, tickfont=dict(size=10)), # Smaller x-tick labels
coloraxis_colorbar=dict(title="Percentage", tickfont=dict(size=10)),
width=1000, # Wider
height=1000, # Taller
font=dict(size=11), # Overall font size
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
)
return dcc.Graph(figure=fig)
def get_geographic_maps(dff: pl.DataFrame) -> list[dbc.Col] | list:
"""
Génère les cartes géographiques pour l'hexagone et les DOM-TOM.
"""
regions: dict = {
"Hexagone": {
"coordinates": [46.6, 2.2],
"zoom_leaflet": 5,
"zoom_chloropleth": 1,
"name": "Hexagone",
},
"971": {
"coordinates": [16.23, -61.55],
"zoom_leaflet": 9,
"zoom_chloropleth": 1,
"name": "Guadeloupe",
},
"972": {
"coordinates": [14.64, -61.02],
"zoom_leaflet": 10,
"zoom_chloropleth": 1,
"name": "Martinique",
},
"973": {
"coordinates": [3.93, -53.12],
"zoom_leaflet": 7,
"zoom_chloropleth": 1,
"name": "Guyane",
},
"974": {
"coordinates": [-21.11, 55.53],
"zoom_leaflet": 9,
"zoom_chloropleth": 1,
"name": "La Réunion",
},
"976": {
"coordinates": [-12.82, 45.16],
"zoom_leaflet": 10,
"zoom_chloropleth": 1,
"name": "Mayotte",
},
}
def make_map_data(region_code: str) -> tuple[list, str | None]:
lff: pl.LazyFrame = dff.lazy()
if region_code == "Hexagone":
lff = lff.filter(
(pl.col("acheteur_departement_code").str.len_chars() == 2)
& (pl.col("titulaire_departement_code").str.len_chars() == 2)
)
else:
lff = lff.filter(
(pl.col("acheteur_departement_code") == code)
| (pl.col("titulaire_departement_code") == code)
)
nb_marches = lff.select("uid").collect()["uid"].n_unique()
if nb_marches == 0:
return [], None
dfs = []
if (code == "Hexagone" and nb_marches > 30000) or (
code != "Hexagone" and nb_marches > 10000
):
_map_type: str = "chloropleth"
lff = lff.rename({"acheteur_departement_code": "Département"})
lff = (
lff.select(["uid", "Département"])
.drop_nulls()
.group_by("uid")
.agg(pl.col("Département").first())
.group_by("Département")
.len("uid")
)
dfs.append(lff.collect())
else:
_map_type: str = "clusters"
for org_type in ["acheteur", "titulaire"]:
lff_org = (
lff.select(
"uid",
f"{org_type}_longitude",
f"{org_type}_latitude",
f"{org_type}_nom",
)
.group_by(
f"{org_type}_longitude",
f"{org_type}_latitude",
f"{org_type}_nom",
)
.len("nb_marches")
.filter(
pl.col(f"{org_type}_latitude").is_not_null()
& pl.col(f"{org_type}_longitude").is_not_null()
)
)
markers = []
# Couleurs accessibles (Okabe-Ito)
colors = {
"acheteur": "#E69F00", # orange
"titulaire": "#56B4E9", # bleu ciel
}
for row in lff_org.collect().to_dicts():
markers.append(
{
"lat": row[f"{org_type}_latitude"],
"lon": row[f"{org_type}_longitude"],
"tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
"marker_color": colors[org_type],
}
)
dfs.append(markers)
return dfs, _map_type
cols = []
for code in regions.keys():
regions[code]["data"], map_type = make_map_data(code)
if map_type == "chloropleth":
map_graph = make_chloropleth_map(regions[code])
elif map_type == "clusters":
map_graph = make_clusters_map(regions[code])
elif map_type is None:
continue
else:
raise ValueError(f"Map type '{map_type}' not recognised")
lg, xl = (12, 8) if code == "Hexagone" else (6, 4)
col = make_card(regions[code]["name"], fig=map_graph, lg=lg, xl=xl)
cols.append(col)
return cols
def make_chloropleth_map(region: dict) -> dcc.Graph:
df_map = region["data"][0]
fig = px.choropleth(
df_map,
geojson=DEPARTEMENTS_GEOJSON,
locations="Département",
color="uid",
color_continuous_scale="Reds",
range_color=(df_map["uid"].min(), df_map["uid"].max()),
labels={"uid": "Marchés attribués"},
scope="europe",
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
mapbox={
"style": "carto-positron",
"center": {"lon": 10, "lat": 10},
"zoom": 8,
"domain": {"x": [0, 1], "y": [0, 1]},
}
)
graph = dcc.Graph(figure=fig, config={"displayModeBar": False})
return graph
def make_clusters_map(region: dict) -> dl.Map:
# JavaScript functions for styling
ns = Namespace("dash_clientside", "leaflet")
point_to_layer = ns("pointToLayer")
cluster_to_layer = ns("clusterToLayer")
name = region["name"]
# Données de la région
region_acheteurs = region["data"][0]
region_titulaires = region["data"][1]
# Couleurs
color_acheteur = region_acheteurs[0]["marker_color"]
color_titulaire = region_titulaires[0]["marker_color"]
acheteurs_geojson_data = dlx.dicts_to_geojson(region_acheteurs)
titulaires_geojson_data = dlx.dicts_to_geojson(region_titulaires)
center, zoom = region["coordinates"], region["zoom_leaflet"]
region_id = name.lower().replace(" ", "-")
leaflet_map = dl.Map(
[
dl.TileLayer(),
dl.GeoJSON(
data=titulaires_geojson_data,
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"geojson-{region_id}-titulaires",
options={"fillColor": color_titulaire},
),
dl.GeoJSON(
data=acheteurs_geojson_data,
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"geojson-{region_id}-acheteurs",
options={"fillColor": color_acheteur},
),
],
center=center,
zoom=zoom,
style={
"width": "100%",
"height": "400px" if name == "Hexagone" else "300px",
},
id=f"map-{region_id}",
)
return leaflet_map
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
if "titulaire_distance" not in lff.collect_schema().names():
dff = pl.DataFrame({"titulaire_distance": pl.Series([], dtype=pl.Float64)})
else:
dff = (
lff.select("titulaire_distance")
.drop_nulls()
.filter(pl.col("titulaire_distance") > 0)
.collect(engine="streaming")
)
log_distances = dff["titulaire_distance"].log(10).to_numpy()
fig = go.Figure()
if len(log_distances) > 0:
counts, bin_edges = np.histogram(log_distances, bins=25)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
bin_widths = bin_edges[1:] - bin_edges[:-1]
bin_edges_km = 10.0**bin_edges
def fmt_km(km):
if km < 10:
return f"{km:.1f}"
elif km < 1000:
return f"{round(km)}"
else:
return f"{round(km):,}".replace(",", " ")
hover_texts = []
for i in range(len(counts)):
nb = f"{counts[i]:,}".replace(",", " ")
hover_texts.append(
f"Distance : {fmt_km(bin_edges_km[i])} {fmt_km(bin_edges_km[i + 1])} km"
f"<br>Nombre de marchés : {nb}"
)
fig.add_trace(
go.Bar(
x=bin_centers,
y=counts,
width=bin_widths,
hovertext=hover_texts,
hoverinfo="text",
)
)
fig.update_layout(bargap=0)
fig.update_layout(margin=dict(r=10, t=10))
fig.update_xaxes(
tickvals=[0, 1, 2, 3, 4],
ticktext=["1", "10", "100", "1 000", "10 000"],
title_text="Distance (km)",
)
fig.update_yaxes(title_text="Nombre de marchés")
return dcc.Graph(figure=fig)
def get_dashboard_summary_table(dff, dff_per_uid, nb_marches):
nb_acheteurs = dff.select("acheteur_id").n_unique()
nb_titulaires = dff.select("titulaire_id", "titulaire_typeIdentifiant").n_unique()
total_montant = int(dff_per_uid.select(pl.col("montant").sum()).item())
median_distance = dff.select(pl.median("titulaire_distance")).item()
summary_table = [
html.P(["Nombre de marchés : ", html.Strong(str(format_number(nb_marches)))]),
html.P(
[
"Nombre d'acheteurs uniques : ",
html.Strong(str(format_number(nb_acheteurs))),
]
),
html.P(
[
"Nombre de titulaires uniques : ",
html.Strong(str(format_number(nb_titulaires))),
]
),
html.P(
[
"Montant total (",
html.Span(
"?",
id={"type": "modal-trigger", "index": "montant"},
style={"cursor": "pointer", "textDecoration": "underline dotted"},
),
") : ",
html.Strong(format_number(total_montant) + ""),
]
),
html.P(
[
"Distance acheteur-titulaire médiane : ",
html.Strong(format_number(median_distance) + " km"),
]
),
]
return summary_table
def make_card(
title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4
) -> dbc.Col:
children = []
if title:
children.append(html.H5(title, className="card-title"))
if subtitle:
children.append(html.H6(subtitle, className="card-subtitle mb-2 text-muted"))
if fig is not None:
children.append(fig)
if paragraphs:
for p in paragraphs:
p.className = "card-text"
children.append(p)
card = dbc.Col(
html.Div(html.Div(className="card-body", children=children), className="card"),
lg=lg,
xl=xl,
# width=width,
# className="mb-4",
)
return card
def make_donut(
lff: pl.LazyFrame,
names_col,
per_uid: bool,
nulls="?",
potentially_many_names: bool = False,
):
title = DATA_SCHEMA[names_col]["title"]
lff = lff.rename({names_col: title})
lff = lff.select("uid", title)
if per_uid:
lff = lff.group_by("uid").first()
lff = lff.group_by(title).len("Nombre")
lff = lff.with_columns(pl.col(title).replace(None, pl.lit(nulls)))
dff = lff.collect(engine="streaming")
nb_names = dff[title].n_unique()
sum_values = dff["Nombre"].sum()
dff = dff.with_columns(
pl.when((pl.col("Nombre") / sum_values) < 0.01)
.then(pl.lit("Autres"))
.otherwise(pl.col(title))
.alias(title)
)
dff = dff.with_columns(
pl.col("Nombre")
.map_elements(format_number, return_dtype=pl.String)
.alias("Nombre_fmt")
)
fig = px.pie(
dff,
values="Nombre",
names=title,
hole=0.4,
color_discrete_sequence=px.colors.qualitative.Safe,
custom_data=["Nombre_fmt"],
)
fig = fig.update_traces(
texttemplate="<b>%{label}</b><br><b>%{percent}</b>",
hovertemplate="<b>%{label}</b><br>%{customdata[0]}<extra></extra>",
)
fig = fig.update_layout(showlegend=False, font=dict(size=14))
graph = dcc.Graph(figure=fig)
if potentially_many_names:
return graph, nb_names
return graph
def make_column_picker(page: str):
table_data = []
table_columns = [
{
"id": col,
"name": DATA_SCHEMA[col]["title"],
"description": DATA_SCHEMA[col]["description"],
}
for col in schema.names()
]
for column in table_columns:
new_column = {
"id": column["id"],
"name": column["name"],
"description": DATA_SCHEMA[column["id"]]["description"],
}
table_data.append(new_column)
table = (
DataTable(
row_selectable="multi",
data=table_data,
filter_action="native",
sort_action="none",
style_cell={
"textAlign": "left",
},
columns=[
{
"name": "Nom",
"id": "name",
},
{
"name": "Description",
"id": "description",
},
],
style_cell_conditional=[
{
"if": {"column_id": "description"},
"minWidth": "450px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
}
],
page_action="none",
dtid=f"{page}_column_list",
),
)
return table
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
if isinstance(data, pl.LazyFrame):
lff = data
else:
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
if org_type == "titulaire":
extra_columns.append("titulaire_typeIdentifiant")
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra_columns
lff = lff.select(columns)
lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra_columns).agg(
pl.len().alias("Attributions")
)
lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
lff = lff.cast(pl.String)
lff = lff.fill_null("")
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()
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=[f"{org_type}_id"]
)
dff = add_links(dff)
data = dff.to_dicts()
# data = add_links_in_dict(data, f"{org_type}")
return DataTable(
dtid=f"top10_{org_type}",
data=data,
page_action="native",
page_size=10,
columns=columns,
tooltip_header=tooltip,
filter_action="native" if filters else "none",
)
View File
+202
View File
@@ -0,0 +1,202 @@
import os
from dash import dcc, html, register_page
from src.figures import get_sources_tables
from src.utils.seo import META_CONTENT
NAME = "À propos"
register_page(
__name__,
path="/a-propos",
title="À propos | decp.info",
name="À propos",
description="En savoir plus sur decp.info, l'outil d'exploration des données essentielles de la commande publique.",
image_url=META_CONTENT["image_url"],
order=5,
)
layout = html.Div(
className="container",
children=[
html.H2(NAME),
html.Div(
className="a-propos-container",
children=[
# Main Content Column
html.Div(
className="a-propos-content",
children=[
dcc.Markdown(
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos besoins. Cet outil ne peut rester performant que si je comprends les problèmes qu'il peut aider à résoudre. Ce projet ne peut rester gratuit que grâce au financement du développement de nouvelles fonctionnalités.
En effet, le potentiel des données d'attribution de marchés et des données qui peuvent les enrichir est très loin d'être exploité par
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-titulaire...)
- des alertes par email si des marchés correspondant à certains critères
- ...et toutes les fonctionnalités auxquelles vous pourrez penser
"""
),
html.H4("Consommer les données brutes", id="donnees-brutes"),
dcc.Markdown(
"""
Vous pouvez consommer les données qui alimentent decp.info
- en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données
- en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest)
"""
),
html.H4("Contact", id="contact"),
dcc.Markdown(
"""
- Email : [colin@colmo.tech](mailto:colin@colmo.tech)
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
"""
),
html.H4("Pour contribuer", id="contribuer"),
dcc.Markdown(
"""
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
- écrivez-moi et on discute !
"""
),
html.H4("Pour explorer le projet", id="explorer"),
dcc.Markdown(
"""
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
- 🗞️ [mon blog](https://colin.maudry.com)
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
- 🚰 code source
- [de decp.info](https://github.com/ColinMaudry/decp.info)
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
"""
),
html.H4(
"Qualité et exhaustivité des données",
id="qualite-exhausitivite",
),
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.
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."""
),
html.H4("Sources de données ", id="sources"),
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
html.H4("Mentions légales", id="mentions-legales"),
html.H5("Publication", id="publication"),
dcc.Markdown(
"""
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
Siège social : 1 carrefour Jouaust, 35000 Rennes
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville lEvêque, 75008 Paris
"""
),
html.H5("Suivi d'audience", id="audience"),
dcc.Markdown(
"""
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
**Ce site naffiche pas de bannière de consentement aux cookies, pourquoi ?**
Cest vrai, vous navez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes daccord avec le dépôt de cookies.
Rien dexceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi daudience, correctement configurés pour respecter la vie privée, sont exemptés dautorisation préalable.
Jutilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il mest donc impossible dassocier vos visites sur ce site à votre personne.
J'enregistre également les données suivantes, de manière anonyme, afin de mieux comprendre comment vous utilisez le site et l'améliorer :
- recherches sur la page d'accueil
- filtres appliqués aux données
"""
),
html.H5("Attributions", id="attributions"),
dcc.Markdown("""
Les polices de caractères sont distribuées par [Bunny fonts](https://fonts.bunny.net), une alternative européenne et qualitative à Google Fonts.
- la police de caractère [Inter](https://fonts.bunny.net/family/inter), principale police de ce site, a été créée par The Inter Project Authors ([source](https://github.com/rsms/inter))
- la police de caractère [Fira Code](https://fonts.bunny.net/family/fira-code), la police à largeure fixe, a été créée par The Fira Code Project Authors (https://github.com/tonsky/FiraCode)
"""),
html.H4(
"Liste des marchés par département", id="liste_marches"
),
dcc.Markdown(
"""
- [Marchés par département](/departements)
"""
),
],
),
# Table of Contents Column
html.Div(
className="a-propos-toc",
children=[
html.Div(
[
html.A(
"Consommer les données brutes",
href="#donnees-brutes",
className="toc-link",
),
html.A(
"Contact", href="#contact", className="toc-link"
),
html.A(
"Pour contribuer",
href="#contribuer",
className="toc-link",
),
html.A(
"Pour explorer le projet",
href="#explorer",
className="toc-link",
),
html.A(
"Qualité et exhaustivité des données",
href="#qualite-exhausitivite",
className="toc-link",
),
html.A(
"Sources de données",
href="#sources",
className="toc-link",
),
html.A(
"Mentions légales",
href="#mentions-legales",
className="toc-link",
),
html.A(
"Publication",
href="#publication",
className="toc-link toc-level-2",
),
html.A(
"Suivi d'audience",
href="#audience",
className="toc-link toc-level-2",
),
html.A(
"Attributions",
href="#attributions",
className="toc-link toc-level-2",
),
]
),
],
),
],
),
],
)
+538
View File
@@ -0,0 +1,538 @@
import datetime
from typing import Any
import dash_bootstrap_components as dbc
import polars as pl
from dash import (
ClientsideFunction,
Input,
Output,
State,
callback,
clientside_callback,
dcc,
html,
register_page,
)
from src.db import query_marches, schema
from src.figures import (
DataTable,
get_distance_histogram,
get_top_org_table,
make_card,
make_column_picker,
point_on_map,
)
from src.utils.data import DF_ACHETEURS, get_annuaire_data, get_departement_region
from src.utils.frontend import get_button_properties
from src.utils.seo import META_CONTENT
from src.utils.table import (
COLUMNS,
filter_table_data,
format_number,
get_default_hidden_columns,
prepare_table_data,
sort_table_data,
)
from src.utils.tracking import track_search
def get_title(acheteur_id: str | None = None) -> str:
acheteur_nom = DF_ACHETEURS.filter(pl.col("acheteur_id") == acheteur_id).select(
"acheteur_nom"
)
if acheteur_nom.height > 0:
return f"Marchés publics attribués par {acheteur_nom.item(0, 0)} | decp.info"
return "Marchés publics attribués | decp.info"
register_page(
__name__,
path_template="/acheteurs/<acheteur_id>",
title=get_title,
name="Acheteur",
description="Consultez les marchés publics attribués par cet acheteur.",
image_url=META_CONTENT["image_url"],
order=5,
)
DATATABLE = html.Div(
className="marches_table",
children=DataTable(
dtid="acheteur_datatable",
persistence=True,
persistence_type="local",
persisted_props=["filter_query", "sort_by"],
page_action="custom",
filter_action="custom",
sort_action="custom",
page_size=10,
hidden_columns=[],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
layout = [
dcc.Store(id="acheteur_data", storage_type="memory"),
dcc.Store(id="acheteur-hidden-columns", storage_type="local"),
dcc.Store(id="filter-cleanup-trigger-acheteur"),
dcc.Location(id="acheteur_url", refresh="callback-nav"),
html.Div(
children=[
html.Div(
style={"marginBottom": "50px"},
children=[
dbc.Row(
className="mb-2",
children=[
dbc.Col(
html.H2(
children=[
html.Span(id="acheteur_siret"),
" - ",
html.Span(id="acheteur_nom"),
],
),
width=8,
),
dbc.Col(
dcc.Dropdown(
id="acheteur_year",
options=["Toutes les années"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
width=4,
),
],
),
dbc.Row(
className="mb-2",
children=[
dbc.Col(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(
[
"Commune : ",
html.Strong(id="acheteur_commune"),
]
),
html.P(
[
"Département : ",
html.Strong(id="acheteur_departement"),
]
),
html.P(
["Région : ", html.Strong(id="acheteur_region")]
),
html.A(
id="acheteur_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
),
],
width=4,
),
dbc.Col(
children=[
html.P(id="acheteur_titre_stats"),
html.P(id="acheteur_marches_attribues"),
html.P(id="acheteur_titulaires_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-data-acheteur",
className="btn btn-primary",
),
dcc.Download(id="download-data-acheteur"),
],
width=4,
),
dbc.Col(
id="acheteur_map",
width=4,
),
],
),
dbc.Row(
children=[
dbc.Col(
className="marches_table",
id="top10_titulaires",
width=8,
),
dbc.Col(id="acheteur-distance-histogram", width=4),
],
),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics attribués"),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
# Bouton modal des colonnes affichées
dbc.Button(
"Colonnes affichées",
id="acheteur_columns_open",
className="column_list",
),
html.P("lignes", id="acheteur_nb_rows"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-filtered-data-acheteur",
className="btn btn-primary",
disabled=True,
),
dcc.Download(id="acheteur-download-filtered-data"),
dbc.Button(
"Remise à zéro",
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
id="btn-acheteur-reset",
),
],
className="table-menu",
),
dbc.Modal(
[
dbc.ModalHeader(
dbc.ModalTitle("Choix des colonnes à afficher")
),
dbc.ModalBody(
id="acheteur_columns_body",
children=make_column_picker("acheteur"),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="acheteur_columns_close",
className="ms-auto",
n_clicks=0,
)
),
],
id="acheteur_columns",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="xl",
),
DATATABLE,
],
),
],
),
]
@callback(
Output(component_id="acheteur_siret", component_property="children"),
Output(component_id="acheteur_nom", component_property="children"),
Output(component_id="acheteur_commune", component_property="children"),
Output(component_id="acheteur_map", component_property="children"),
Output(component_id="acheteur_departement", component_property="children"),
Output(component_id="acheteur_region", component_property="children"),
Output(component_id="acheteur_lien_annuaire", component_property="href"),
Input(component_id="acheteur_url", component_property="pathname"),
)
def update_acheteur_infos(url):
acheteur_siret = url.split("/")[-1]
# if len(acheteur_siret) != 14:
# acheteur_siret = (
# f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
# )
data = get_annuaire_data(acheteur_siret)
data_etablissement = data.get("matching_etablissements") if data else None
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"],
departement_code,
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
)
raison_sociale = data["nom_raison_sociale"]
libelle_commune = data_etablissement["libelle_commune"]
else:
acheteur_map = html.Div()
code_departement, nom_departement, nom_region = "", "", ""
departement = ""
lien_annuaire = ""
raison_sociale = ""
libelle_commune = ""
return (
acheteur_siret,
raison_sociale,
libelle_commune,
acheteur_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="acheteur_marches_attribues", component_property="children"),
Output(
component_id="acheteur_titulaires_differents", component_property="children"
),
Input(component_id="acheteur_data", component_property="data"),
)
def update_acheteur_stats(data):
dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
if dff.height == 0:
dff = pl.DataFrame(schema=schema)
df_marches = dff.unique("id")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_attribues = [html.Strong(nb_marches), " marchés et accord-cadres attribués"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_titulaires = dff.unique("titulaire_id").height
nb_titulaires = [
html.Strong(format_number(nb_titulaires)),
" titulaires (SIRET) différents",
]
del dff
return marches_attribues, nb_titulaires
@callback(
Output(component_id="acheteur_data", component_property="data"),
Output("btn-download-data-acheteur", "disabled"),
Output("btn-download-data-acheteur", "children"),
Output("btn-download-data-acheteur", "title"),
Input(component_id="acheteur_url", component_property="pathname"),
Input(component_id="acheteur_year", component_property="value"),
)
def get_acheteur_marches_data(url, ach_year: str) -> tuple:
acheteur_siret = url.split("/")[-1]
lff = query_marches("acheteur_id = ?", (acheteur_siret,)).lazy()
if ach_year and ach_year != "Toutes les années":
ach_year = int(ach_year)
lff = lff.filter(pl.col("dateNotification").dt.year() == ach_year)
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
dff: pl.DataFrame = lff.collect(engine="streaming")
download_disabled, download_text, download_title = get_button_properties(dff.height)
data = dff.to_dicts()
return data, download_disabled, download_text, download_title
@callback(
Output("acheteur_datatable", "data"),
Output("acheteur_datatable", "columns"),
Output("acheteur_datatable", "tooltip_header"),
Output("acheteur_datatable", "data_timestamp"),
Output("acheteur_nb_rows", "children"),
Output("btn-download-filtered-data-acheteur", "disabled"),
Output("btn-download-filtered-data-acheteur", "children"),
Output("btn-download-filtered-data-acheteur", "title"),
Output("filter-cleanup-trigger-acheteur", "data"),
Input("acheteur_url", "href"),
Input("acheteur_data", "data"),
Input("acheteur_datatable", "page_current"),
Input("acheteur_datatable", "page_size"),
Input("acheteur_datatable", "filter_query"),
Input("acheteur_datatable", "sort_by"),
State("acheteur_datatable", "data_timestamp"),
)
def get_last_marches_data(
href, data, page_current, page_size, filter_query, sort_by, data_timestamp
) -> tuple:
return prepare_table_data(
data, data_timestamp, filter_query, page_current, page_size, sort_by, "acheteur"
)
@callback(
Output(component_id="top10_titulaires", component_property="children"),
Input(component_id="acheteur_data", component_property="data"),
)
def get_top_titulaires(data):
table = get_top_org_table(data, "titulaire", ["titulaire_distance"])
return make_card(fig=table, title="Top titulaires", lg=12, xl=12)
@callback(
Output("download-data-acheteur", "data"),
Input("btn-download-data-acheteur", "n_clicks"),
State(component_id="acheteur_data", component_property="data"),
State(component_id="acheteur_nom", component_property="children"),
State(component_id="acheteur_year", component_property="value"),
prevent_initial_call=True,
)
def download_acheteur_data(
n_clicks,
data: list[dict[str, Any]],
acheteur_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx")
@callback(
Output("acheteur-download-filtered-data", "data"),
State("acheteur_data", "data"),
Input("btn-download-filtered-data-acheteur", "n_clicks"),
State("acheteur_nom", "children"),
State("acheteur_datatable", "filter_query"),
State("acheteur_datatable", "sort_by"),
State("acheteur_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def download_filtered_acheteur_data(
data,
n_clicks,
acheteur_nom,
filter_query,
sort_by,
hidden_columns: list | None = None,
):
lff: pl.LazyFrame = pl.LazyFrame(
data
) # start from the full acheteur data, not from paginated table data
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
track_search(filter_query, "ach download")
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(
to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx"
)
# Pour nettoyer les icontains et i< des filtres
# voir aussi src/assets/dash_clientside.js
clientside_callback(
ClientsideFunction(
namespace="clientside",
function_name="clean_filters",
),
Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True),
Input("filter-cleanup-trigger-acheteur", "data"),
prevent_initial_call=True,
)
@callback(
Output("acheteur-hidden-columns", "data", allow_duplicate=True),
Input("acheteur_column_list", "selected_rows"),
prevent_initial_call=True,
)
def update_hidden_columns_from_checkboxes(selected_columns):
if selected_columns:
selected_columns = [COLUMNS[i] for i in selected_columns]
hidden_columns = [col for col in COLUMNS if col not in selected_columns]
return hidden_columns
else:
return []
@callback(
Output("acheteur_datatable", "hidden_columns"),
Input(
"acheteur-hidden-columns",
"data",
),
)
def store_hidden_columns(hidden_columns):
if hidden_columns is None:
hidden_columns = get_default_hidden_columns("acheteur")
return hidden_columns
@callback(
Output("acheteur_column_list", "selected_rows"),
Input("acheteur_datatable", "hidden_columns"),
State("acheteur_column_list", "selected_rows"), # pour éviter la boucle infinie
)
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
hidden_cols = hidden_cols or get_default_hidden_columns("acheteur")
# Show all columns that are NOT hidden
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
return visible_cols
@callback(
Output("acheteur_columns", "is_open"),
Input("acheteur_columns_open", "n_clicks"),
Input("acheteur_columns_close", "n_clicks"),
State("acheteur_columns", "is_open"),
)
def toggle_acheteur_columns(click_open, click_close, is_open):
if click_open or click_close:
return not is_open
return is_open
@callback(
Output("acheteur_datatable", "filter_query", allow_duplicate=True),
Output("acheteur_datatable", "sort_by"),
Input("btn-acheteur-reset", "n_clicks"),
prevent_initial_call=True,
)
def reset_view(n_clicks):
return "", []
@callback(
Output("acheteur-distance-histogram", "children"),
Input("acheteur_data", "data"),
)
def update_acheteur_distance_histogram(data):
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
fig = get_distance_histogram(lff)
return make_card(
title="Distance acheteurtitulaire",
subtitle="en nombre de marchés, échelle logarithmique",
fig=fig,
lg=12,
xl=12,
)
View File
+91
View File
@@ -0,0 +1,91 @@
from dash import Input, Output, callback, dcc, html, register_page
from src.db import get_cursor
from src.utils.data import DEPARTEMENTS
NAME = "Département"
def get_title(code):
return f"Marchés publics de {DEPARTEMENTS[code]['departement']} | decp.info"
def get_description(code):
return f"Marchés publics passés dans le département {DEPARTEMENTS[code]['departement']} | decp.info"
register_page(
__name__,
path_template="/departements/<code>",
title=get_title,
description=get_description,
order=50,
name=NAME,
)
layout = html.Div(
[
dcc.Location(id="departement_url", refresh="callback-nav"),
html.Div(id="departement_marches"),
]
)
@callback(
Output(component_id="departement_marches", component_property="children"),
Input(component_id="departement_url", component_property="pathname"),
)
def departement_marches(url):
departement = url.split("/")[-1]
def make_link_list(org_type) -> list:
table = (
"acheteurs_departement"
if org_type == "acheteur"
else "titulaires_departement"
if org_type == "titulaire"
else None
)
if table is None:
raise ValueError
col_prefix = org_type
rows = (
get_cursor()
.execute(
f"SELECT {col_prefix}_id, {col_prefix}_nom "
f"FROM {table} "
f"WHERE {col_prefix}_departement_code = ? "
f"ORDER BY {col_prefix}_nom",
[departement],
)
.fetchall()
)
link_list = []
for org_id, org_nom in rows:
li = html.Li(
[
dcc.Link(
org_nom,
href=url + f"/{org_type}/{org_id}",
title=f"Marchés publics de {org_nom}",
),
" ",
dcc.Link(
"(page dédiée)",
href=f"/{org_type}s/{org_id}",
title=f"Page dédiée aux marchés publics de {org_nom}",
),
]
)
link_list.append(li)
return link_list
content = [
html.H3("Acheteurs publics du département"),
html.Ul(make_link_list("acheteur")),
html.H3("Titulaires du département"),
html.Ul(make_link_list("titulaire")),
]
return content
+25
View File
@@ -0,0 +1,25 @@
from dash import dcc, html, register_page
from src.utils.data import DEPARTEMENTS
NAME = "Départements"
register_page(
__name__,
path="/departements",
title="Marchés par département | decp.info",
name="Départements",
description="Tous les marchés publics, classés par départements",
)
layout = html.Div(
[
html.H3("Départements"),
html.Ul(
[
html.Li(dcc.Link(d["departement"], href=f"/departements/{k}"))
for k, d in DEPARTEMENTS.items()
]
),
]
)
+107
View File
@@ -0,0 +1,107 @@
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"
def make_org_nom_verbe(org_type, org_id) -> tuple:
if org_type == "titulaire":
df = DF_TITULAIRES
verbe = "remportés"
elif org_type == "acheteur":
df = DF_ACHETEURS
verbe = "attribués"
else:
raise ValueError
org_nom = (
df.filter(pl.col(f"{org_type}_id") == org_id)
.select(f"{org_type}_nom")
.item(0, 0)
)
return org_nom, verbe
def get_title(code, org_type, org_id):
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):
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
return f"Liste complète des marchés publics {verbe} par {org_nom} et publiés par decp.info. Cliquez sur les liens pour consulter les détails de chaque marché."
register_page(
__name__,
path_template="/departements/<code>/<org_type>/<org_id>",
title=get_title,
description=get_description,
order=40,
name=NAME,
)
layout = html.Div(
[
dcc.Location(id="liste_marches_url", refresh="callback-nav"),
html.Div(id="liste_marches"),
]
)
@callback(
Output(component_id="liste_marches", component_property="children"),
Input(component_id="liste_marches_url", component_property="pathname"),
)
def liste_marches(url):
org_type = url.split("/")[-2]
org_id = url.split("/")[-1]
def make_link_list() -> list:
table = (
"acheteurs_marches"
if org_type == "acheteur"
else "titulaires_marches"
if org_type == "titulaire"
else None
)
if table is None:
raise ValueError
rows = (
get_cursor()
.execute(
f"SELECT uid, objet FROM {table} WHERE {org_type}_id = ?",
[org_id],
)
.fetchall()
)
return [
html.Li(
dcc.Link(
objet,
href=f"/marches/{uid}",
title=f"Marchés public attribué : {objet}",
)
)
for uid, objet in rows
]
nom, verbe = make_org_nom_verbe(org_type, org_id)
content = [
html.H3(f"Marchés publics {verbe} par {nom}"),
html.Ul(make_link_list()),
]
return content
+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
+262
View File
@@ -0,0 +1,262 @@
import json
from datetime import datetime
import dash_bootstrap_components as dbc
from dash import Input, Output, callback, dcc, html, register_page
from polars import selectors as cs
from src.db import query_marches
from src.utils.data import DATA_SCHEMA
from src.utils.seo import META_CONTENT, make_org_jsonld
from src.utils.table import format_values, unformat_montant
def get_title(uid: str = None) -> str:
return f"Marché {uid} | decp.info"
register_page(
__name__,
path_template="/marches/<uid>",
title=get_title,
name="Marché",
description="Consultez les détails de ce marché public : montant, acheteur, titulaires, modifications, etc.",
image_url=META_CONTENT["image_url"],
order=7,
)
layout = [
dcc.Store(id="marche_data"),
dcc.Store(id="titulaires_data"),
dcc.Location(id="marche_url", refresh="callback-nav"),
html.Script(type="application/ld+json", id="marche_jsonld"),
dbc.Container(
className="marche_infos",
children=[
dbc.Row(
dbc.Col(
[
html.H1(id="marche_objet", style={"fontSize": "1.5em"}),
html.P(
"Vous consultez un résumé des données de ce marché public"
),
html.Ul(
[
html.Li(
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
),
html.Li(
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
),
]
),
html.P(
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
),
]
)
),
dbc.Row(
[
dbc.Col(id="marche_infos_1", width=12, md=4),
dbc.Col(id="marche_infos_2", width=12, md=4),
dbc.Col(
width=12,
md=4,
children=[
html.H4("Titulaires"),
html.Ul(id="marche_infos_titulaires"),
],
),
]
),
],
),
]
@callback(
Output("marche_data", "data"),
Output("titulaires_data", "data"),
Input(component_id="marche_url", component_property="pathname"),
)
def get_marche_data(url) -> tuple[dict, list]:
marche_uid = url.split("/")[-1]
# Filtre SQL côté DuckDB, puis Polars pour le post-traitement
dff_marche = query_marches("uid = ?", (marche_uid,))
if dff_marche.height == 0:
return {}, []
lff = dff_marche.lazy()
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
dff_marche_unique = lff.unique("uid").collect(engine="streaming")
dff_marche_unique = format_values(dff_marche_unique)
return dff_marche_unique.to_dicts()[0], dff_titulaires.to_dicts()
@callback(
Output("marche_objet", "children"),
Output("marche_infos_1", "children"),
Output("marche_infos_2", "children"),
Output("marche_infos_titulaires", "children"),
Input("marche_data", "data"),
Input("titulaires_data", "data"),
)
def update_marche_info(marche, titulaires):
def make_parameter(col, bold=True):
column_object = DATA_SCHEMA.get(col)
column_name = column_object.get("title") if column_object else col
if marche and col in marche:
if col == "acheteur_nom":
value = html.A(
href=f"/acheteurs/{marche['acheteur_id']}",
children=marche["acheteur_nom"],
)
elif col == "sourceDataset":
value = html.A(
href=marche["sourceFile"], children=marche["sourceDataset"]
)
column_name = "Source des données"
# Dates
elif col in ["dateNotification", "datePublicationDonnees"]:
value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y")
# Listes
elif (
col
in [
"techniques",
"typesPrix",
"considerationsSociales",
"considerationsEnvironnementales",
]
and col in marche
and "," in marche[col]
):
col_values = marche[col].split(", ")
lines = []
for val in col_values:
lines.append(html.Li(val))
_content = html.Div(
[html.P([column_name, " : "]), html.Ul(children=lines)]
)
return _content
else:
value = marche.get(col)
else:
value = ""
value = html.Strong(value) if bold else value
param_content = html.P([column_name, " : ", value])
return param_content
marche_objet = make_parameter("objet", bold=False)
marche_infos = [
make_parameter("id"),
make_parameter("dateNotification"), # date
make_parameter("nature"),
make_parameter("acheteur_nom"), # lien
make_parameter("montant"),
make_parameter("codeCPV"),
make_parameter("procedure"),
make_parameter("techniques"), # list
make_parameter("dureeMois"),
make_parameter("dureeRestanteMois"),
make_parameter("offresRecues"),
make_parameter("datePublicationDonnees"), # date
make_parameter("formePrix"),
make_parameter("typesPrix"), # list
make_parameter("attributionAvance"),
make_parameter("tauxAvance"),
make_parameter("marcheInnovant"), # label
make_parameter("modalitesExecution"),
make_parameter("considerationsSociales"), # list
make_parameter("considerationsEnvironnementales"), # list
make_parameter("ccag"),
make_parameter("sousTraitanceDeclaree"),
make_parameter("typeGroupementOperateurs"),
make_parameter("origineFrance"),
make_parameter("origineUE"),
make_parameter("idAccordCadre"),
make_parameter("sourceDataset"), # lien
]
half = round(len(marche_infos) / 2)
# pas inclus pour l'instant : lieu d'exécution, modifications
titulaires_lines = []
for titulaire in titulaires:
if titulaire["titulaire_typeIdentifiant"] == "SIRET":
categorie = titulaire.get("titulaire_categorie", "")
if titulaire.get("titulaire_distance"):
distance = str(titulaire.get("titulaire_distance")) + " km"
else:
distance = ""
content = html.Li(
[
html.A(
href=f"/titulaires/{titulaire['titulaire_id']}",
children=titulaire["titulaire_nom"],
),
f" ({categorie}, {distance})",
]
)
else:
content = html.Li(titulaire["titulaire_nom"])
titulaires_lines.append(content)
return marche_objet, marche_infos[:half], marche_infos[half:], titulaires_lines
@callback(
Output(component_id="marche_jsonld", component_property="children"),
Input("marche_data", "data"),
Input("titulaires_data", "data"),
)
def get_marche_jsonld(marche, titulaires) -> str:
acheteur_id = marche.get("acheteur_id")
type_order = (
"Service" if marche.get("categorie") in ["Services", "Travaux"] else "Product"
)
result = []
for titulaire in titulaires:
jsonld = {
"@context": "https://schema.org",
"@type": "Order",
"@id": f"https://decp.info/marches/{marche.get('uid')}",
"name": f"{marche.get('nature')} conclu par {marche.get('acheteur_nom')} le {marche.get('dateNotification')}",
"description": marche.get("objet"),
"orderNumber": marche.get("uid"),
"orderDate": marche.get("dateNotification"),
"price": unformat_montant(marche.get("montant")),
"priceCurrency": "EUR",
"customer": make_org_jsonld(
acheteur_id, org_name=marche.get("acheteur_nom"), org_type="acheteur"
),
"seller": make_org_jsonld(
titulaire.get("titulaire_id"),
org_name=titulaire.get("titulaire_nom"),
org_type="titulaire",
type_org_id=titulaire.get("titulaire_typeIdentifiant", "SIRET"),
),
"orderedItem": {
"@type": type_order,
"name": marche.get("objet"),
"category": {
"@type": "CategoryCode",
"propertyID": "cpv",
"codeValue": marche.get("codeCPV"),
# "description": "Description du code CPV"
},
# "serviceType": "Description du code CPV"
},
}
result.append(jsonld)
return json.dumps(result, indent=2)
+954
View File
@@ -0,0 +1,954 @@
import urllib.parse
from datetime import datetime
import dash_bootstrap_components as dbc
import polars as pl
from dash import (
ALL,
Input,
Output,
State,
callback,
ctx,
dcc,
html,
no_update,
register_page,
)
from src.db import schema
from src.figures import (
DataTable,
get_barchart_sources,
get_dashboard_summary_table,
get_distance_histogram,
get_duplicate_matrix,
get_geographic_maps,
get_top_org_table,
make_card,
make_column_picker,
make_donut,
)
from src.utils import logger
from src.utils.cache import cache
from src.utils.data import (
DEPARTEMENTS,
DF_ACHETEURS,
DF_TITULAIRES,
prepare_dashboard_data,
)
from src.utils.frontend import get_enum_values_as_dict
from src.utils.seo import META_CONTENT
from src.utils.table import COLUMNS, get_default_hidden_columns, prepare_table_data
NAME = "Observatoire"
register_page(
__name__,
path="/observatoire",
title="Observatoire | decp.info",
name=NAME,
description="Visualisez l'état de la publication des données essentielles des marchés publics en France.",
image_url=META_CONTENT["image_url"],
order=3,
)
OPTIONS_YEARS = []
for year in reversed(range(2017, datetime.now().year + 1)):
option_year = {
"label": str(year),
"value": year,
}
OPTIONS_YEARS.append(option_year)
OPTIONS_DEPARTEMENTS = []
for code in DEPARTEMENTS.keys():
departement = {
"label": f"{DEPARTEMENTS[code]['departement']} ({code})",
"value": code,
}
OPTIONS_DEPARTEMENTS.append(departement)
OBSERVATOIRE_COLUMNS = [
col
for col in schema.names()
if col.startswith("acheteur")
or col.startswith("titulaire")
or col
in [
"uid",
"dateNotification",
"montant",
"considerationsSociales",
"considerationsEnvironnementales",
"marcheInnovant",
"sousTraitanceDeclaree",
"techniques",
"sourceDataset",
"type",
"codeCPV",
]
]
layout = [
dcc.Location(id="dashboard_url", refresh="callback-nav"),
dcc.Store(id="observatoire-filters", storage_type="local"),
dcc.Store(id="observatoire-hidden-columns", storage_type="local"),
dcc.Store(
id="filter-cleanup-trigger-observatoire-preview"
), # utilisé juste pour ne pas avoir à adapter les données retournées de prepare_table data
dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Montants")),
dbc.ModalBody(
[
dcc.Markdown(
"""
Les données saisies et publiées par les acheteurs comportent de nombreux montants farfelus qui sabotent les statistiques, au lieu de montants estimés avec rigueur. On parle de montants atteignant parfois les millions de milliards. Certains réutilisateurs des données mettent de côté ces marchés ou bien modifient les montants selon des règles fatalement arbitraires. J'ai fait le choix de ne quasiment pas modifier les données* afin de visibiliser le problème.
Alors, on fait comment ?
\\* Les montants composés de plus de 11 chiffres, sans les décimales, [sont ramenés](https://github.com/ColinMaudry/decp-processing/blob/main/src/tasks/clean.py#L63-L71) à 12 311 111 111, un nombre qui reste très élevé et qui est facilement reconnaissable.
"""
),
]
),
dbc.ModalFooter(
dbc.Button("Fermer", id="montant-modal-close", className="ms-auto")
),
],
id="montant-modal",
is_open=False,
),
html.Div(
className="container-fluid",
children=[
html.H2(children=[NAME], id="page_title"),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-statistques",
type="default",
children=[
dbc.Row(
[
dbc.Col(
xl=3,
lg=4,
id="filters",
children=[
html.H5("Période d'attribution"),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_year",
options=OPTIONS_YEARS,
placeholder="12 derniers mois",
persistence=True,
persistence_type="local",
),
),
),
html.H5("Acheteur"),
dbc.Row(
dbc.Col(
dcc.Input(
id="dashboard_acheteur_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_acheteur_categorie",
options=get_enum_values_as_dict(
"acheteur_categorie"
),
placeholder="Catégorie",
persistence=True,
persistence_type="local",
)
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_acheteur_departement_code",
searchable=True,
multi=True,
placeholder="Département",
options=OPTIONS_DEPARTEMENTS,
persistence=True,
persistence_type="local",
),
),
),
html.H5("Titulaire"),
dbc.Row(
dbc.Col(
dcc.Input(
id="dashboard_titulaire_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_titulaire_categorie",
placeholder="Catégorie",
options=get_enum_values_as_dict(
"titulaire_categorie"
),
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_titulaire_departement_code",
searchable=True,
multi=True,
placeholder="Département",
options=OPTIONS_DEPARTEMENTS,
persistence=True,
persistence_type="local",
),
),
),
html.H5("Marché"),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_type",
placeholder="Type",
options=get_enum_values_as_dict("type"),
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Input(
id="dashboard_marche_objet",
placeholder="Objet",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
[
dbc.Col(
dcc.Input(
id="dashboard_marche_code_cpv",
placeholder="Code CPV (début)",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
lg=8,
),
dbc.Col(
html.A(
"liste des codes",
href="https://cpvcodes.eu/fr",
target="_blank",
),
lg=4,
),
]
),
dbc.Row(
[
dbc.Col(
dcc.Input(
id="dashboard_montant_min",
placeholder="Montant min.",
type="number",
min=0,
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
width=6,
),
dbc.Col(
dcc.Input(
id="dashboard_montant_max",
placeholder="Montant max.",
type="number",
min=0,
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
width=6,
),
]
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_techniques",
placeholder="Techniques d'achat",
options=get_enum_values_as_dict(
"techniques"
),
multi=True,
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
[
dbc.Col("Sous-traitance :", lg=5),
dbc.Col(
dbc.RadioItems(
id="dashboard_marche_sous_traitance_declaree",
options=[
{
"label": "Tous",
"value": "all",
},
{
"label": "Oui",
"value": "oui",
},
{
"label": "Non",
"value": "non",
},
],
value="all",
inline=True,
persistence=True,
persistence_type="local",
),
lg=7,
),
]
),
dbc.Row(
[
dbc.Col("Marché innovant :", lg=5),
dbc.Col(
dbc.RadioItems(
id="dashboard_marche_innovant",
options=[
{
"label": "Tous",
"value": "all",
},
{
"label": "Oui",
"value": "oui",
},
{
"label": "Non",
"value": "non",
},
],
value="all",
inline=True,
persistence=True,
persistence_type="local",
),
lg=7,
),
]
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_considerations_sociales",
placeholder="Considérations sociales",
options=get_enum_values_as_dict(
"considerationsSociales"
),
multi=True,
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_considerations_environnementales",
placeholder="Considérations environnementales",
multi=True,
options=get_enum_values_as_dict(
"considerationsEnvironnementales"
),
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
[
dbc.Col(
[
dcc.Download(
id="download-observatoire"
),
dbc.Button(
"Voir les données",
id="btn-observatoire-preview",
className="btn btn-primary mt-2",
color="primary",
outline=True,
),
dcc.Input(
id="observatoire-share-url",
readOnly=True,
style={"display": "none"},
),
],
lg=12,
xl=6,
),
dbc.Col(
id="observatoire-copy-container",
lg=12,
xl=6,
),
]
),
],
),
dbc.Col(
width=12,
lg=8,
xl=9,
id="cards",
children=[],
),
]
)
],
),
],
),
dbc.Offcanvas(
id="observatoire-preview",
title="Prévisualisation des données",
placement="bottom",
is_open=False,
scrollable=True,
style={"height": "75vh"},
children=[
# Header row: title + "Colonnes affichées" button
dbc.Row(
[
dbc.Col(
html.Div(
className="table-menu",
children=[
dbc.Button(
"Choisir les colonnes",
id="observatoire-preview-columns-open",
className="btn btn-primary",
),
html.P(id="nb_rows_observatoire"),
dbc.Button(
"Télécharger au format Excel",
id="btn-download-observatoire",
disabled=True,
className="btn btn-primary",
outline=True,
),
],
),
width="auto",
),
],
className="mb-2 align-items-center",
),
# Column picker modal
dbc.Modal(
[
dbc.ModalHeader(
dbc.ModalTitle("Colonnes affichées dans la prévisualisation")
),
dbc.ModalBody(
id="observatoire-preview-columns-body",
children=make_column_picker("observatoire_preview"),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="observatoire-preview-columns-close",
className="ms-auto",
n_clicks=0,
)
),
],
id="observatoire-preview-columns-modal",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="xl",
),
# DataTable
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-statistques",
type="default",
children=[
html.Div(
className="marches_table",
children=DataTable(
dtid="observatoire-preview-table",
page_size=5,
page_action="custom",
sort_action="custom",
filter_action="custom",
hidden_columns=[],
columns=[
{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS
],
),
)
],
),
],
),
]
FILTER_PARAMS = [
# (component_id, url_key, is_multi, default_value)
("dashboard_year", "annee", False, None),
("dashboard_acheteur_id", "acheteur_id", False, None),
("dashboard_acheteur_categorie", "acheteur_cat", False, None),
("dashboard_acheteur_departement_code", "acheteur_dept", True, None),
("dashboard_titulaire_id", "titulaire_id", False, None),
("dashboard_titulaire_categorie", "titulaire_cat", False, None),
("dashboard_titulaire_departement_code", "titulaire_dept", True, None),
("dashboard_marche_type", "type", False, None),
("dashboard_marche_objet", "objet", False, None),
("dashboard_marche_code_cpv", "cpv", False, None),
("dashboard_montant_min", "montant_min", False, None),
("dashboard_montant_max", "montant_max", False, None),
("dashboard_marche_techniques", "techniques", True, None),
("dashboard_marche_innovant", "innovant", False, "all"),
("dashboard_marche_sous_traitance_declaree", "sous_traitance", False, "all"),
("dashboard_marche_considerations_sociales", "social", True, None),
("dashboard_marche_considerations_environnementales", "env", True, None),
]
@callback(
*[Output(fp[0], "value") for fp in FILTER_PARAMS],
Input("dashboard_url", "search"),
Input("dashboard_url", "pathname"),
State("observatoire-filters", "data"),
)
def restore_filters(search, _pathname, stored_filters):
if search:
params = urllib.parse.parse_qs(search.lstrip("?"))
known_keys = {fp[1] for fp in FILTER_PARAMS}
if any(k in params for k in known_keys):
values = []
for _comp_id, url_key, is_multi, default in FILTER_PARAMS:
if url_key in params:
if is_multi:
values.append(params[url_key])
else:
raw = params[url_key][0]
if url_key in ("montant_min", "montant_max"):
try:
raw = float(raw)
except (ValueError, TypeError):
raw = None
values.append(raw)
else:
values.append(default)
return tuple(values)
return (no_update,) * 17
@callback(
Output("observatoire-share-url", "value"),
Output("observatoire-copy-container", "children"),
*[Input(fp[0], "value") for fp in FILTER_PARAMS],
Input("dashboard_url", "href"),
)
def sync_observatoire_share_url(*args):
# Last arg is href (State), rest are filter values
filter_values = args[:-1]
href = args[-1]
if not href:
return no_update, no_update
base_url = href.split("?")[0]
params = []
for (_, url_key, is_multi, default), value in zip(FILTER_PARAMS, filter_values):
if value is None or value == default or value == [] or value == "":
continue
if is_multi and isinstance(value, list):
for v in value:
params.append((url_key, v))
else:
params.append((url_key, value))
query_string = urllib.parse.urlencode(params)
full_url = f"{base_url}?{query_string}" if query_string else base_url
if params:
copy_button = dcc.Clipboard(
id="btn-copy-observatoire-url",
target_id="observatoire-share-url",
title="Copier l'URL de cette vue",
style={
"display": "inline-block",
"fontSize": 20,
"verticalAlign": "top",
"cursor": "pointer",
},
className="fa fa-link",
children=[
dbc.Button(
"Partager cette vue",
id="btn-copy-observatoire",
className="btn btn-primary mt-2",
title="Copier l'adresse de cette vue filtrée pour la partager.",
)
],
)
else:
copy_button = html.Div()
return full_url, copy_button
@callback(
Output("observatoire-copy-container", "children", allow_duplicate=True),
Input("btn-copy-observatoire", "n_clicks", allow_optional=True),
prevent_initial_call=True,
)
def show_confirmation(n_clicks):
if n_clicks:
return html.Span(
"Adresse de la vue copiée",
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
)
return no_update
def _normalize_filter_params(filter_params: dict) -> tuple:
"""Produce a deterministic, hashable key for caching."""
return tuple(
sorted(
(k, tuple(v) if isinstance(v, list) else v)
for k, v in filter_params.items()
)
)
@cache.memoize()
def _compute_dashboard_children(filter_params_normalized: tuple):
logger.debug("Cache miss — computing dashboard")
filter_params = {
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
}
dff = prepare_dashboard_data(**filter_params)
lff = dff.lazy()
df_per_uid = (
dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first())
)
nb_marches = df_per_uid.height
cards = []
card_summary_table = get_dashboard_summary_table(dff, df_per_uid, nb_marches)
cards.append(make_card(title="Résumé", paragraphs=card_summary_table))
donut_acheteur_categorie, nb_acheteur_categories = make_donut(
lff,
"acheteur_categorie",
nulls="Autres",
per_uid=True,
potentially_many_names=True,
)
cards.append(
make_card(
title="Catégorie d'acheteur",
subtitle="en nombre de marchés attribués",
fig=donut_acheteur_categorie,
lg=12 if nb_acheteur_categories > 4 else 6,
xl=8 if nb_acheteur_categories > 4 else 4,
)
)
donut_titulaire_categorie = make_donut(
lff, "titulaire_categorie", per_uid=False, nulls="?"
)
cards.append(
make_card(
title="Catégorie d'entreprise",
subtitle="en nombre de titulaires",
fig=donut_titulaire_categorie,
)
)
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
cards.append(
make_card(
title="Type d'achat",
subtitle="en nombre de marchés attribués",
fig=donut_marche_type,
)
)
distance_histogram = get_distance_histogram(lff)
cards.append(
make_card(
title="Distance acheteurtitulaire",
subtitle="en nombre de marchés, échelle logarithmique",
fig=distance_histogram,
)
)
top_acheteurs = get_top_org_table(
lff, org_type="acheteur", filters=False, extra_columns=[]
)
cards.append(make_card(title="Top acheteurs", fig=top_acheteurs, lg=12, xl=8))
top_titulaires = get_top_org_table(
lff, org_type="titulaire", filters=False, extra_columns=[]
)
cards.append(make_card(title="Top titulaires", fig=top_titulaires, lg=12, xl=8))
geographic_maps: list[dbc.Col] | None = get_geographic_maps(dff)
other_cards = []
sources_barchart = get_barchart_sources(lff, type_date="dateNotification")
other_cards.append(
make_card(
title="Sources de données",
subtitle="Nombre de marchés attribués par mois de notification et source de données",
fig=sources_barchart,
lg=12,
xl=8,
)
)
duplicate_matrix = get_duplicate_matrix()
other_cards.append(
make_card(
title="Matrice de doublons entre sources de données",
subtitle="Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source.",
fig=duplicate_matrix,
lg=12,
xl=8,
)
)
return cards + geographic_maps + other_cards
@callback(
Output("cards", "children"),
Output("observatoire-filters", "data"),
*[Input(fp[0], "value") for fp in FILTER_PARAMS],
)
def update_dashboard_cards(*filter_values):
filter_params = {}
for (input_id, _url_key, _is_multi, _default), value in zip(
FILTER_PARAMS, filter_values
):
filter_params[input_id] = value
filter_params_normalized = _normalize_filter_params(filter_params)
children = _compute_dashboard_children(filter_params_normalized)
return dbc.Row(children=children), filter_params
@callback(
Output("download-observatoire", "data"),
Input("btn-download-observatoire", "n_clicks"),
State("observatoire-filters", "data"),
State("observatoire-hidden-columns", "data"),
prevent_initial_call=True,
)
def download_observatoire(_n_clicks, filter_params, hidden_columns):
dff = prepare_dashboard_data(**(filter_params or {}))
if hidden_columns:
dff = dff.drop(hidden_columns)
def to_bytes(buffer):
dff.write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
@callback(
Output("montant-modal", "is_open"),
Input({"type": "modal-trigger", "index": ALL}, "n_clicks"),
Input("montant-modal-close", "n_clicks"),
prevent_initial_call=True,
)
def toggle_montant_modal(n_triggers, _close):
return isinstance(ctx.triggered_id, dict) and any(n_triggers)
@callback(
Output("page_title", "children"),
Input("dashboard_acheteur_id", "value"),
Input("dashboard_titulaire_id", "value"),
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
if acheteur_id and len(acheteur_id) == 14:
if nom := lookup_nom(DF_ACHETEURS, "acheteur_id", "acheteur_nom", acheteur_id):
return [
NAME,
html.Small(nom, className="text-muted d-block fw-normal fs-5"),
]
elif titulaire_id and len(titulaire_id) == 14:
if nom := lookup_nom(
DF_TITULAIRES, "titulaire_id", "titulaire_nom", titulaire_id
):
return [
NAME,
html.Small(nom, className="text-muted d-block fw-normal fs-5"),
]
return NAME
@callback(
Output("observatoire-preview", "is_open"),
Input("btn-observatoire-preview", "n_clicks"),
State("observatoire-preview", "is_open"),
prevent_initial_call=True,
)
def toggle_observatoire_preview(n_clicks, is_open):
return not is_open
@callback(
Output("observatoire-preview-table", "data"),
Output("observatoire-preview-table", "columns"),
Output("observatoire-preview-table", "tooltip_header"),
Output("observatoire-preview-table", "data_timestamp"),
Output("nb_rows_observatoire", "children"),
Output("btn-download-observatoire", "disabled"),
Output("btn-download-observatoire", "children"),
Output("btn-download-observatoire", "title"),
Output("filter-cleanup-trigger-observatoire-preview", "data", allow_duplicate=True),
Input("observatoire-preview", "is_open"),
Input("observatoire-preview-table", "filter_query"),
Input("observatoire-preview-table", "page_current"),
Input("observatoire-preview-table", "page_size"),
Input("observatoire-preview-table", "sort_by"),
State("observatoire-preview-table", "data_timestamp"),
State("observatoire-filters", "data"),
prevent_initial_call=True,
)
def populate_preview_table(
is_open,
filter_query,
page_current,
page_size,
sort_by,
data_timestamp,
filter_params,
):
if not is_open:
return (no_update,) * 9
dff = prepare_dashboard_data(**(filter_params or {}))
return prepare_table_data(
dff.lazy(),
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"observatoire-preview",
)
@callback(
Output("observatoire-hidden-columns", "data", allow_duplicate=True),
Input("observatoire_preview_column_list", "selected_rows"),
prevent_initial_call=True,
)
def update_hidden_columns_from_checkboxes(selected_columns):
if selected_columns:
selected_columns = [COLUMNS[i] for i in selected_columns]
hidden_columns = [col for col in COLUMNS if col not in selected_columns]
return hidden_columns
else:
return []
@callback(
Output("observatoire-preview-table", "hidden_columns"),
Input(
"observatoire-hidden-columns",
"data",
),
)
def store_hidden_columns(hidden_columns):
return hidden_columns
@callback(
Output("observatoire_preview_column_list", "selected_rows"),
Input("observatoire-preview-table", "hidden_columns"),
State(
"observatoire_preview_column_list", "selected_rows"
), # pour éviter la boucle infinie
)
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
hidden_cols = hidden_cols or get_default_hidden_columns("tableau")
# Show all columns that are NOT hidden
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
return visible_cols
@callback(
Output("observatoire-preview-columns-modal", "is_open"),
Input("observatoire-preview-columns-open", "n_clicks"),
Input("observatoire-preview-columns-close", "n_clicks"),
State("observatoire-preview-columns-modal", "is_open"),
)
def toggle_tableau_columns(click_open, click_close, is_open):
if click_open or click_close:
return not is_open
return is_open
+132
View File
@@ -0,0 +1,132 @@
import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, dcc, html, register_page
from src.figures import DataTable
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES
from src.utils.search import search_org
from src.utils.seo import META_CONTENT
from src.utils.table import setup_table_columns
NAME = "Recherche"
register_page(
__name__,
path="/",
title="Recherche de marchés publics | decp.info",
name=NAME,
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.",
image_url=META_CONTENT["image_url"],
order=0,
)
layout = html.Div(
className="container",
children=[
html.Div(
className="tagline",
children=html.P("Recherchez un acheteur ou un titulaire de marché public"),
),
html.Div(
style={
"display": "flex",
"justifyContent": "center",
"marginTop": "30px",
"marginBottom": "30px",
},
children=[
dcc.Input(
id="search",
type="text",
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
autoFocus=True,
style={
"margin": "0",
"width": "500px",
"border": "1px solid #ccc",
"borderRight": "none",
"borderRadius": "3px 0 0 3px",
"padding": "5px 10px",
"outline": "none",
"height": "34px",
},
),
html.Button(
"=>",
id="search-button",
className="btn btn-primary",
style={
"border": "1px solid #ccc",
"borderRadius": "0 3px 3px 0",
"marginLeft": "0",
"height": "auto", # Ensure it matches input height if necessary, often relying on padding/line-height
},
),
],
),
html.P(
[
"...ou bien filtrez les marchés publics dans la vue ",
dcc.Link("Tableau", href="/tableau"),
],
style={"textAlign": "center"},
id="mention_tableau",
),
# html.Div(
# className="search_options",
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
# ),
dbc.Row(id="search_results"),
],
)
@callback(
Output("search_results", "children"),
Output("mention_tableau", "style"),
Input("search", "n_submit"),
Input("search-button", "n_clicks"),
State("search", "value"),
prevent_initial_call=True,
)
def update_search_results(n_submit, n_clicks, query):
if query and len(query) >= 1:
cols = []
for org_type in ["acheteur", "titulaire"]:
if org_type == "acheteur":
dff = DF_ACHETEURS
elif org_type == "titulaire":
dff = DF_TITULAIRES
else:
raise ValueError(f"{org_type} is not supported")
# Search acheteurs and titulaires using the same function
results = search_org(dff, query, org_type=org_type)
count = results.height
# Format output
columns, tooltip = setup_table_columns(results, hideable=False)
col = (
dbc.Col(
children=[
html.H3(f"{org_type.title()}s : {count}"),
DataTable(
dtid=f"results_{org_type}_datatable",
columns=columns,
data=results.to_dicts(),
page_size=10,
sort_action="none",
filter_action="none",
),
],
md=6,
)
if count > 0
else html.P(f"Aucun {org_type} trouvé.")
)
cols.append(col)
style = {"textAlign": "center", "display": "none"}
return cols, style
return html.P(""), {"textAlign": "center"}
+538
View File
@@ -0,0 +1,538 @@
import json
import os
import urllib.parse
import uuid
from datetime import datetime
import dash_bootstrap_components as dbc
import polars as pl
from dash import (
ClientsideFunction,
Input,
Output,
State,
callback,
clientside_callback,
dcc,
html,
no_update,
register_page,
)
from src.db import query_marches, schema
from src.figures import DataTable, make_column_picker
from src.utils import get_last_modified, logger
from src.utils.seo import META_CONTENT
from src.utils.table import (
COLUMNS,
filter_table_data,
get_default_hidden_columns,
invert_columns,
prepare_table_data,
sort_table_data,
)
from src.utils.tracking import track_search
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()
NAME = "Tableau"
register_page(
__name__,
path="/tableau",
title="Tableau des marchés publics | decp.info",
name=NAME,
description="Consultez, filtrez et exportez les données essentielles de la commande publique sous forme de tableau.",
image_url=META_CONTENT["image_url"],
order=1,
)
DATATABLE = html.Div(
className="marches_table",
children=DataTable(
dtid="tableau_datatable",
persisted_props=["filter_query", "sort_by"],
persistence_type="local",
persistence=True,
page_size=20,
page_action="custom",
filter_action="custom",
sort_action="custom",
hidden_columns=[],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
layout = [
dcc.Location(id="tableau_url", refresh=False),
dcc.Store(id="filter-cleanup-trigger-tableau"),
dcc.Store(id="tableau-hidden-columns", storage_type="local"),
dcc.Store(id="tableau-table"),
html.Script(
type="application/ld+json",
id="dataset_jsonld",
children=[
json.dumps(
{
"@context": "https://schema.org/",
"@type": "Dataset",
"name": "Données essentielles des marchés publics français (DECP)",
"description": "Données de marchés publics exhaustives décrivant les marchés publics attribués en France depuis 2018.",
"url": "https://decp.info",
"sameAs": "https://www.data.gouv.fr/datasets/608c055b35eb4e6ee20eb325",
"keywords": [
"marchés publics",
"commande publique",
"decp",
"public procurement",
],
"license": "https://www.etalab.gouv.fr/licence-ouverte-open-licence",
"isAccessibleForFree": True,
"creator": {
"@type": "Organization",
"url": "https://colmo.tech",
"name": "Colmo",
"sameAs": "https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "Support et contact commercial",
"email": "colin@colmo.tech",
},
},
"includedInDataCatalog": {
"@type": "DataCatalog",
"name": "data.gouv.fr",
},
"distribution": [
{
"@type": "DataDownload",
"encodingFormat": "CSV",
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52",
},
{
"@type": "DataDownload",
"encodingFormat": "Parquet",
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432",
},
],
"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}",
"spatialCoverage": {
"@type": "Place",
"address": {"countryCode": "FR"},
},
},
indent=2,
)
],
),
dcc.Markdown(
f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin (exemples : [marchés de voirie < 40 k€ en 2025](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset), [marchés > 500 k€ avec clause sociale attribués à des PME à plus de 100 km dans le Var](/tableau?filtres=%7Btitulaire_categorie%7D+icontains+PME+%26%26+%7Btitulaire_distance%7D+i%3E+100+%26%26+%7Bmontant%7D+i%3E+500000+%26%26+%7Bacheteur_departement_code%7D+icontains+83+%26%26+%7BconsiderationsSociales%7D+icontains+clause&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2CconsiderationsSociales%2Ctitulaire_distance%2Cacheteur_departement_code%2Ctitulaire_categorie%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {len(schema.names())} en cliquant sur le bouton **Choisir les colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.",
style={"maxWidth": "1000px"},
),
html.Div(
[],
id="header",
),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
# Modal du mode d'emploi
dbc.Button("Mode d'emploi", id="tableau_help_open"),
dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Mode d'emploi")),
dbc.ModalBody(
dcc.Markdown(
dangerously_allow_html=True,
children=f"""
##### Définition des colonnes
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
##### Vos réglages sont persistents
Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée.
##### Appliquer des filtres
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est pas sensible à la casse (majuscules/minuscules) et est sensbible à l'accentuation.
- `rennes` => le texte contient "rennes"
- `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole"
- `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte
- `metropole+rennes` => le texte contient "metropole rennes", collé et dans cet ordre
- `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri"
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
- soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
- Champs date (Date de notification, ...) :
- `< 2024-01-31` pour "avant le 31 janvier 2024"
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022"
Vous pouvez filtrer plusieurs colonnes à la fois.
##### Trier les données
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre :
1. tri croissant
2. tri décroissant
3. pas de tri
##### Afficher plus de colonnes
Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {len(schema.names())} colonnes, ce serait dommage de vous limiter !
Pour afficher plus de colonnes, cliquez sur le bouton **Choisir les colonnes** et cochez les colonnes pour les afficher.
##### Partager une vue
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur **Partager** pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres.
Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard.
##### Télécharger le résultat
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**.
##### Liens
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée
(informations, marchés attribués/remportés, etc.)
""",
),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="tableau_help_close",
className="ms-auto",
n_clicks=0,
)
),
],
id="tableau_help",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="lg",
),
# Bouton modal des colonnes affichées
dbc.Button(
"Choisir les colonnes",
id="tableau_columns_open",
className="column_list",
title="Choisir les colonnes à afficher et masquer",
),
html.P("lignes", id="nb_rows"),
html.Div(id="copy-container"),
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
dbc.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-data",
disabled=True,
),
dcc.Download(id="download-data"),
dcc.Store(id="filtered_data", storage_type="memory"),
html.P("Données mises à jour le " + str(update_date)),
dbc.Button(
"Remettre à zéro",
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
id="btn-tableau-reset",
),
],
className="table-menu",
),
dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")),
dbc.ModalBody(
id="tableau_columns_body",
children=make_column_picker("tableau"),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="tableau_columns_close",
className="ms-auto",
n_clicks=0,
)
),
],
id="tableau_columns",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="xl",
),
DATATABLE,
],
),
]
@callback(
Output("tableau_datatable", "data"),
Output("tableau_datatable", "columns"),
Output("tableau_datatable", "tooltip_header"),
Output("tableau_datatable", "data_timestamp"),
Output("nb_rows", "children"),
Output("btn-download-data", "disabled"),
Output("btn-download-data", "children"),
Output("btn-download-data", "title"),
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
Input("tableau_url", "href"),
Input("tableau_datatable", "page_current"),
Input("tableau_datatable", "page_size"),
Input("tableau_datatable", "filter_query"),
Input("tableau_datatable", "sort_by"),
State("tableau_datatable", "data_timestamp"),
prevent_initial_call=True,
)
def update_table(href, page_current, page_size, filter_query, sort_by, data_timestamp):
# if ctx.triggered_id != "url":
# search_params = None
# else:
# search_params = urllib.parse.parse_qs(search_params.lstrip("?"))
return prepare_table_data(
None, data_timestamp, filter_query, page_current, page_size, sort_by, "tableau"
)
@callback(
Output("download-data", "data"),
Input("btn-download-data", "n_clicks"),
State("tableau_datatable", "filter_query"),
State("tableau_datatable", "sort_by"),
State("tableau_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list | None = None):
lff: pl.LazyFrame = query_marches().lazy()
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
track_search(filter_query, "tab download")
lff = filter_table_data(lff, filter_query)
if sort_by and len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
@callback(
Output("tableau_datatable", "filter_query"),
Output("tableau_datatable", "sort_by"),
Output("tableau-hidden-columns", "data"),
Output("tableau_url", "search"),
Output("filter-cleanup-trigger-tableau", "data"),
Input("tableau_url", "search"),
State("tableau_datatable", "filter_query"),
State("tableau_datatable", "sort_by"),
)
def restore_view_from_url(search, stored_filters, stored_sort):
if not search and not stored_filters:
return no_update, no_update, no_update, no_update, no_update
params = urllib.parse.parse_qs(search.lstrip("?")) if search else {}
logger.debug("params " + json.dumps(params, indent=2))
filter_query = no_update
sort_by = no_update
hidden_columns = no_update
trigger_cleanup = no_update
if "filtres" in params:
filter_query = params["filtres"][0]
trigger_cleanup = str(uuid.uuid4())
elif stored_filters:
filter_query = stored_filters
trigger_cleanup = str(uuid.uuid4())
if "tris" in params:
try:
sort_by = json.loads(params["tris"][0])
except json.JSONDecodeError:
pass
elif stored_sort:
sort_by = stored_sort
if "colonnes" in params:
table_columns = params["colonnes"][0].split(",")
verified_columns = [
column for column in table_columns if column in schema.names()
]
hidden_columns = invert_columns(verified_columns)
return filter_query, sort_by, hidden_columns, "", trigger_cleanup
# Pour nettoyer les icontains et i< des filtres
# voir aussi src/assets/dash_clientside.js
clientside_callback(
ClientsideFunction(
namespace="clientside",
function_name="clean_filters",
),
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
Input("filter-cleanup-trigger-tableau", "data"),
prevent_initial_call=True,
)
@callback(
Output("share-url", "value"),
Output("copy-container", "children"),
Input("tableau_datatable", "filter_query"),
Input("tableau_datatable", "sort_by"),
Input("tableau_datatable", "hidden_columns"),
State("tableau_url", "href"),
prevent_initial_call=True,
)
def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
if not href:
return no_update, no_update
# Extract base URL (remove existing query params)
base_url = href.split("?")[0]
params = {}
if filter_query:
params["filtres"] = filter_query
if sort_by:
params["tris"] = json.dumps(sort_by)
if hidden_columns:
table_columns = invert_columns(hidden_columns)
table_columns = ",".join(table_columns)
params["colonnes"] = table_columns
query_string = urllib.parse.urlencode(params)
full_url = f"{base_url}?{query_string}" if query_string else base_url
copy_button = dcc.Clipboard(
id="btn-copy-url",
target_id="share-url",
title="Copier l'URL de cette vue",
style={
"display": "inline-block",
"fontSize": 20,
"verticalAlign": "top",
"cursor": "pointer",
},
className="fa fa-link",
children=[
dbc.Button(
"Partager la vue",
className="btn btn-primary",
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
)
],
)
return full_url, copy_button
@callback(
Output("copy-container", "children", allow_duplicate=True),
Input("btn-copy-url", "n_clicks", allow_optional=True),
prevent_initial_call=True,
)
def show_confirmation(n_clicks):
if n_clicks:
return html.Span(
"Adresse de la vue copiée",
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
)
return no_update
@callback(
Output("tableau_help", "is_open"),
[Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")],
[State("tableau_help", "is_open")],
)
def toggle_tableau_help(click_open, click_close, is_open):
if click_open or click_close:
return not is_open
return is_open
@callback(
Output("tableau-hidden-columns", "data", allow_duplicate=True),
Input("tableau_column_list", "selected_rows"),
prevent_initial_call=True,
)
def update_hidden_columns_from_checkboxes(selected_columns):
if selected_columns:
selected_columns = [COLUMNS[i] for i in selected_columns]
hidden_columns = [col for col in COLUMNS if col not in selected_columns]
return hidden_columns
else:
return []
@callback(
Output("tableau_datatable", "hidden_columns"),
Input(
"tableau-hidden-columns",
"data",
),
)
def store_hidden_columns(hidden_columns):
if hidden_columns is None:
hidden_columns = get_default_hidden_columns("tableau")
return hidden_columns
@callback(
Output("tableau_column_list", "selected_rows"),
Input("tableau_datatable", "hidden_columns"),
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
)
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
hidden_cols = hidden_cols or get_default_hidden_columns("tableau")
# Show all columns that are NOT hidden
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
return visible_cols
@callback(
Output("tableau_columns", "is_open"),
Input("tableau_columns_open", "n_clicks"),
Input("tableau_columns_close", "n_clicks"),
State("tableau_columns", "is_open"),
)
def toggle_tableau_columns(click_open, click_close, is_open):
if click_open or click_close:
return not is_open
return is_open
@callback(
Output("tableau_datatable", "filter_query", allow_duplicate=True),
Output("tableau_datatable", "sort_by", allow_duplicate=True),
Input("btn-tableau-reset", "n_clicks"),
prevent_initial_call=True,
)
def reset_view(n_clicks):
return "", []
+561
View File
@@ -0,0 +1,561 @@
import datetime
from typing import Any
import dash_bootstrap_components as dbc
import polars as pl
from dash import (
ClientsideFunction,
Input,
Output,
State,
callback,
clientside_callback,
dcc,
html,
register_page,
)
from src.db import query_marches, schema
from src.figures import (
DataTable,
get_distance_histogram,
get_top_org_table,
make_column_picker,
point_on_map,
)
from src.utils.data import DF_TITULAIRES, get_annuaire_data, get_departement_region
from src.utils.frontend import get_button_properties
from src.utils.seo import META_CONTENT
from src.utils.table import (
COLUMNS,
filter_table_data,
format_number,
get_default_hidden_columns,
prepare_table_data,
sort_table_data,
)
from src.utils.tracking import track_search
def get_title(titulaire_id: str = None) -> str:
titulaire_nom = DF_TITULAIRES.filter(pl.col("titulaire_id") == titulaire_id).select(
"titulaire_nom"
)
if titulaire_nom.height > 0:
return f"Marchés publics remportés par {titulaire_nom.item(0, 0)} | decp.info"
return "Marchés publics remportés | decp.info"
register_page(
__name__,
path_template="/titulaires/<titulaire_id>",
title=get_title,
name="Titulaire",
description="Consultez les marchés publics remportés par ce titulaire.",
image_url=META_CONTENT["image_url"],
order=5,
)
DATATABLE = html.Div(
className="marches_table",
children=DataTable(
dtid="titulaire_datatable",
persistence=True,
persistence_type="local",
persisted_props=["filter_query", "sort_by"],
page_action="custom",
filter_action="custom",
sort_action="custom",
page_size=10,
hidden_columns=[],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
layout = [
dcc.Store(id="titulaire_data", storage_type="memory"),
dcc.Store(id="titulaire-hidden-columns", storage_type="local"),
dcc.Store(id="filter-cleanup-trigger-titulaire"),
dcc.Location(id="titulaire_url", refresh="callback-nav"),
html.Div(
children=[
html.Div(
style={"marginBottom": "50px"},
children=[
dbc.Row(
className="mb-2",
children=[
dbc.Col(
html.H2(
children=[
html.Span(id="titulaire_siret"),
" - ",
html.Span(id="titulaire_nom"),
],
),
width=8,
),
dbc.Col(
dcc.Dropdown(
id="titulaire_year",
options=["Toutes les années"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
width=4,
),
],
),
dbc.Row(
className="mb-2",
children=[
dbc.Col(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(
[
"Commune : ",
html.Strong(id="titulaire_commune"),
]
),
html.P(
[
"Département : ",
html.Strong(id="titulaire_departement"),
]
),
html.P(
[
"Région : ",
html.Strong(id="titulaire_region"),
]
),
html.A(
id="titulaire_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
),
],
width=4,
),
dbc.Col(
children=[
html.P(id="titulaire_titre_stats"),
html.P(id="titulaire_marches_remportes"),
html.P(id="titulaire_acheteurs_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-data-titulaire",
className="btn btn-primary",
),
dcc.Download(id="download-data-titulaire"),
],
width=4,
),
dbc.Col(
id="titulaire_map",
width=4,
),
],
),
dbc.Row(
children=[
dbc.Col(
html.Div(
children=[
html.H3("Top acheteurs"),
html.Div(
className="marches_table",
id="top10_acheteurs",
),
],
),
width=8,
),
dbc.Col(id="titulaire-distance-histogram", width=4),
],
),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics remportés"),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
# Bouton modal des colonnes affichées
dbc.Button(
"Colonnes affichées",
id="titulaire_columns_open",
className="column_list",
),
html.P("lignes", id="titulaire_nb_rows"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-filtered-data-titulaire",
disabled=True,
className="btn btn-primary",
),
dcc.Download(id="titulaire-download-filtered-data"),
dbc.Button(
"Remise à zéro",
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
id="btn-titulaire-reset",
className="btn btn-primary",
),
],
className="table-menu",
),
dbc.Modal(
[
dbc.ModalHeader(
dbc.ModalTitle("Choix des colonnes à afficher")
),
dbc.ModalBody(
id="titulaire_columns_body",
children=make_column_picker("titulaire"),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="titulaire_columns_close",
className="ms-auto",
n_clicks=0,
)
),
],
id="titulaire_columns",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="xl",
),
DATATABLE,
],
),
],
),
]
@callback(
Output(component_id="titulaire_siret", component_property="children"),
Output(component_id="titulaire_nom", component_property="children"),
Output(component_id="titulaire_commune", component_property="children"),
Output(component_id="titulaire_map", component_property="children"),
Output(component_id="titulaire_departement", component_property="children"),
Output(component_id="titulaire_region", component_property="children"),
Output(component_id="titulaire_lien_annuaire", component_property="href"),
Input(component_id="titulaire_url", component_property="pathname"),
)
def update_titulaire_infos(url):
titulaire_siret = url.split("/")[-1]
data = get_annuaire_data(titulaire_siret)
data_etablissement = data.get("matching_etablissements") if data else None
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"],
departement_code,
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
)
raison_sociale = data["nom_raison_sociale"]
libelle_commune = data_etablissement["libelle_commune"]
else:
titulaire_map = html.Div()
code_departement, nom_departement, nom_region = "", "", ""
departement = ""
lien_annuaire = ""
raison_sociale = html.Span(
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
)
libelle_commune = ""
return (
titulaire_siret,
raison_sociale,
libelle_commune,
titulaire_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="titulaire_marches_remportes", component_property="children"),
Output(
component_id="titulaire_acheteurs_differents", component_property="children"
),
Input(component_id="titulaire_data", component_property="data"),
)
def update_titulaire_stats(data):
dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
if dff.height == 0:
nb_marches = 0
nb_acheteurs = 0
else:
df_marches = dff.unique("uid")
nb_marches = format_number(df_marches.height)
nb_acheteurs = dff.unique("acheteur_id").height
texte_marches_remportes = [
html.Strong(nb_marches),
" marchés et accord-cadres remportés",
]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
texte_nb_acheteurs = [
html.Strong(format_number(nb_acheteurs)),
" acheteurs (SIRET) différents",
]
return texte_marches_remportes, texte_nb_acheteurs
@callback(
Output(component_id="titulaire_data", component_property="data"),
Output("btn-download-data-titulaire", "disabled"),
Output("btn-download-data-titulaire", "children"),
Output("btn-download-data-titulaire", "title"),
Input(component_id="titulaire_url", component_property="pathname"),
Input(component_id="titulaire_year", component_property="value"),
)
def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
titulaire_siret = url.split("/")[-1]
lff = query_marches(
"titulaire_id = ? AND titulaire_typeIdentifiant = 'SIRET'",
(titulaire_siret,),
).lazy()
if titulaire_year and titulaire_year != "Toutes les années":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
)
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
lff = lff.fill_null("")
dff: pl.DataFrame = lff.collect(engine="streaming")
download_disabled, download_text, download_title = get_button_properties(dff.height)
data = dff.to_dicts()
return data, download_disabled, download_text, download_title
@callback(
Output("titulaire_datatable", "data"),
Output("titulaire_datatable", "columns"),
Output("titulaire_datatable", "tooltip_header"),
Output("titulaire_datatable", "data_timestamp"),
Output("titulaire_nb_rows", "children"),
Output("btn-download-filtered-data-titulaire", "disabled"),
Output("btn-download-filtered-data-titulaire", "children"),
Output("btn-download-filtered-data-titulaire", "title"),
Output("filter-cleanup-trigger-titulaire", "data"),
Input(component_id="titulaire_url", component_property="href"),
Input("titulaire_data", "data"),
Input("titulaire_datatable", "page_current"),
Input("titulaire_datatable", "page_size"),
Input("titulaire_datatable", "filter_query"),
Input("titulaire_datatable", "sort_by"),
State("titulaire_datatable", "data_timestamp"),
)
def get_last_marches_data(
href, data, page_current, page_size, filter_query, sort_by, data_timestamp
) -> list[dict]:
return prepare_table_data(
data,
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"titulaire",
)
@callback(
Output(component_id="top10_acheteurs", component_property="children"),
Input(component_id="titulaire_data", component_property="data"),
)
def get_top_acheteurs(data):
return get_top_org_table(data, "acheteur", ["titulaire_distance"])
@callback(
Output("download-data-titulaire", "data"),
Input("btn-download-data-titulaire", "n_clicks"),
State(component_id="titulaire_data", component_property="data"),
State(component_id="titulaire_nom", component_property="children"),
State(component_id="titulaire_year", component_property="value"),
prevent_initial_call=True,
)
def download_titulaire_data(
n_clicks,
data: list[dict[str, Any]],
titulaire_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx")
@callback(
Output("titulaire-download-filtered-data", "data"),
State("titulaire_data", "data"),
Input("btn-download-filtered-data-titulaire", "n_clicks"),
State("titulaire_nom", "children"),
State("titulaire_datatable", "filter_query"),
State("titulaire_datatable", "sort_by"),
State("titulaire_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def download_filtered_titulaire_data(
data,
n_clicks,
titulaire_nom,
filter_query,
sort_by,
hidden_columns: list | None = None,
):
lff: pl.LazyFrame = pl.LazyFrame(
data
) # start from the full titulaire data, not from paginated table data
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
track_search(filter_query, "titu download")
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(
to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx"
)
# Pour nettoyer les icontains et i< des filtres
# voir aussi src/assets/dash_clientside.js
clientside_callback(
ClientsideFunction(
namespace="clientside",
function_name="clean_filters",
),
Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True),
Input("filter-cleanup-trigger-titulaire", "data"),
prevent_initial_call=True,
)
@callback(
Output("titulaire-hidden-columns", "data", allow_duplicate=True),
Input("titulaire_column_list", "selected_rows"),
prevent_initial_call=True,
)
def update_hidden_columns_from_checkboxes(selected_columns):
if selected_columns:
selected_columns = [COLUMNS[i] for i in selected_columns]
hidden_columns = [col for col in COLUMNS if col not in selected_columns]
return hidden_columns
else:
return []
@callback(
Output("titulaire_datatable", "hidden_columns"),
Input(
"titulaire-hidden-columns",
"data",
),
)
def store_hidden_columns(hidden_columns):
if hidden_columns is None:
hidden_columns = get_default_hidden_columns("titulaire")
return hidden_columns
@callback(
Output("titulaire_column_list", "selected_rows"),
Input("titulaire_datatable", "hidden_columns"),
State("titulaire_column_list", "selected_rows"), # pour éviter la boucle infinie
)
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
hidden_cols = hidden_cols or get_default_hidden_columns("titulaire")
# Show all columns that are NOT hidden
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
return visible_cols
@callback(
Output("titulaire_columns", "is_open"),
Input("titulaire_columns_open", "n_clicks"),
Input("titulaire_columns_close", "n_clicks"),
State("titulaire_columns", "is_open"),
)
def toggle_titulaire_columns(click_open, click_close, is_open):
if click_open or click_close:
return not is_open
return is_open
@callback(
Output("titulaire_datatable", "filter_query", allow_duplicate=True),
Output("titulaire_datatable", "sort_by"),
Input("btn-titulaire-reset", "n_clicks"),
prevent_initial_call=True,
)
def reset_view(n_clicks):
return "", []
@callback(
Output("titulaire-distance-histogram", "children"),
Input("titulaire_data", "data"),
)
def update_titulaire_distance_histogram(data):
lff = pl.LazyFrame(data)
if "titulaire_distance" in lff.collect_schema().names():
lff = lff.with_columns(
pl.col("titulaire_distance").cast(pl.Float64, strict=False)
)
fig = get_distance_histogram(lff)
return [
html.H3("Distance acheteur-titulaire"),
html.H6("par nombre de marchés", className="card-subtitle mb-2 text-muted"),
fig,
]
+43
View File
@@ -0,0 +1,43 @@
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",
level=logging.INFO,
datefmt="%Y-%m-%d %H:%M:%S",
)
DEVELOPMENT = os.getenv("DEVELOPMENT", "False").lower() == "true"
logger = logging.getLogger("decp.info")
if DEVELOPMENT:
logger.setLevel(logging.DEBUG)
DOMAIN_NAME = (
"test.decp.info"
if os.getenv("DEVELOPMENT", "False").lower() == "true"
else "decp.info"
)
+4
View File
@@ -0,0 +1,4 @@
from flask_caching import Cache
# Isolé dans un fichier dédié pour éviter les imports circulaires
cache = Cache()
+128
View File
@@ -0,0 +1,128 @@
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
from src.db import get_cursor, query_marches, schema
from src.utils import logger
logging.getLogger("httpx").setLevel("WARNING")
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()
response = response.json()["results"][0]
except (HTTPError, IndexError):
response = None
logger.warning("Could not fetch data from recherche-entreprises.api.")
return response
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_departements_geojson() -> dict:
with open("./data/departements-1000m.geojson") as f:
geojson = json.load(f)
# Ajout de feature.id
for f in geojson["features"]:
f["id"] = f["properties"]["code"]
return geojson
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
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)
logger.info(f"Utilisation du schéma local ({local_path})")
new_schema = OrderedDict()
for col in original_schema["fields"]:
new_schema[col["name"]] = col
return new_schema
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
"""Exécute la requête DuckDB filtrée pour le tableau de bord.
Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble
correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame
appellent `.lazy()` sur le résultat.
"""
from src.utils.table_sql import dashboard_filters_to_sql
where_sql, params = dashboard_filters_to_sql(**filter_params)
return query_marches(where_sql=where_sql, params=params)
def build_org_frame(org_type: str) -> pl.DataFrame:
org_cols = [
c
for c in schema.names()
if c.startswith(f"{org_type}_")
and c not in (f"{org_type}_latitude", f"{org_type}_longitude")
]
select_list = ", ".join(org_cols)
group_list = ", ".join(org_cols)
sql = f'SELECT {select_list}, COUNT(*) AS "Marchés" FROM decp GROUP BY {group_list}'
return get_cursor().execute(sql).pl()
DF_ACHETEURS = build_org_frame("acheteur")
DF_TITULAIRES = build_org_frame("titulaire")
DEPARTEMENTS = get_departements()
DEPARTEMENTS_GEOJSON = get_departements_geojson()
DATA_SCHEMA = get_data_schema()
+27
View File
@@ -0,0 +1,27 @@
from src.utils.data import DATA_SCHEMA
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 = "Télécharger les données telles qu'affichées au format Excel"
return download_disabled, download_text, download_title
def get_enum_values_as_dict(column_name):
try:
options = {}
for value in DATA_SCHEMA[column_name]["enum"]:
options[value] = value
return options
except KeyError:
return {"not_found": "not found"}
+84
View File
@@ -0,0 +1,84 @@
import polars as pl
from unidecode import unidecode
from src.utils.table import add_links
from src.utils.tracking import track_search
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
+54
View File
@@ -0,0 +1,54 @@
from src.utils import DOMAIN_NAME
from src.utils.data import get_annuaire_data
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)
if not annuaire_data:
return {}
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
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."
),
}
+533
View File
@@ -0,0 +1,533 @@
import os
import uuid
import polars as pl
from dash import no_update
from polars import selectors as cs
from src.db import count_marches, count_unique_marches, query_marches, schema
from src.utils import logger
from src.utils.cache import cache
from src.utils.data import DATA_SCHEMA
from src.utils.frontend import get_button_properties
from src.utils.tracking import track_search
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(
(
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
).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_"):
detail_link = (
'<a href = "/titulaires/'
+ pl.col("titulaire_id")
+ '">'
+ pl.col(col)
+ "</a>"
)
if col == "titulaire_nom":
detail_link = (
detail_link
+ ' <a href="/observatoire?titulaire_id='
+ pl.col("titulaire_id")
+ '" title="Voir dans l\'observatoire">📊</a>'
)
dff = dff.with_columns(
pl.when(
pl.Expr.or_(
pl.col("titulaire_typeIdentifiant").is_null(),
pl.col("titulaire_typeIdentifiant") == "SIRET",
)
)
.then(detail_link)
.otherwise(pl.col(col))
.alias(col)
)
if col.startswith("acheteur_"):
detail_link = (
'<a href = "/acheteurs/'
+ pl.col("acheteur_id")
+ '">'
+ pl.col(col)
+ "</a>"
)
if col == "acheteur_nom":
detail_link = (
detail_link
+ ' <a href="/observatoire?acheteur_id='
+ pl.col("acheteur_id")
+ '" title="Voir dans l\'observatoire">📊</a>'
)
dff = dff.with_columns(detail_link.alias(col))
if col == "uid":
dff = dff.with_columns(
(
'<a href = "/marches/'
+ pl.col("uid")
+ '">'
+ pl.col("uid")
+ "</a>"
).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'<a href="/{org_type}s/{org_id}">{marche[org_type + "_nom"]}</a>'
)
if marche.get("uid"):
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
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 normalize_sort_by(sort_by) -> tuple:
if not sort_by:
return ()
return tuple((entry["column_id"], entry["direction"]) for entry in sort_by)
def format_number(number) -> str:
if not number:
return ""
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):
# 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 filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
_schema = lff.collect_schema()
filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part)
if not isinstance(col_name, str) or not isinstance(filter_value, str):
continue
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"] and isinstance(filter_value, str):
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 = 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 == "tableau":
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 postprocess_page(dff: pl.DataFrame) -> pl.DataFrame:
"""Post-traitement à appliquer sur une page déjà paginée.
À appeler après la pagination.
"""
dff = dff.with_columns(pl.all().cast(pl.String).fill_null(""))
dff = add_links(dff)
if "sourceFile" in dff.columns:
dff = add_resource_link(dff)
if dff.height > 0:
dff = format_values(dff)
return dff
@cache.memoize()
def _fetch_page_sql(
filter_query: str | None,
sort_by_key: tuple,
page_current: int,
page_size: int,
) -> tuple[pl.DataFrame, int, int]:
"""Chemin rapide : filtre/tri/pagine dans DuckDB, post-traite la page seule.
Retourne (page_dataframe_post_traitée, total_count, total_unique_count).
"""
# Import local pour éviter une dépendance circulaire
# (src.utils.table_sql importe split_filter_part depuis src.utils.table).
from src.utils.table_sql import filter_query_to_sql, sort_by_to_sql
logger.debug(
f"Cache miss SQL — filter={filter_query!r} sort={sort_by_key!r} "
f"page={page_current} size={page_size}"
)
where_sql, params = filter_query_to_sql(filter_query or "", schema)
sort_by_dash = [
{"column_id": col, "direction": direction} for col, direction in sort_by_key
]
order_by = sort_by_to_sql(sort_by_dash, schema) or None
total = count_marches(where_sql, params)
total_unique = count_unique_marches(where_sql, params)
page = query_marches(
where_sql=where_sql,
params=params,
order_by=order_by,
limit=page_size,
offset=page_current * page_size,
)
page = postprocess_page(page)
return page, total, total_unique
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:
"""
logger.debug(" + + + + + + + + + + + + + + + + + + ")
if filter_query:
track_search(filter_query, source_table)
trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4())
if data is None:
# Probablement car il s'agit de la page Tableau
sort_by_key = normalize_sort_by(sort_by)
dff, height, total_unique = _fetch_page_sql(
filter_query=filter_query,
sort_by_key=sort_by_key,
page_current=page_current,
page_size=page_size,
)
else:
if isinstance(data, list):
lff: pl.LazyFrame = pl.LazyFrame(
data, strict=False, infer_schema_length=5000
)
elif isinstance(data, pl.LazyFrame):
lff = data
else:
lff = query_marches().lazy()
if filter_query:
lff = filter_table_data(lff, filter_query)
df_height = lff.select("uid").collect(engine="streaming")
height = df_height.height
total_unique = df_height["uid"].n_unique()
if sort_by and len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
start_row = page_current * page_size
lff = lff.slice(start_row, page_size)
dff = lff.collect(engine="streaming")
dff: pl.DataFrame = postprocess_page(dff)
if height > 0:
nb_rows = (
f"{format_number(height)} lignes ({format_number(total_unique)} marchés)"
)
else:
nb_rows = "0 lignes (0 marchés)"
table_columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
download_disabled, download_text, download_title = get_button_properties(height)
return (
dicts,
table_columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
trigger_cleanup,
)
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
COLUMNS = schema.names()
+241
View File
@@ -0,0 +1,241 @@
from datetime import datetime, timedelta
import polars as pl
from src.utils import logger
from src.utils.table import split_filter_part
def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list]:
"""Traduit le DSL de filtres de dash_table.DataTable en fragment SQL DuckDB.
Retourne (where_clause, params) where_clause est un fragment à injecter
après WHERE et params est la liste des valeurs à passer à
cursor.execute(sql, params). Les identifiants de colonnes sont validés
contre le schéma fourni ; jamais concaténés avec des valeurs utilisateur.
"""
if not filter_query:
return "TRUE", []
clauses: list[str] = []
params: list = []
for part in filter_query.split(" && "):
col_name, operator, raw_value = split_filter_part(part)
if not isinstance(col_name, str) or not isinstance(raw_value, str):
continue
if col_name not in schema.names():
logger.warning(f"Colonne inconnue ignorée : {col_name!r}")
continue
col_type = schema[col_name]
is_numeric = col_type.is_numeric()
col_is_date = col_type == pl.Date
quoted_col = f'"{col_name}"'
if is_numeric:
try:
value = int(raw_value) if col_type.is_integer() else float(raw_value)
except ValueError:
logger.warning(f"Valeur numérique invalide ignorée : {raw_value!r}")
continue
if operator == "contains":
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} = ?")
elif operator == ">":
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} > ?")
elif operator == "<":
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} < ?")
else:
logger.warning(f"Opérateur invalide pour numérique : {operator!r}")
continue
params.append(value)
continue
# String / Date : toujours traité comme texte (parité avec Polars)
value = raw_value.strip('"')
if operator == "contains":
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
clauses.append(f"{quoted_col} IS NOT NULL AND {target} {operator} ?")
params.append(value)
else:
logger.warning(f"Opérateur invalide pour chaîne : {operator!r}")
continue
if not clauses:
return "TRUE", []
return " AND ".join(clauses), params
def sort_by_to_sql(sort_by: list[dict] | None, schema: pl.Schema) -> str:
"""Traduit sort_by (format Dash) en clause ORDER BY DuckDB.
Retourne '' si pas de tri (aucun ORDER BY à ajouter).
"""
if not sort_by:
return ""
fragments: list[str] = []
for entry in sort_by:
col = entry.get("column_id")
direction = entry.get("direction")
if col not in schema.names():
logger.warning(f"Tri sur colonne inconnue ignoré : {col!r}")
continue
if direction not in ("asc", "desc"):
logger.warning(f"Tri sur direction inconnue ignoré : {direction!r}")
continue
fragments.append(f'"{col}" {direction.upper()} NULLS LAST')
return ", ".join(fragments)
def dashboard_filters_to_sql(
dashboard_year=None,
dashboard_acheteur_id=None,
dashboard_acheteur_categorie=None,
dashboard_acheteur_departement_code=None,
dashboard_titulaire_id=None,
dashboard_titulaire_categorie=None,
dashboard_titulaire_departement_code=None,
dashboard_marche_type=None,
dashboard_marche_objet=None,
dashboard_marche_code_cpv=None,
dashboard_marche_considerations_sociales=None,
dashboard_marche_considerations_environnementales=None,
dashboard_marche_techniques=None,
dashboard_marche_innovant=None,
dashboard_marche_sous_traitance_declaree=None,
dashboard_montant_min=None,
dashboard_montant_max=None,
) -> tuple[str, list]:
"""Traduit les filtres du tableau de bord en (where_clause, params) DuckDB."""
clauses: list[str] = []
params: list = []
if dashboard_year:
clauses.append('YEAR("dateNotification") = ?')
params.append(int(dashboard_year))
else:
clauses.append('"dateNotification" > ?')
params.append(datetime.now() - timedelta(days=365))
if dashboard_acheteur_id:
dashboard_acheteur_id = dashboard_acheteur_id.replace(" ", "")
clauses.append('"acheteur_id" LIKE ?')
params.append(f"%{dashboard_acheteur_id}%")
else:
if dashboard_acheteur_categorie:
clauses.append('"acheteur_categorie" = ?')
params.append(dashboard_acheteur_categorie)
if dashboard_acheteur_departement_code:
placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code))
clauses.append(f'"acheteur_departement_code" IN ({placeholders})')
params.extend(dashboard_acheteur_departement_code)
if dashboard_titulaire_id:
dashboard_titulaire_id = dashboard_titulaire_id.replace(" ", "")
clauses.append('"titulaire_id" LIKE ?')
params.append(f"%{dashboard_titulaire_id}%")
else:
if dashboard_titulaire_categorie:
clauses.append('"titulaire_categorie" = ?')
params.append(dashboard_titulaire_categorie)
if dashboard_titulaire_departement_code:
placeholders = ", ".join(["?"] * len(dashboard_titulaire_departement_code))
clauses.append(f'"titulaire_departement_code" IN ({placeholders})')
params.extend(dashboard_titulaire_departement_code)
if dashboard_marche_type:
clauses.append('"type" = ?')
params.append(dashboard_marche_type)
if dashboard_marche_objet:
where_clause, param_list = tokenize_text_filter("objet", dashboard_marche_objet)
clauses.append(where_clause)
params.extend(param_list)
if dashboard_marche_code_cpv:
clauses.append('"codeCPV" LIKE ?')
params.append(f"{dashboard_marche_code_cpv}%")
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
clauses.append('"marcheInnovant" = ?')
params.append(dashboard_marche_innovant)
if (
dashboard_marche_sous_traitance_declaree
and dashboard_marche_sous_traitance_declaree != "all"
):
clauses.append('"sousTraitanceDeclaree" = ?')
params.append(dashboard_marche_sous_traitance_declaree)
if dashboard_marche_techniques:
clauses.append("list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])")
params.append(list(dashboard_marche_techniques))
if dashboard_marche_considerations_sociales:
clauses.append(
"list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_considerations_sociales))
if dashboard_marche_considerations_environnementales:
clauses.append(
"list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_considerations_environnementales))
if dashboard_montant_min is not None:
clauses.append('"montant" >= ?')
params.append(dashboard_montant_min)
if dashboard_montant_max is not None:
clauses.append('"montant" <= ?')
params.append(dashboard_montant_max)
return " AND ".join(clauses), params
def tokenize_text_filter(
column: str, text: str, col_is_date: bool = False
) -> tuple[str, list]:
terms = text.split()
# si col_is_date alors le deuxième doit être casté en VARCHAR
if col_is_date:
quoted_col = f'CAST("{column}" AS VARCHAR)'
else:
quoted_col = f'"{column}"'
conditions = [f'"{column}" IS NOT NULL', f"{quoted_col} <> ''"]
params = []
for term in terms:
conditions.append(f"{quoted_col} ILIKE ?")
if term.startswith("*") or term.endswith("*"):
params.append(term.replace("*", "%"))
elif "+" in term:
params.append(f"%{term.replace('+', ' ')}%")
else:
params.append(f"%{term}%")
where_clause = " AND ".join(conditions)
return where_clause, params

Some files were not shown because too many files have changed in this diff Show More