Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc80ad94e | |||
| ec877669d4 | |||
| a41b70d5af | |||
| e41dbe772c | |||
| 5a3770027f | |||
| 5c90b5f350 | |||
| dd229a212f | |||
| 8bcf57ce83 | |||
| 0fea49109a | |||
| 8337f7cbf1 | |||
| 159d409237 | |||
| 1586f9b7e4 | |||
| 46dfc23d42 | |||
| 8c3642e5d6 | |||
| 0c99f037b5 | |||
| 929b836cb0 | |||
| 73a10e20a6 | |||
| 48da8cde5c | |||
| 36ea24278f | |||
| 000e4e89db | |||
| 3396986b2d | |||
| f3305d42cd | |||
| f56f5bfc94 | |||
| 5a9b585b44 | |||
| 89a419a8d2 | |||
| 90af1f6eb4 | |||
| e9db143f45 | |||
| 6a5b4aa2aa | |||
| 56b7a8a0a1 | |||
| 5bd519f9e5 | |||
| 5ca5fc300f | |||
| b93b290f97 | |||
| e8eba9ba66 | |||
| 87d657abf8 | |||
| 5a89f70d4c | |||
| 6e37e59c4c | |||
| 3f931313a6 | |||
| 815d25519f | |||
| cd45c364e6 | |||
| b423c01068 | |||
| 0729ebb23e | |||
| c682f16c40 | |||
| 94f9192760 | |||
| f490e099ea | |||
| 8a9f135662 | |||
| b12a5b72c2 | |||
| c2ab1f49de | |||
| 81526ae2b4 | |||
| e41351dfc1 | |||
| b854d77d2f | |||
| 3db5b804e4 | |||
| 89b0666e70 | |||
| 95bc323d63 | |||
| 8376295464 | |||
| 1e194f52a6 | |||
| ff1402d427 | |||
| 1ab5c639f0 | |||
| 637c35522f | |||
| 83744cea65 | |||
| 23932c8dd3 | |||
| 8e216124f1 | |||
| b0e440595a | |||
| 88f37c199a | |||
| 68b3072aa3 | |||
| 9f74aaea69 | |||
| 89f95904b8 | |||
| 8e7048490e | |||
| 59fb9e7b3a | |||
| 01e714dba5 | |||
| 859506c4aa | |||
| 4c6f38f41e | |||
| 1171708d9e | |||
| c66bf97572 | |||
| 804f6e7883 | |||
| 12d01bb3e0 | |||
| 9d37e0c59b | |||
| 5cd540a8d0 | |||
| 1717c118ab | |||
| 87f336a457 | |||
| 0e9597de54 | |||
| 972d7f55b2 | |||
| 17df388035 | |||
| 21f0be3265 | |||
| c8b2cc3683 | |||
| ad19744a47 | |||
| 59fe1df1e7 | |||
| 313a7ba230 | |||
| 5c9fd6b4ef | |||
| 74b122e75c | |||
| a9b37c1907 | |||
| 8ad69b4bae | |||
| 10ec2c6649 | |||
| fadb4b1930 | |||
| e595128c13 | |||
| f9b46eea36 | |||
| 5ed704a497 | |||
| 64001959c3 | |||
| ec8b109258 | |||
| cd25673a5f | |||
| 459984bcbf | |||
| a2f9b2dca0 | |||
| cd208ee57b | |||
| 5ec550373d | |||
| d1da7c321c | |||
| e4a670948d | |||
| 2034577eb2 |
@@ -0,0 +1,43 @@
|
|||||||
|
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@v3
|
||||||
|
|
||||||
|
- 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: ${{ secrets.APP_PATH }}/deploy.sh ${{ env.APP_NAME }}
|
||||||
+8
-11
@@ -2,23 +2,20 @@ repos:
|
|||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-ast
|
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.3.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
args: [ --config=pyproject.toml ]
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.3.0
|
|
||||||
hooks:
|
|
||||||
- id: black-jupyter
|
|
||||||
args: [ --config=pyproject.toml ]
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v2.5.1
|
rev: v2.5.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
files: \.(js|css|html|json|md)$
|
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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
|
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
|
||||||
PORT=8050
|
PORT=8050
|
||||||
DEVELOPMENT=True
|
DEVELOPMENT=True
|
||||||
|
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
decp.info is a web application that enable analysis and download of
|
||||||
|
French public procurement data.
|
||||||
|
Copyright (C) 2025 Colin Maudry
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# decp.info
|
# decp.info
|
||||||
|
|
||||||
|
> v2.0.1
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
=> [decp.info](https://decp.info)
|
=> [decp.info](https://decp.info)
|
||||||
@@ -10,16 +12,44 @@ Outil d'exploration et de téléchargement des données essentielles de la comma
|
|||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install .
|
pip install .
|
||||||
|
|
||||||
|
# Copie et personnalisation du .env
|
||||||
|
cp template.env .env
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Pour la production
|
||||||
gunicorn app:server
|
gunicorn app:server
|
||||||
|
|
||||||
|
# Pour avoir le debuggage et le hot reload
|
||||||
|
python run.py
|
||||||
```
|
```
|
||||||
## Dépôts de code connexes
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Liens connexes
|
||||||
|
|
||||||
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
|
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
|
||||||
- [decp-table-schema](https://github.com/ColinMaudry/decp-table-schema) (schéma de données tabulaire)
|
- [colin.maudry.com](https://colin.maudry.com) (blog)
|
||||||
|
|
||||||
## Notes de version
|
## Notes de version
|
||||||
|
|
||||||
### 2.0.0-alpha
|
#### 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
|
- Data table fonctionnelle
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
|||||||
|
#!bin/bash
|
||||||
|
|
||||||
|
# Utilisé principalement avec les Github Actions
|
||||||
|
# cf. .github/workflows/deploy.yaml
|
||||||
|
|
||||||
|
appname = "$1"
|
||||||
|
|
||||||
|
systemctl stop $appname
|
||||||
|
cd /var/www/$appname
|
||||||
|
git pull
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install .
|
||||||
|
deactivate
|
||||||
|
chown -R $appname:www-data *
|
||||||
|
systemctl start $appname
|
||||||
+5
-2
@@ -1,17 +1,20 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decp.info"
|
name = "decp.info"
|
||||||
description = ""
|
description = ""
|
||||||
version = "2.0.0"
|
version = "2.0.1"
|
||||||
requires-python = ">= 3.10"
|
requires-python = ">= 3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
|
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dash",
|
"dash==3.2.0",
|
||||||
|
"dash[compress]",
|
||||||
"polars",
|
"polars",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
"dash-bootstrap-components",
|
"dash-bootstrap-components",
|
||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
|
"xlsxwriter",
|
||||||
|
"plotly[express]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from os import getenv
|
|
||||||
from src.app import app
|
from src.app import app
|
||||||
|
|
||||||
# To use `gunicorn run:server` (prod)
|
# To use `gunicorn run:server` (prod)
|
||||||
|
|||||||
+60
-2
@@ -1,10 +1,68 @@
|
|||||||
from dash import Dash, html, page_container, page_registry, dcc
|
import logging
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
|
from dash import Dash, dcc, html, page_container, page_registry
|
||||||
|
from flask import send_from_directory
|
||||||
|
|
||||||
|
app = Dash(
|
||||||
|
external_stylesheets=[dbc.themes.SIMPLEX],
|
||||||
|
title="decp.info",
|
||||||
|
use_pages=True,
|
||||||
|
compress=True,
|
||||||
|
)
|
||||||
|
# COSMO (belle font, blue),
|
||||||
|
# UNITED (rouge, ubuntu font),
|
||||||
|
# LUMEN (gros séparateur, blue clair),
|
||||||
|
# SIMPLEX (rouge, séparateur)
|
||||||
|
|
||||||
|
|
||||||
app = Dash(external_stylesheets=[dbc.themes.UNITED], title="decp.info", use_pages=True)
|
# robots.txt
|
||||||
|
@app.server.route("/robots.txt")
|
||||||
|
def robots():
|
||||||
|
return send_from_directory("./assets", "robots.txt", mimetype="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("decp.info")
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
|
level=logging.INFO,
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.index_string = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
{%metas%}
|
||||||
|
<title>{%title%}</title>
|
||||||
|
{%favicon%}
|
||||||
|
{%css%}
|
||||||
|
</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>
|
||||||
|
"""
|
||||||
|
|
||||||
app.layout = html.Div(
|
app.layout = html.Div(
|
||||||
[
|
[
|
||||||
html.Div(
|
html.Div(
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# START YOAST BLOCK
|
||||||
|
# Copié depuis https://next.ink/robots.txt
|
||||||
|
# ---------------------------
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# END YOAST BLOCK
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Disallow: /
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Disallow: /
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Disallow: /
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Disallow: /
|
||||||
|
User-agent: Amazonbot
|
||||||
|
Disallow: /
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Disallow: /
|
||||||
|
User-Agent: FacebookBot
|
||||||
|
Disallow: /
|
||||||
|
User-Agent: Applebot
|
||||||
|
Disallow: /
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
Disallow: /
|
||||||
|
User-agent: Bytespider
|
||||||
|
Disallow: /
|
||||||
|
User-agent: Claude-Web
|
||||||
|
Disallow: /
|
||||||
|
User-agent: Diffbot
|
||||||
|
Disallow: /
|
||||||
|
User-agent: ImagesiftBot
|
||||||
|
Disallow: /
|
||||||
|
User-agent: Omgilibot
|
||||||
|
Disallow: /
|
||||||
|
User-agent: Omgili
|
||||||
|
Disallow: /
|
||||||
|
User-agent: YouBot
|
||||||
|
Disallow: /
|
||||||
+58
-12
@@ -1,15 +1,21 @@
|
|||||||
/* Change le texte du bout d'export */
|
/* Change la marge bout d'export */
|
||||||
button.export {
|
.table-menu {
|
||||||
font-size: 0;
|
font-size: 16px;
|
||||||
}
|
margin: 12px;
|
||||||
button.export:before {
|
height: 36px;
|
||||||
content: "Télécharger au format Excel";
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Masquer le bouton de configuration de la casse dans les filtres */
|
.table-menu > * {
|
||||||
input.dash-filter--case {
|
margin: 8px;
|
||||||
display: none;
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#source_table p {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#source_table {
|
||||||
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Réduire la taille du texte de la colonne Objet */
|
/* Réduire la taille du texte de la colonne Objet */
|
||||||
@@ -18,6 +24,7 @@ td[data-dash-column="objet"] {
|
|||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Couleur des en-têtes */
|
||||||
.dash-table-container
|
.dash-table-container
|
||||||
.dash-spreadsheet-container
|
.dash-spreadsheet-container
|
||||||
.dash-spreadsheet-inner
|
.dash-spreadsheet-inner
|
||||||
@@ -33,6 +40,37 @@ td[data-dash-column="objet"] {
|
|||||||
background-color: #f0afa3;
|
background-color: #f0afa3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash-table-container p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-filter--case {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu de masquage des colonnes */
|
||||||
|
.column-header--hide svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.show-hide {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
margin: 0 0 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-hide::before {
|
||||||
|
background: inherit;
|
||||||
|
content: "Colonnes affichées";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-hide-menu-item > input {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternance des couleurs pour les lignes */
|
||||||
#table tr:nth-child(even) td {
|
#table tr:nth-child(even) td {
|
||||||
background-color: #feeeee;
|
background-color: #feeeee;
|
||||||
}
|
}
|
||||||
@@ -42,10 +80,14 @@ td[data-dash-column="objet"] {
|
|||||||
margin: 0 0 20px 20px;
|
margin: 0 0 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Menu de navigation */
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
padding: 10px;
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: solid 1px black;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.nav {
|
a.nav {
|
||||||
@@ -54,7 +96,7 @@ a.nav {
|
|||||||
font-size: 120%;
|
font-size: 120%;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.navbar.h1 {
|
||||||
float: left;
|
float: left;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
@@ -62,3 +104,7 @@ h1 {
|
|||||||
h3 {
|
h3 {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#_pages_content {
|
||||||
|
padding-top: 28px;
|
||||||
|
}
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import plotly.express as px
|
||||||
|
import polars as pl
|
||||||
|
from dash import dash_table, html
|
||||||
|
|
||||||
|
|
||||||
|
def get_map_count_marches(lf: pl.LazyFrame):
|
||||||
|
lf = lf.with_columns(
|
||||||
|
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
|
||||||
|
)
|
||||||
|
lf = (
|
||||||
|
lf.select(["uid", "Département"])
|
||||||
|
.drop_nulls()
|
||||||
|
.unique(subset="uid")
|
||||||
|
.group_by("Département")
|
||||||
|
.len("uid")
|
||||||
|
)
|
||||||
|
# Suppression des infos pour les DOM/TOM pour l'instant
|
||||||
|
lf = lf.remove(pl.col("Département").is_in(["97", "98"]))
|
||||||
|
|
||||||
|
with open("./data/departements-1000m.geojson") as f:
|
||||||
|
departements = json.load(f)
|
||||||
|
|
||||||
|
# Ajout de feature.id
|
||||||
|
for f in departements["features"]:
|
||||||
|
f["id"] = f["properties"]["code"]
|
||||||
|
|
||||||
|
df = lf.collect()
|
||||||
|
|
||||||
|
fig = px.choropleth(
|
||||||
|
df,
|
||||||
|
geojson=departements,
|
||||||
|
locations="Département",
|
||||||
|
color="uid",
|
||||||
|
color_continuous_scale="Reds",
|
||||||
|
title="Nombres de marchés attribués par département (lieu d'exécution)",
|
||||||
|
range_color=(df["uid"].min(), df["uid"].max()),
|
||||||
|
labels={"uid": "Marchés attribués"},
|
||||||
|
scope="europe",
|
||||||
|
width=1000,
|
||||||
|
height=800,
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_geos(fitbounds="locations", visible=False)
|
||||||
|
fig.update_layout(
|
||||||
|
mapbox={
|
||||||
|
"style": "carto-positron",
|
||||||
|
"center": {"lon": 10, "lat": 10},
|
||||||
|
"zoom": 1,
|
||||||
|
"domain": {"x": [0, 1], "y": [0, 1]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
|
||||||
|
labels = {
|
||||||
|
"dateNotification": "notification",
|
||||||
|
"datePublicationDonnees": "publication des données",
|
||||||
|
}
|
||||||
|
|
||||||
|
lf = lf.select("uid", type_date, "sourceDataset")
|
||||||
|
|
||||||
|
lf = lf.unique("uid")
|
||||||
|
|
||||||
|
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
|
||||||
|
lf = lf.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
|
||||||
|
lf = lf.with_columns(
|
||||||
|
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
|
||||||
|
.then(pl.lit("aws"))
|
||||||
|
.otherwise(pl.col("sourceDataset"))
|
||||||
|
.alias("sourceDataset")
|
||||||
|
)
|
||||||
|
|
||||||
|
lf = lf.with_columns(pl.col(type_date).dt.year().alias("annee"))
|
||||||
|
lf = lf.filter(
|
||||||
|
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, 2025)
|
||||||
|
)
|
||||||
|
lf = lf.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
|
||||||
|
lf = (
|
||||||
|
lf.group_by([type_date, "sourceDataset"])
|
||||||
|
.len()
|
||||||
|
.sort(by=[type_date, "len"], descending=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# lf = lf.with_columns(
|
||||||
|
# pl.when(pl.col("sourceDataset").is_null()).then(
|
||||||
|
# pl.lit("Source inconnue")).alias("sourceDataset")
|
||||||
|
# )
|
||||||
|
|
||||||
|
lf = lf.sort(by=["sourceDataset"], descending=False)
|
||||||
|
df: pl.DataFrame = lf.collect()
|
||||||
|
|
||||||
|
fig = px.bar(
|
||||||
|
df,
|
||||||
|
x=type_date,
|
||||||
|
y="len",
|
||||||
|
color="sourceDataset",
|
||||||
|
title=f"Nombre de marchés attribués par date de {labels[type_date]} et source de données",
|
||||||
|
labels={
|
||||||
|
"len": "Nombre de marchés",
|
||||||
|
type_date: f"Mois de {labels[type_date]}",
|
||||||
|
"sourceDataset": "Source de données",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def get_sources_tables(source_path) -> html.Div:
|
||||||
|
df = pl.read_csv(source_path)
|
||||||
|
df = df.with_columns(
|
||||||
|
(
|
||||||
|
pl.lit('<a href = "')
|
||||||
|
+ pl.col("url")
|
||||||
|
+ pl.lit('">')
|
||||||
|
+ pl.col("nom")
|
||||||
|
+ pl.lit("</a>")
|
||||||
|
).alias("nom")
|
||||||
|
)
|
||||||
|
df = df.drop("url")
|
||||||
|
df = df.sort(by=["nb_marchés"], descending=True)
|
||||||
|
|
||||||
|
datatable = dash_table.DataTable(
|
||||||
|
id="source_table",
|
||||||
|
columns=[
|
||||||
|
{
|
||||||
|
"name": i,
|
||||||
|
"id": i,
|
||||||
|
"presentation": "markdown",
|
||||||
|
"type": "text",
|
||||||
|
"format": {"nully": "N/A"},
|
||||||
|
}
|
||||||
|
for i in df.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},
|
||||||
|
)
|
||||||
|
datatable.data = df.to_dicts()
|
||||||
|
|
||||||
|
return html.Div(children=datatable)
|
||||||
+94
-3
@@ -1,9 +1,100 @@
|
|||||||
from dash import register_page, html
|
import os
|
||||||
|
|
||||||
|
from dash import dcc, html, register_page
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
|
||||||
title = "À propos"
|
title = "À propos"
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=3
|
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=5
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [html.H2(title)]
|
layout = [
|
||||||
|
html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.H2(title),
|
||||||
|
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-fournisseur...)
|
||||||
|
- la sauvegarde de filtres pour les retrouver plus tard et les partager
|
||||||
|
- des alertes par email si des marchés correspondant à certains critères
|
||||||
|
- le développement d'une API pour alimenter d'autres logiciels
|
||||||
|
- ...et toutes les fonctionnalités auxquelles vous pourrez penser
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
- envoyez un mail et on discute !
|
||||||
|
|
||||||
|
#### Pour explorer le projet
|
||||||
|
|
||||||
|
- ✉️ [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), qui parle beaucoup de transparence des marchés publics
|
||||||
|
- 📔 [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("Contact", id="contact"),
|
||||||
|
dcc.Markdown("""
|
||||||
|
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
|
||||||
|
- 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/)
|
||||||
|
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
|
||||||
|
"""),
|
||||||
|
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"),
|
||||||
|
dcc.Markdown("""
|
||||||
|
##### Publication
|
||||||
|
|
||||||
|
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 l’Evêque, 75008 Paris
|
||||||
|
|
||||||
|
##### Suivi d'audience
|
||||||
|
|
||||||
|
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 n’affiche pas de bannière de consentement aux cookies, pourquoi ?**
|
||||||
|
|
||||||
|
C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes d’accord avec le dépôt de cookies.
|
||||||
|
|
||||||
|
Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable.
|
||||||
|
|
||||||
|
J’utilise 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 m’est donc impossible d’associer vos visites sur ce site à votre personne."""),
|
||||||
|
# Matomo propose cependant ce formulaire si vous souhaitez totalement désactiver le suivi de vos sessions sur ce site :"""),
|
||||||
|
# html.Div(
|
||||||
|
# id="matomo-opt-out",
|
||||||
|
# style={
|
||||||
|
# "border": "1pt solid lightgrey",
|
||||||
|
# "padding": "12px",
|
||||||
|
# "margin": "auto 0 auto 12px",
|
||||||
|
# "width": "80%",
|
||||||
|
# },
|
||||||
|
# children=["Vous utilisez un bloqueur de suivi de trafic."],
|
||||||
|
# ),
|
||||||
|
# html.Script(
|
||||||
|
# src="https://analytics.maudry.com/index.php?module=CoreAdminHome&action=optOutJS&divId=matomo-opt-out&language=auto&showIntro=1"
|
||||||
|
# ),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|||||||
+184
-37
@@ -1,15 +1,55 @@
|
|||||||
from dash import html, dcc, dash_table, register_page, Input, Output, State, callback
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from src.utils import split_filter_part
|
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from src.utils import (
|
||||||
|
add_annuaire_link,
|
||||||
|
add_resource_link,
|
||||||
|
booleans_to_strings,
|
||||||
|
format_number,
|
||||||
|
lf,
|
||||||
|
logger,
|
||||||
|
split_filter_part,
|
||||||
|
)
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
df = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
|
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
|
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
|
||||||
|
df_filtered = pl.DataFrame()
|
||||||
|
|
||||||
|
# Unique les données actuelles, pas les anciennes versions de marchés
|
||||||
|
|
||||||
|
lf = lf.filter(pl.col("donneesActuelles"))
|
||||||
|
|
||||||
|
# Suppression des colonnes inutiles
|
||||||
|
lf = lf.drop(
|
||||||
|
[
|
||||||
|
"donneesActuelles",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convertir les colonnes booléennes en chaînes de caractères
|
||||||
|
lf = booleans_to_strings(lf)
|
||||||
|
|
||||||
|
# Remplacer les valeurs manquantes par des chaînes vides
|
||||||
|
lf = lf.fill_null("")
|
||||||
|
|
||||||
|
|
||||||
|
# Ajout des liens vers l'annuaire
|
||||||
|
lf = add_annuaire_link(lf)
|
||||||
|
|
||||||
|
# Ajout des liens open data
|
||||||
|
lf = add_resource_link(lf)
|
||||||
|
|
||||||
|
schema = lf.collect_schema()
|
||||||
|
|
||||||
|
|
||||||
title = "Tableau"
|
title = "Tableau"
|
||||||
register_page(__name__, path="/", title=f"decp.info - {title}", name=title, order=1)
|
register_page(__name__, path="/", title="decp.info", name=title, order=1)
|
||||||
|
|
||||||
datatable = dash_table.DataTable(
|
datatable = dash_table.DataTable(
|
||||||
cell_selectable=False,
|
cell_selectable=False,
|
||||||
@@ -19,14 +59,21 @@ datatable = dash_table.DataTable(
|
|||||||
page_action="custom",
|
page_action="custom",
|
||||||
filter_action="custom",
|
filter_action="custom",
|
||||||
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
||||||
columns=[{"name": i, "id": i} for i in df.collect_schema().names()],
|
columns=[
|
||||||
selected_columns=[],
|
{
|
||||||
selected_rows=[],
|
"name": i,
|
||||||
# sort_action="native",
|
"id": i,
|
||||||
# sort_mode="multi",
|
"presentation": "markdown",
|
||||||
export_format="xlsx",
|
"type": "text",
|
||||||
export_columns="visible",
|
"format": {"nully": "N/A"},
|
||||||
export_headers="ids",
|
"hideable": True,
|
||||||
|
}
|
||||||
|
for i in lf.collect_schema().names()
|
||||||
|
],
|
||||||
|
sort_action="custom",
|
||||||
|
sort_mode="multi",
|
||||||
|
sort_by=[],
|
||||||
|
row_deletable=False,
|
||||||
style_cell_conditional=[
|
style_cell_conditional=[
|
||||||
{
|
{
|
||||||
"if": {"column_id": "objet"},
|
"if": {"column_id": "objet"},
|
||||||
@@ -36,15 +83,26 @@ datatable = dash_table.DataTable(
|
|||||||
"lineHeight": "14px",
|
"lineHeight": "14px",
|
||||||
"whiteSpace": "normal",
|
"whiteSpace": "normal",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "acheteur_nom"},
|
||||||
|
"minWidth": "250px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "14px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
data_timestamp=0,
|
data_timestamp=0,
|
||||||
|
markdown_options={"html": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
html.Div(
|
html.Div(
|
||||||
html.Details(
|
html.Details(
|
||||||
children=[
|
children=[
|
||||||
html.Summary(html.H3("Utilisation")),
|
html.Summary(
|
||||||
|
html.H3("Mode d'emploi", style={"text-decoration": "underline"}),
|
||||||
|
),
|
||||||
dcc.Markdown(
|
dcc.Markdown(
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -53,13 +111,13 @@ layout = [
|
|||||||
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
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 est insensible à la casse (majuscules/minuscules).
|
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules).
|
||||||
- Champs numériques : possibilité d'ajouter < ou > devant le chiffre recherché pour chercher des valeurs inférieures ou supérieur.
|
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de > ou < pour filtrer les valeurs supérieures ou inférieures.
|
||||||
|
|
||||||
|
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
|
||||||
|
|
||||||
**Télécharger le résultat**
|
**Télécharger le résultat**
|
||||||
|
|
||||||
Vous pouvez télécharger le résultat de vos filtres et tris en cliquant sur Télécharger au format Excel.
|
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.
|
||||||
|
|
||||||
Les colonnes supprimées seront absentes du fichier téléchargé.
|
|
||||||
|
|
||||||
Si vous téléchargez un volume important de données, il se peut que vous attendiez quelques minutes avant le début du téléchargement.
|
Si vous téléchargez un volume important de données, il se peut que vous attendiez quelques minutes avant le début du téléchargement.
|
||||||
"""
|
"""
|
||||||
@@ -77,9 +135,24 @@ layout = [
|
|||||||
# )]),
|
# )]),
|
||||||
dcc.Loading(
|
dcc.Loading(
|
||||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
id="loading-1",
|
id="loading-home",
|
||||||
type="default",
|
type="default",
|
||||||
children=datatable,
|
children=[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.P("lignes", id="nb_rows"),
|
||||||
|
html.Button(
|
||||||
|
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||||
|
id="btn-download-data",
|
||||||
|
disabled=True,
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-data"),
|
||||||
|
html.P("Données mises à jour le " + str(update_date)),
|
||||||
|
],
|
||||||
|
className="table-menu",
|
||||||
|
),
|
||||||
|
datatable,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -87,46 +160,120 @@ layout = [
|
|||||||
@callback(
|
@callback(
|
||||||
Output("table", "data"),
|
Output("table", "data"),
|
||||||
Output("table", "data_timestamp"),
|
Output("table", "data_timestamp"),
|
||||||
|
Output("nb_rows", "children"),
|
||||||
|
Output("btn-download-data", "disabled"),
|
||||||
|
Output("btn-download-data", "children"),
|
||||||
|
Output("btn-download-data", "title"),
|
||||||
Input("table", "page_current"),
|
Input("table", "page_current"),
|
||||||
Input("table", "page_size"),
|
Input("table", "page_size"),
|
||||||
Input("table", "filter_query"),
|
Input("table", "filter_query"),
|
||||||
|
Input("table", "sort_by"),
|
||||||
State("table", "data_timestamp"),
|
State("table", "data_timestamp"),
|
||||||
)
|
)
|
||||||
def update_table(page_current, page_size, filter_query, data_timestamp):
|
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
|
||||||
print(" + + + + + + + + + + + + + + + + + + ")
|
print(" + + + + + + + + + + + + + + + + + + ")
|
||||||
print("Filter query:", filter_query)
|
global df_filtered
|
||||||
# 1. Apply Filters
|
|
||||||
dff = df # start from the original data
|
# Application des filtres
|
||||||
|
lff: pl.LazyFrame = lf # start from the original data
|
||||||
if filter_query:
|
if filter_query:
|
||||||
filtering_expressions = filter_query.split(" && ")
|
filtering_expressions = filter_query.split(" && ")
|
||||||
for filter_part in filtering_expressions:
|
for filter_part in filtering_expressions:
|
||||||
col_name, operator, filter_value = split_filter_part(filter_part)
|
col_name, operator, filter_value = split_filter_part(filter_part)
|
||||||
|
col_type = str(schema[col_name])
|
||||||
print("filter_value:", filter_value)
|
print("filter_value:", filter_value)
|
||||||
print("filter_value_type:", type(filter_value))
|
print("filter_value_type:", type(filter_value))
|
||||||
|
print("col_type:", col_type)
|
||||||
|
|
||||||
if operator in ("<", "<=", ">", ">="):
|
if operator in ("<", "<=", ">", ">="):
|
||||||
filter_value = int(filter_value)
|
filter_value = int(filter_value)
|
||||||
if operator == "<":
|
if operator == "<":
|
||||||
dff = dff.filter(pl.col(col_name) < filter_value)
|
lff = lff.filter(pl.col(col_name) < filter_value)
|
||||||
elif operator == ">":
|
elif operator == ">":
|
||||||
dff = dff.filter(pl.col(col_name) > filter_value)
|
lff = lff.filter(pl.col(col_name) > filter_value)
|
||||||
elif operator == ">=":
|
elif operator == ">=":
|
||||||
dff = dff.filter(pl.col(col_name) >= filter_value)
|
lff = lff.filter(pl.col(col_name) >= filter_value)
|
||||||
elif operator == "<=":
|
elif operator == "<=":
|
||||||
dff = dff.filter(pl.col(col_name) <= filter_value)
|
lff = lff.filter(pl.col(col_name) <= filter_value)
|
||||||
# these operators match polars series filter operators
|
|
||||||
|
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
|
||||||
|
lff = lff.filter(pl.col(col_name) == filter_value)
|
||||||
|
|
||||||
|
elif operator == "contains" and col_type == "String":
|
||||||
|
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
|
||||||
|
|
||||||
elif operator == "contains":
|
|
||||||
dff = dff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
|
|
||||||
# elif operator == 'datestartswith':
|
# elif operator == 'datestartswith':
|
||||||
# dff = dff.filter(pl.col(col_name).str.startswith(filter_value)")
|
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
|
||||||
|
|
||||||
# 2. Paginate Data
|
if len(sort_by) > 0:
|
||||||
|
lff = lff.sort(
|
||||||
|
[col["column_id"] for col in sort_by],
|
||||||
|
descending=[col["direction"] == "desc" for col in sort_by],
|
||||||
|
nulls_last=True,
|
||||||
|
)
|
||||||
|
print(sort_by)
|
||||||
|
|
||||||
|
dff: pl.DataFrame = lff.collect()
|
||||||
|
|
||||||
|
df_filtered = dff.clone()
|
||||||
|
|
||||||
|
height = dff.height
|
||||||
|
|
||||||
|
nb_rows = f"{format_number(height)} lignes"
|
||||||
|
|
||||||
|
# Pagination des données
|
||||||
start_row = page_current * page_size
|
start_row = page_current * page_size
|
||||||
# end_row = (page_current + 1) * page_size
|
# end_row = (page_current + 1) * page_size
|
||||||
|
dff = dff.slice(start_row, page_size)
|
||||||
|
dicts = dff.to_dicts()
|
||||||
|
|
||||||
dff = dff.slice(start_row, page_size).collect()
|
if height > 65000:
|
||||||
# print("dff_sliced:", dff.select("titulaire.typeId"))
|
download_disabled = True
|
||||||
dff = dff.to_dicts()
|
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
|
||||||
|
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
|
||||||
|
else:
|
||||||
|
download_disabled = False
|
||||||
|
download_text = "Télécharger au format Excel"
|
||||||
|
download_title = ""
|
||||||
|
|
||||||
return dff, data_timestamp + 1 # update data, update timestamp
|
return (
|
||||||
|
dicts,
|
||||||
|
data_timestamp + 1,
|
||||||
|
nb_rows,
|
||||||
|
download_disabled,
|
||||||
|
download_text,
|
||||||
|
download_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("download-data", "data"),
|
||||||
|
Input("btn-download-data", "n_clicks"),
|
||||||
|
State("table", "hidden_columns"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def download_data(n_clicks, hidden_columns: list = None):
|
||||||
|
df_to_download = df_filtered.clone()
|
||||||
|
|
||||||
|
print(df_to_download.columns)
|
||||||
|
|
||||||
|
# Rétablissement des colonnes source et sourceOpenData (voir add_resource_link)
|
||||||
|
df_to_download = df_to_download.with_columns(
|
||||||
|
pl.col("source").str.extract(r'href="(.*?)"').alias("sourceFile"),
|
||||||
|
pl.col("source").str.extract(r'">(.*?)<').alias("sourceDataset"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Les colonnes masquées sont supprimées
|
||||||
|
if hidden_columns:
|
||||||
|
df_to_download = df_to_download.drop(hidden_columns)
|
||||||
|
|
||||||
|
def to_bytes(buffer):
|
||||||
|
df_to_download.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")
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
from dash import register_page, html
|
|
||||||
|
|
||||||
title = "Statistiques"
|
|
||||||
|
|
||||||
register_page(
|
|
||||||
__name__, path="/statistiques", title=f"decp.info - {title}", name=title, order=2
|
|
||||||
)
|
|
||||||
|
|
||||||
layout = [html.H2(title)]
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from dash import dcc, html, register_page
|
||||||
|
|
||||||
|
from src.figures import get_barchart_sources, get_map_count_marches
|
||||||
|
from src.utils import lf
|
||||||
|
|
||||||
|
title = "Statistiques"
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__, path="/statistiques", title=f"decp.info - {title}", name=title, order=3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.H2(title),
|
||||||
|
dcc.Loading(
|
||||||
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
|
id="loading-statistques",
|
||||||
|
type="default",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
children=[
|
||||||
|
dcc.Markdown("""
|
||||||
|
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par
|
||||||
|
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas,
|
||||||
|
ou publient d'une manière qui rend la récupération des données compliquée. Les données présentées sur ce site
|
||||||
|
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative.
|
||||||
|
|
||||||
|
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22source%20de%20donn%C3%A9es%22),
|
||||||
|
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
|
||||||
|
"""),
|
||||||
|
dcc.Graph(figure=get_map_count_marches(lf)),
|
||||||
|
dcc.Graph(
|
||||||
|
figure=get_barchart_sources(lf, "dateNotification")
|
||||||
|
),
|
||||||
|
dcc.Graph(
|
||||||
|
figure=get_barchart_sources(
|
||||||
|
lf, "datePublicationDonnees"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
+110
@@ -1,9 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
import polars.selectors as cs
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from polars.exceptions import ComputeError
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
operators = [
|
operators = [
|
||||||
["s<", "<"],
|
["s<", "<"],
|
||||||
["s>", ">"],
|
["s>", ">"],
|
||||||
|
["i<", "<"],
|
||||||
|
["i>", ">"],
|
||||||
["icontains", "contains"],
|
["icontains", "contains"],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
logger = logging.getLogger("decp.info")
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
|
level=logging.INFO,
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def split_filter_part(filter_part):
|
def split_filter_part(filter_part):
|
||||||
print("filter part", filter_part)
|
print("filter part", filter_part)
|
||||||
@@ -13,7 +33,97 @@ def split_filter_part(filter_part):
|
|||||||
name_part = name_part.strip()
|
name_part = name_part.strip()
|
||||||
value = value_part.strip()
|
value = value_part.strip()
|
||||||
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
||||||
|
print("=>", name, operator_group[1], value)
|
||||||
|
|
||||||
return name, operator_group[1], value
|
return name, operator_group[1], value
|
||||||
|
|
||||||
return [None] * 3
|
return [None] * 3
|
||||||
|
|
||||||
|
|
||||||
|
def add_resource_link(lff: pl.LazyFrame) -> pl.LazyFrame:
|
||||||
|
lff = lff.with_columns(
|
||||||
|
(
|
||||||
|
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
|
||||||
|
).alias("source")
|
||||||
|
)
|
||||||
|
lff = lff.drop(["sourceFile", "sourceDataset"])
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
def add_annuaire_link(lff: pl.LazyFrame):
|
||||||
|
lff = lff.with_columns(
|
||||||
|
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET")
|
||||||
|
.then(
|
||||||
|
'<a href = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
|
||||||
|
+ pl.col("titulaire_id")
|
||||||
|
+ '">'
|
||||||
|
+ pl.col("titulaire_id")
|
||||||
|
+ "</a>"
|
||||||
|
)
|
||||||
|
.otherwise(pl.col("titulaire_id"))
|
||||||
|
.alias("titulaire_id")
|
||||||
|
)
|
||||||
|
lff = lff.with_columns(
|
||||||
|
(
|
||||||
|
'<a href = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
|
||||||
|
+ pl.col("acheteur_id")
|
||||||
|
+ '">'
|
||||||
|
+ pl.col("acheteur_id")
|
||||||
|
+ "</a>"
|
||||||
|
).alias("acheteur_id")
|
||||||
|
)
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
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 format_number(number) -> str:
|
||||||
|
number = "{:,}".format(number).replace(",", " ")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def get_decp_data() -> pl.LazyFrame:
|
||||||
|
# Chargement du fichier parquet
|
||||||
|
# Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment.
|
||||||
|
# On utilise polars pour la rapidité et la facilité de manipulation des données.
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Lecture du fichier parquet ({os.getenv('DATA_FILE_PARQUET_PATH')})..."
|
||||||
|
)
|
||||||
|
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
|
except ComputeError:
|
||||||
|
# Le fichier est probablement en cours de mise à jour
|
||||||
|
logger.info("Échec, nouvelle tentative dans 10s...")
|
||||||
|
sleep(10)
|
||||||
|
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
|
|
||||||
|
# Remplacement des valeurs numériques par des chaînes de caractères
|
||||||
|
# lff = numbers_to_strings(lff)
|
||||||
|
|
||||||
|
# Tri des marchés par date de notification
|
||||||
|
lff = lff.sort(by=["datePublicationDonnees"], descending=True, nulls_last=True)
|
||||||
|
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
lf = get_decp_data()
|
||||||
|
|||||||
Reference in New Issue
Block a user