Merge branch 'feature/89_backup' into dev

This commit is contained in:
Colin Maudry
2026-06-24 17:49:15 +02:00
48 changed files with 8814 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+140
View File
@@ -0,0 +1,140 @@
{
"version": 1,
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"**/*.py",
"**/*.go",
"**/*.rs",
"**/*.java",
"**/*.c",
"**/*.h",
"**/*.cpp",
"**/*.hpp",
"**/*.cc",
"**/*.cxx",
"**/*.cs",
"**/*.php",
"**/*.rb",
"**/*.swift",
"**/*.kt",
"**/*.kts",
"**/*.dart",
"**/*.svelte",
"**/*.liquid",
"**/*.pas",
"**/*.dpr",
"**/*.dpk",
"**/*.lpr",
"**/*.dfm",
"**/*.fmx"
],
"exclude": [
"**/.git/**",
"**/node_modules/**",
"**/vendor/**",
"**/Pods/**",
"**/dist/**",
"**/build/**",
"**/out/**",
"**/bin/**",
"**/obj/**",
"**/target/**",
"**/*.min.js",
"**/*.bundle.js",
"**/.next/**",
"**/.nuxt/**",
"**/.svelte-kit/**",
"**/.output/**",
"**/.turbo/**",
"**/.cache/**",
"**/.parcel-cache/**",
"**/.vite/**",
"**/.astro/**",
"**/.docusaurus/**",
"**/.gatsby/**",
"**/.webpack/**",
"**/.nx/**",
"**/.yarn/cache/**",
"**/.pnpm-store/**",
"**/storybook-static/**",
"**/.expo/**",
"**/web-build/**",
"**/ios/Pods/**",
"**/ios/build/**",
"**/android/build/**",
"**/android/.gradle/**",
"**/__pycache__/**",
"**/.venv/**",
"**/venv/**",
"**/site-packages/**",
"**/dist-packages/**",
"**/.pytest_cache/**",
"**/.mypy_cache/**",
"**/.ruff_cache/**",
"**/.tox/**",
"**/.nox/**",
"**/*.egg-info/**",
"**/.eggs/**",
"**/go/pkg/mod/**",
"**/target/debug/**",
"**/target/release/**",
"**/.gradle/**",
"**/.m2/**",
"**/generated-sources/**",
"**/.kotlin/**",
"**/.dart_tool/**",
"**/.vs/**",
"**/.nuget/**",
"**/artifacts/**",
"**/publish/**",
"**/cmake-build-*/**",
"**/CMakeFiles/**",
"**/bazel-*/**",
"**/vcpkg_installed/**",
"**/.conan/**",
"**/Debug/**",
"**/Release/**",
"**/x64/**",
"**/.pio/**",
"**/release/**",
"**/*.app/**",
"**/*.asar",
"**/DerivedData/**",
"**/.build/**",
"**/.swiftpm/**",
"**/xcuserdata/**",
"**/Carthage/Build/**",
"**/SourcePackages/**",
"**/__history/**",
"**/__recovery/**",
"**/*.dcu",
"**/.composer/**",
"**/storage/framework/**",
"**/bootstrap/cache/**",
"**/.bundle/**",
"**/tmp/cache/**",
"**/public/assets/**",
"**/public/packs/**",
"**/.yardoc/**",
"**/coverage/**",
"**/htmlcov/**",
"**/.nyc_output/**",
"**/test-results/**",
"**/.coverage/**",
"**/.idea/**",
"**/logs/**",
"**/tmp/**",
"**/temp/**",
"**/_build/**",
"**/docs/_build/**",
"**/site/**"
],
"languages": [],
"frameworks": [],
"maxFileSize": 1048576,
"extractDocstrings": true,
"trackCallSites": true
}
+6
View File
@@ -12,3 +12,9 @@ build
**/decp.duckdb.tmp
**/decp.duckdb.lock
**/schema.cache.json
# Runtime databases (never commit)
users.sqlite
*.sqlite
!tests/*.sqlite
!tests/**/*.sqlite
+11
View File
@@ -39,3 +39,14 @@ BREVO_TEMPLATE_VERIFY_ID= # ID numérique du template "vérification
BREVO_TEMPLATE_RESET_ID= # ID numérique du template "réinitialisation mot de passe"
MAIL_FROM=noreply@decp.info # expéditeur (doit être un expéditeur vérifié dans Brevo)
MAIL_FROM_NAME=decp.info # nom d'expéditeur
# Sauvegarde de users.sqlite vers un stockage S3-compatible
S3_ENDPOINT_URL=https://s3.exemple.net
S3_BUCKET=decp-backups
S3_BACKUP_PREFIX=backups
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# Clé Fernet : générer avec
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables.
BACKUP_ENCRYPTION_KEY=
+23
View File
@@ -96,3 +96,26 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from `
- `main` branch → manual deploy to decp.info via GitHub Actions
- `dev` branch → auto-deploy to test.decp.info via GitHub Actions
#### Sauvegarde de la base utilisateurs
`users.sqlite` est sauvegardée toutes les heures sur S3 par un timer systemd
(voir `deploy/decpinfo-backup.{service,timer}`). Installation initiale (une fois,
sur le serveur, en root) :
```bash
cp deploy/decpinfo-backup.service deploy/decpinfo-backup.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now decpinfo-backup.timer
systemctl list-timers decpinfo-backup.timer # vérifier le prochain déclenchement
```
Restauration manuelle :
```bash
cd /var/www/decpinfo && source .venv/bin/activate
python -m src.backup list
systemctl stop decpinfo
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
systemctl start decpinfo
```
+16
View File
@@ -25,6 +25,22 @@ uv run run.py
Ne pas oublier de mettre à jour les fichier .env.
### Sauvegarde de la base utilisateurs
`users.sqlite` est sauvegardée toutes les heures sur S3 via un timer systemd. Pour lister les sauvegardes disponibles :
```bash
python -m src.backup list
```
Pour restaurer une sauvegarde, arrêtez le service, restaurez la base, puis redémarrez :
```bash
systemctl stop decpinfo
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
systemctl start decpinfo
```
## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
+169
View File
@@ -0,0 +1,169 @@
Arrêté du 22 mars 2019 relatif aux fonctionnalités et exigences minimales des profils d'acheteurs
Dernière mise à jour des données de ce texte : 01 avril 2019
NOR : ECOM1831551A
JORF n°0077 du 31 mars 2019
Le ministre de l'économie et des finances et la ministre des outre-mer,
Vu le règlement n° 910/2014 du Parlement européen et du Conseil du 23 juillet 2014 sur l'identification électronique et les services de confiance pour les transactions électroniques au sein du marché intérieur et abrogeant la directive 1999/93/CE ;
Vu le règlement d'exécution (UE) n° 2016/7 de la Commission européenne du 5 janvier 2016 établissant le formulaire type pour le document unique de marché européen ;
Vu le code de la commande publique, et notamment ses articles R. 2132-2, R. 2132-3, R. 2196-1 et R. 3131-1 ;
Vu la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économique numérique ;
Vu l'ordonnance n° 2005-1516 du 8 décembre 2005 relative aux échanges électroniques entre les usagers et les autorités administratives et entre les autorités administratives, notamment ses articles 9, 10, 11 et 12,
Arrêtent :
Article 1
I. - Le profil d'acheteur permet à l'acheteur d'effectuer les actions suivantes :
1° S'identifier et s'authentifier ;
2° Publier des avis d'appel à la concurrence et leurs éventuelles modifications ;
3° Mettre à disposition des documents de la consultation ;
4° Réceptionner et conserver des candidatures y compris si elles se présentent sous la forme du document unique de marché européen électronique constituant un échange de données structurées ;
5° Réceptionner et conserver des offres, y compris hors délais ;
6° Compléter un formulaire nécessaire à la publication des données essentielles prévues par l'arrêté du 22 mars 2019 relatif aux données essentielles dans la commande publique ou importer ces données lorsqu'elles sont disponibles dans un autre système d'information ;
7° Accéder à un service de courrier électronique au sens de l'article 1 de la loi du 21 juin 2004 susvisée ;
8° Accéder à un historique des évènements permettant l'enregistrement et la traçabilité des actions ayant eu lieu sur le profil d'acheteur notamment le retrait et le dépôt de documents ;
9° Répondre aux questions soumises par les entreprises ;
10° Obtenir les documents justificatifs et moyens de preuve lorsque ceux-ci peuvent être directement obtenus auprès d'autres administrations.
II. - Le profil d'acheteurs permet à l'opérateur économique d'effectuer les actions suivantes :
1° S'identifier et s'authentifier ;
2° Connaître les prérequis techniques et les modules d'extension nécessaires pour utiliser le profil d'acheteur ;
3° Accéder à un espace permettant de tester que la configuration du poste de travail utilisé est en adéquation avec les prérequis techniques du profil d'acheteur ;
4° Effectuer une recherche permettant d'accéder notamment aux avis d'appel à la concurrence, aux consultations et aux données essentielles ;
5° Consulter et télécharger en accès gratuit, libre, direct et complet les documents de la consultation, les avis d'appel à la concurrence et leurs éventuelles modifications ;
6° Accéder à un espace permettant de simuler le dépôt de documents ;
7° Déposer une candidature y compris si elle se présente sous la forme du document unique de marché européen électronique constituant un échange de données structurées ;
8° Déposer des offres, y compris les dépôts successifs quand la procédure le requiert et les offres signées électroniquement ;
9° Solliciter une assistance ou consulter un support utilisateur permettant d'apporter des réponses aux problématiques techniques ;
10° Formuler des questions à l'acheteur ;
11° Consulter et télécharger les données essentielles conformément aux dispositions de l'arrêté du 22 mars 2019 relatif aux données essentielles dans la commande publique.
Article 2
I. - Le profil d'acheteur répond aux exigences fixées dans les référentiels généraux de sécurité, d'interopérabilité et d'accessibilité prévues aux articles 9 et 11 de l'ordonnance n° 2005-1516 du 8 décembre 2005 susvisée.
II. - Les fonctionnalités visées à l'article 1er répondent aux exigences techniques, de sécurité et d'accessibilité suivantes :
1° Le profil d'acheteur accepte les fichiers communément disponibles et notamment les fichiers aux formats .XML et .JSON ;
2° La taille et les formats des documents et avis d'appel à la concurrence sont indiqués ;
3° L'horodatage est qualifié conformément aux dispositions du règlement n° 910/2014 du Parlement européen et du Conseil du 23 juillet 2014 susvisé ;
4° Le profil d'acheteur assure l'intégrité des données ;
5° Le profil d'acheteur permet une visualisation adaptée au média utilisé ;
6° Le profil d'acheteur garantit la confidentialité des candidatures, des offres et des demandes de participation jusqu'à l'expiration du délai prévu pour leur présentation. Les documents sont inaccessibles avant cette date. A l'expiration de ce délai, ils ne sont accessibles qu'aux personnes autorisées. Le profil d'acheteur recourt à des moyens de cryptologie ou à un outil de gestion des droits d'accès et des privilèges ou à une technique équivalente ;
7° Le profil d'acheteur est interopérable avec les autres outils et dispositifs de communication électronique et d'échanges d'informations utilisés dans le cadre de la commande publique.
III. - Les dépôts, par l'opérateur économique, de documents sur le profil d'acheteur donnent immédiatement lieu à l'envoi d'un accusé de réception automatique portant les mentions suivantes :
- l'identification de l'opérateur économique auteur du dépôt ;
- le nom de l'acheteur public ;
- l'intitulé et l'objet de la consultation concernée ;
- la date et l'heure de réception des documents ;
- la liste détaillée des documents transmis.
Article 3
I. - Pour les autorités concédantes, le profil d'acheteur permet d'effectuer les actions suivantes :
1° S'identifier et s'authentifier ;
2° Mettre à disposition des documents de la consultation ;
3° Réceptionner et conserver des candidatures ;
4° Réceptionner et conserver des offres, y compris hors délais ;
5° Compléter un formulaire nécessaire à la publication des données essentielles prévues par l'arrêté du 22 mars 2019 relatif aux données essentielles dans la commande publique précité ou importer ces données lorsqu'elles sont disponibles dans un autre système d'information ;
6° Accéder à un historique des évènements permettant l'enregistrement et la traçabilité des actions ayant eu lieu sur le profil d'acheteur notamment le retrait et le dépôt de documents.
II. - Le profil d'acheteur permet à l'opérateur économique d'effectuer les actions suivantes :
1° S'identifier et s'authentifier ;
2° Connaître les prérequis techniques et les modules d'extension nécessaires pour utiliser le profil d'acheteur ;
3° Accéder à un espace permettant de tester que la configuration du poste de travail utilisé est en adéquation avec les prérequis techniques du profil d'acheteur ;
4° Effectuer une recherche permettant d'accéder notamment aux consultations et aux données essentielles ;
5° Consulter et télécharger en accès gratuit, libre, direct et complet les documents de la consultation ;
6° Accéder à un espace permettant de simuler le dépôt de documents ;
7° Déposer une candidature ;
8° Déposer des offres ;
9° Solliciter une assistance ou consulter un support utilisateur permettant d'apporter des réponses aux problématiques techniques ;
10° Consulter et télécharger les données essentielles conformément aux dispositions de l'arrêté du 22 mars 2019 relatif aux données essentielles dans la commande publique précité.
III. - Les fonctionnalités visées au I et au II de l'article 3 répondent aux exigences techniques, de sécurité et d'accessibilité suivantes :
1° Le profil d'acheteur garantit la confidentialité des candidatures, des offres et des demandes de participation jusqu'à l'expiration du délai prévu pour leur présentation. Les documents sont inaccessibles avant cette date. A l'expiration de ce délai, ils ne sont accessibles qu'aux personnes autorisées. Le profil d'acheteur recourt à des moyens de cryptologie ou à un outil de gestion des droits d'accès et des privilèges ou à une technique équivalente ;
2° Assurer l'intégrité des données ;
3° Permettre une visualisation adaptée au média utilisé ;
4° Le profil d'acheteur répond aux exigences fixées dans les référentiels généraux de sécurité, d'interopérabilité et d'accessibilité prévues aux articles 9 et 11 de l'ordonnance n° 2005-1516 du 8 décembre 2005.
IV. - Les dépôts, par l'opérateur économique, de documents sur le profil d'acheteur donnent immédiatement lieu à l'envoi d'un accusé réception automatique portant les mentions suivantes :
- l'identification de l'opérateur économique auteur du dépôt ;
- le nom de l'autorité concédante ;
- l'intitulé et l'objet de la consultation concernée ;
- la date et l'heure de réception des documents ;
- la liste détaillée des documents transmis.
Article 4
I. - Le profil d'acheteur figure sur une liste publiée sur le portail unique interministériel destiné à rassembler et à mettre à disposition librement l'ensemble des informations publiques.
II. - Chaque profil d'acheteur est identifié par :
- le SIRET de l'acheteur ;
- l'adresse URL du profil d'acheteur ;
- l'adresse URL du DCAT prévue à l'article 8 de l'arrêté du 22 mars 2019 relatif aux données essentielles dans la commande publique précité.
- les coordonnées du ou des acheteurs concernés.
III. - La déclaration du profil d'acheteur est effectuée par l'acheteur ou toute personne habilitée par celui-ci sur le portail unique interministériel destiné à rassembler et à mettre à disposition librement l'ensemble des informations publiques. La déclaration comporte l'identité du déclarant, l'identité de l'organisme chargé de la gestion du profil d'acheteur, l'adresse URL du profil d'acheteur, l'adresse URL du DCAT et les coordonnées du ou des acheteurs concernés.
Article 5
I. - Pour son application à Saint-Barthélemy et à Saint-Pierre-et-Miquelon le 3° de l'article 2 est ainsi rédigé  :
« l'horodatage est qualifié conformément aux dispositions applicables en métropole en vertu du règlement n° 910/2014 du Parlement européen et du Conseil du 23 juillet 2014 susvisé ; »
II. - Les dispositions du présent arrêté sont applicables en Nouvelle-Calédonie, en Polynésie française, dans les îles Wallis et Futuna et dans les Terres australes et antarctiques françaises pour les contrats de la commande publiques conclus par l'Etat et ses établissements publics, sous réserves des adaptations suivantes :
1° Pour son application en Nouvelle-Calédonie et en Polynésie française, le 3° de l'article 2 est ainsi rédigé  :
« l'horodatage est qualifié conformément aux dispositions applicables localement ; »
2° Pour son application les dans les îles Wallis et Futuna et dans les Terres australes et antarctiques françaises le 3° de l'article 2 est ainsi rédigé  :
« l'horodatage est qualifié conformément aux dispositions applicables en métropole en vertu du règlement n° 910/2014 du Parlement européen et du Conseil du 23 juillet 2014 susvisé. »
Article 6
Le présent arrêté constitue l'annexe 7 du code de la commande publique.
Article 7
A modifié les dispositions suivantes
Abroge Arrêté du 14 avril 2017 (Ab)
Abroge Arrêté du 14 avril 2017 - art. 1 (Ab)
Abroge Arrêté du 14 avril 2017 - art. 2 (Ab)
Abroge Arrêté du 14 avril 2017 - art. 3 (Ab)
Abroge Arrêté du 14 avril 2017 - art. 4 (Ab)
Abroge Arrêté du 14 avril 2017 - art. 5 (Ab)
Abroge Arrêté du 14 avril 2017 - art. 6 (Ab)
Article 8
Le présent arrêté entre en vigueur le 1er avril 2019.
Il s'applique aux marchés publics pour lesquels une consultation est engagée ou un avis d'appel à la concurrence est envoyé à la publication à compter de sa date d'entrée en vigueur.
Il s'applique aux contrats de concession pour lesquels une consultation est engagée ou un avis de concession est envoyé à la publication à compter de sa date d'entrée en vigueur.
Article 9
La directrice des affaires juridiques des ministères économiques et financiers et le directeur général des outre-mer sont chargés de l'exécution du présent arrêté, qui sera publié au Journal officiel de la République française.
Fait le 22 mars 2019.
Le ministre de l'économie et des finances,
Pour le ministre et par délégation :
La directrice des affaires juridiques,
L. Bedier
La ministre des outre-mer,
Pour la ministre et par délégation :
Le directeur général des outre-mer,
E. Berthier
+29
View File
@@ -0,0 +1,29 @@
# Contexte
Je souhate ajouter la possibilité pour les utilisateurs de créer un compte utilisateur. Les données des utilisateurs sont stockés dans une base de données sqlite située à la racine du projet. Les seules données demandées sont une adresse email et un mot de passe. Un lien dans le menu supérieur, tout à droite, permet d'accéder à une page qui permet soit de se connecter, soit de créer un compte.
Il s'agit des fondations des comptes utilisateurs, d'autres fonctionnalités viendront s'ajouter.
# Inscription
Données nécessaires :
- adresse email
- mot de passe
Le mot de passe est hashé en base de données.
# Connexion
- adresse email
- mot passe
Lien vers une page de réinitialisation du mot de passe.
# Page du compte
Possibilité de changer le mot de passe.
# Configuration
Ces fonctionnalités nécessitent d'envoyer des emails. Les options nécessaire à une connexion à un service SMTP sont fournies sous la forme de variables d'environnement.
+261
View File
@@ -0,0 +1,261 @@
code,libellé
0000,Organisme de placement collectif en valeurs mobilières sans personnalité morale
1000,Entrepreneur individuel
2110,Indivision entre personnes physiques
2120,Indivision avec personne morale
2210,Société créée de fait entre personnes physiques
2220,Société créée de fait avec personne morale
2310,Société en participation entre personnes physiques
2320,Société en participation avec personne morale
2385,Société en participation de professions libérales
2400,Fiducie
2700,Paroisse hors zone concordataire
2800,Assujetti unique à la TVA
2900,Autre groupement de droit privé non doté de la personnalité morale
3110,Représentation ou agence commerciale d'état ou organisme public étranger immatriculé au RCS
3120,Société commerciale étrangère immatriculée au RCS
3205,Organisation internationale
3210,"État, collectivité ou établissement public étranger"
3220,Société étrangère non immatriculée au RCS
3290,Autre personne morale de droit étranger
4110,Établissement public national à caractère industriel ou commercial doté d'un comptable public
4120,Établissement public national à caractère industriel ou commercial non doté d'un comptable public
4130,Exploitant public
4140,Établissement public local à caractère industriel ou commercial
4150,Régie d'une collectivité locale à caractère industriel ou commercial
4160,Institution Banque de France
5191,Société de caution mutuelle
5192,Société coopérative de banque populaire
5193,Caisse de crédit maritime mutuel
5194,Caisse (fédérale) de crédit mutuel
5195,Association coopérative inscrite (droit local Alsace Moselle)
5196,Caisse d'épargne et de prévoyance à forme coopérative
5202,Société en nom collectif
5203,Société en nom collectif coopérative
5306,Société en commandite simple
5307,Société en commandite simple coopérative
5308,Société en commandite par actions
5309,Société en commandite par actions coopérative
5310,Société en libre partenariat (SLP)
5370,Société de Participations Financières de Profession Libérale Société en commandite par actions (SPFPL SCA)
5385,Société d'exercice libéral en commandite par actions
5410,SARL nationale
5415,SARL d'économie mixte
5422,SARL immobilière pour le commerce et l'industrie (SICOMI)
5426,SARL immobilière de gestion
5430,SARL d'aménagement foncier et d'équipement rural (SAFER)
5431,SARL mixte d'intérêt agricole (SMIA)
5432,SARL d'intérêt collectif agricole (SICA)
5442,SARL d'attribution
5443,SARL coopérative de construction
5451,SARL coopérative de consommation
5453,SARL coopérative artisanale
5454,SARL coopérative d'intérêt maritime
5455,SARL coopérative de transport
5458,SARL coopérative de production (SCOP)
5459,SARL union de sociétés coopératives
5460,Autre SARL coopérative
5470,Société de Participations Financières de Profession Libérale Société à responsabilité limitée (SPFPL SARL)
5485,Société d'exercice libéral à responsabilité limitée
5499,Société à responsabilité limitée (sans autre indication)
5505,SA à participation ouvrière à conseil d'administration
5510,SA nationale à conseil d'administration
5515,SA d'économie mixte à conseil d'administration
5520,Fonds à forme sociétale à conseil d'administration
5522,SA immobilière pour le commerce et l'industrie (SICOMI) à conseil d'administration
5525,SA immobilière d'investissement à conseil d'administration
5530,SA d'aménagement foncier et d'équipement rural (SAFER) à conseil d'administration
5531,Société anonyme mixte d'intérêt agricole (SMIA) à conseil d'administration
5532,SA d'intérêt collectif agricole (SICA) à conseil d'administration
5542,SA d'attribution à conseil d'administration
5543,SA coopérative de construction à conseil d'administration
5546,SA de HLM à conseil d'administration
5547,SA coopérative de production de HLM à conseil d'administration
5548,SA de crédit immobilier à conseil d'administration
5551,SA coopérative de consommation à conseil d'administration
5552,SA coopérative de commerçants-détaillants à conseil d'administration
5553,SA coopérative artisanale à conseil d'administration
5554,SA coopérative (d'intérêt) maritime à conseil d'administration
5555,SA coopérative de transport à conseil d'administration
5558,SA coopérative de production (SCOP) à conseil d'administration
5559,SA union de sociétés coopératives à conseil d'administration
5560,Autre SA coopérative à conseil d'administration
5570,Société de Participations Financières de Profession Libérale Société anonyme à conseil d'administration (SPFPL SA à conseil d'administration)
5585,Société d'exercice libéral à forme anonyme à conseil d'administration
5599,SA à conseil d'administration (s.a.i.)
5605,SA à participation ouvrière à directoire
5610,SA nationale à directoire
5615,SA d'économie mixte à directoire
5620,Fonds à forme sociétale à directoire
5622,SA immobilière pour le commerce et l'industrie (SICOMI) à directoire
5625,SA immobilière d'investissement à directoire
5630,Safer anonyme à directoire
5631,SA mixte d'intérêt agricole (SMIA)
5632,SA d'intérêt collectif agricole (SICA)
5642,SA d'attribution à directoire
5643,SA coopérative de construction à directoire
5646,SA de HLM à directoire
5647,Société coopérative de production de HLM anonyme à directoire
5648,SA de crédit immobilier à directoire
5651,SA coopérative de consommation à directoire
5652,SA coopérative de commerçants-détaillants à directoire
5653,SA coopérative artisanale à directoire
5654,SA coopérative d'intérêt maritime à directoire
5655,SA coopérative de transport à directoire
5658,SA coopérative de production (SCOP) à directoire
5659,SA union de sociétés coopératives à directoire
5660,Autre SA coopérative à directoire
5670,Société de Participations Financières de Profession Libérale Société anonyme à Directoire (SPFPL SA à directoire)
5685,Société d'exercice libéral à forme anonyme à directoire
5699,SA à directoire (s.a.i.)
5710,"SAS, société par actions simplifiée"
5770,Société de Participations Financières de Profession Libérale Société par actions simplifiée (SPFPL SAS)
5785,Société d'exercice libéral par action simplifiée
5800,Société européenne
6100,Caisse d'Épargne et de Prévoyance
6210,Groupement européen d'intérêt économique (GEIE)
6220,Groupement d'intérêt économique (GIE)
6316,Coopérative d'utilisation de matériel agricole en commun (CUMA)
6317,Société coopérative agricole
6318,Union de sociétés coopératives agricoles
6411,Société d'assurance à forme mutuelle
6511,Sociétés Interprofessionnelles de Soins Ambulatoires 
6521,Société civile de placement collectif immobilier (SCPI)
6532,Société civile d'intérêt collectif agricole (SICA)
6533,Groupement agricole d'exploitation en commun (GAEC)
6534,Groupement foncier agricole
6535,Groupement agricole foncier
6536,Groupement forestier
6537,Groupement pastoral
6538,Groupement foncier et rural
6539,Société civile foncière
6540,Société civile immobilière
6541,Société civile immobilière de construction-vente
6542,Société civile d'attribution
6543,Société civile coopérative de construction
6544,Société civile immobilière d' accession progressive à la propriété
6551,Société civile coopérative de consommation
6554,Société civile coopérative d'intérêt maritime
6558,Société civile coopérative entre médecins
6560,Autre société civile coopérative
6561,SCP d'avocats
6562,SCP d'avocats aux conseils
6563,SCP d'avoués d'appel
6564,SCP d'huissiers
6565,SCP de notaires
6566,SCP de commissaires-priseurs
6567,SCP de greffiers de tribunal de commerce
6568,SCP de conseils juridiques
6569,SCP de commissaires aux comptes
6571,SCP de médecins
6572,SCP de dentistes
6573,SCP d'infirmiers
6574,SCP de masseurs-kinésithérapeutes
6575,SCP de directeurs de laboratoire d'analyse médicale
6576,SCP de vétérinaires
6577,SCP de géomètres experts
6578,SCP d'architectes
6585,Autre société civile professionnelle
6589,Société civile de moyens
6595,Caisse locale de crédit mutuel
6596,Caisse de crédit agricole mutuel
6597,Société civile d'exploitation agricole
6598,Exploitation agricole à responsabilité limitée
6599,Autre société civile
6901,Autre personne de droit privé inscrite au registre du commerce et des sociétés
7111,Autorité constitutionnelle
7112,Autorité administrative ou publique indépendante
7113,Ministère
7120,Service central d'un ministère
7150,Service du ministère de la Défense
7160,Service déconcentré à compétence nationale d'un ministère (hors Défense)
7171,Service déconcentré de l'État à compétence (inter) régionale
7172,Service déconcentré de l'État à compétence (inter) départementale
7179,(Autre) Service déconcentré de l'État à compétence territoriale
7190,Ecole nationale non dotée de la personnalité morale
7210,Commune et commune nouvelle
7220,Département
7225,Collectivité et territoire d'Outre Mer
7229,(Autre) Collectivité territoriale
7230,Région
7312,Commune associée et commune déléguée
7313,Section de commune
7314,Ensemble urbain
7321,Association syndicale autorisée
7322,Association foncière urbaine
7323,Association foncière de remembrement
7331,Établissement public local d'enseignement
7340,Pôle métropolitain
7341,Secteur de commune
7342,District urbain
7343,Communauté urbaine
7344,Métropole
7345,Syndicat intercommunal à vocation multiple (SIVOM)
7346,Communauté de communes
7347,Communauté de villes
7348,Communauté d'agglomération
7349,Autre établissement public local de coopération non spécialisé ou entente
7351,Institution interdépartementale ou entente
7352,Institution interrégionale ou entente
7353,Syndicat intercommunal à vocation unique (SIVU)
7354,Syndicat mixte fermé
7355,Syndicat mixte ouvert
7356,Commission syndicale pour la gestion des biens indivis des communes
7357,Pôle d'équilibre territorial et rural (PETR)
7361,Centre communal d'action sociale
7362,Caisse des écoles
7363,Caisse de crédit municipal
7364,Établissement d'hospitalisation
7365,Syndicat inter hospitalier
7366,Établissement public local social et médico-social
7367,Centre Intercommunal d'action sociale (CIAS)
7371,Office public d'habitation à loyer modéré (OPHLM)
7372,Service départemental d'incendie et de secours (SDIS)
7373,Établissement public local culturel
7378,Régie d'une collectivité locale à caractère administratif
7379,(Autre) Établissement public administratif local
7381,Organisme consulaire
7382,Établissement public national ayant fonction d'administration centrale
7383,Établissement public national à caractère scientifique culturel et professionnel
7384,Autre établissement public national d'enseignement
7385,Autre établissement public national administratif à compétence territoriale limitée
7389,Établissement public national à caractère administratif
7410,Groupement d'intérêt public (GIP)
7430,Établissement public des cultes d'Alsace-Lorraine
7450,"Etablissement public administratif, cercle et foyer dans les armées "
7470,Groupement de coopération sanitaire à gestion publique
7490,Autre personne morale de droit administratif
8110,Régime général de la Sécurité Sociale
8120,Régime spécial de Sécurité Sociale
8130,Institution de retraite complémentaire
8140,Mutualité sociale agricole
8150,Régime maladie des non-salariés non agricoles
8160,Régime vieillesse ne dépendant pas du régime général de la Sécurité Sociale
8170,Régime d'assurance chômage
8190,Autre régime de prévoyance sociale
8210,Mutuelle
8250,Assurance mutuelle agricole
8290,Autre organisme mutualiste
8310,Comité social économique dentreprise
8311,Comité social économique d'établissement
8410,Syndicat de salariés
8420,Syndicat patronal
8450,Ordre professionnel ou assimilé
8470,Centre technique industriel ou comité professionnel du développement économique
8490,Autre organisme professionnel
8510,Institution de prévoyance
8520,Institution de retraite supplémentaire
9110,Syndicat de copropriété
9150,Association syndicale libre
9210,Association non déclarée
9220,Association déclarée
9221,Association déclarée d'insertion par l'économique
9222,Association intermédiaire
9223,Groupement d'employeurs
9224,Association d'avocats à responsabilité professionnelle individuelle
9230,"Association déclarée, reconnue d'utilité publique"
9240,Congrégation
9260,"Association de droit local (Bas-Rhin, Haut-Rhin et Moselle)"
9300,Fondation
9900,Autre personne morale de droit privé
9970,Groupement de coopération sanitaire à gestion privée
1 code libellé
2 0000 Organisme de placement collectif en valeurs mobilières sans personnalité morale
3 1000 Entrepreneur individuel
4 2110 Indivision entre personnes physiques
5 2120 Indivision avec personne morale
6 2210 Société créée de fait entre personnes physiques
7 2220 Société créée de fait avec personne morale
8 2310 Société en participation entre personnes physiques
9 2320 Société en participation avec personne morale
10 2385 Société en participation de professions libérales
11 2400 Fiducie
12 2700 Paroisse hors zone concordataire
13 2800 Assujetti unique à la TVA
14 2900 Autre groupement de droit privé non doté de la personnalité morale
15 3110 Représentation ou agence commerciale d'état ou organisme public étranger immatriculé au RCS
16 3120 Société commerciale étrangère immatriculée au RCS
17 3205 Organisation internationale
18 3210 État, collectivité ou établissement public étranger
19 3220 Société étrangère non immatriculée au RCS
20 3290 Autre personne morale de droit étranger
21 4110 Établissement public national à caractère industriel ou commercial doté d'un comptable public
22 4120 Établissement public national à caractère industriel ou commercial non doté d'un comptable public
23 4130 Exploitant public
24 4140 Établissement public local à caractère industriel ou commercial
25 4150 Régie d'une collectivité locale à caractère industriel ou commercial
26 4160 Institution Banque de France
27 5191 Société de caution mutuelle
28 5192 Société coopérative de banque populaire
29 5193 Caisse de crédit maritime mutuel
30 5194 Caisse (fédérale) de crédit mutuel
31 5195 Association coopérative inscrite (droit local Alsace Moselle)
32 5196 Caisse d'épargne et de prévoyance à forme coopérative
33 5202 Société en nom collectif
34 5203 Société en nom collectif coopérative
35 5306 Société en commandite simple
36 5307 Société en commandite simple coopérative
37 5308 Société en commandite par actions
38 5309 Société en commandite par actions coopérative
39 5310 Société en libre partenariat (SLP)
40 5370 Société de Participations Financières de Profession Libérale Société en commandite par actions (SPFPL SCA)
41 5385 Société d'exercice libéral en commandite par actions
42 5410 SARL nationale
43 5415 SARL d'économie mixte
44 5422 SARL immobilière pour le commerce et l'industrie (SICOMI)
45 5426 SARL immobilière de gestion
46 5430 SARL d'aménagement foncier et d'équipement rural (SAFER)
47 5431 SARL mixte d'intérêt agricole (SMIA)
48 5432 SARL d'intérêt collectif agricole (SICA)
49 5442 SARL d'attribution
50 5443 SARL coopérative de construction
51 5451 SARL coopérative de consommation
52 5453 SARL coopérative artisanale
53 5454 SARL coopérative d'intérêt maritime
54 5455 SARL coopérative de transport
55 5458 SARL coopérative de production (SCOP)
56 5459 SARL union de sociétés coopératives
57 5460 Autre SARL coopérative
58 5470 Société de Participations Financières de Profession Libérale Société à responsabilité limitée (SPFPL SARL)
59 5485 Société d'exercice libéral à responsabilité limitée
60 5499 Société à responsabilité limitée (sans autre indication)
61 5505 SA à participation ouvrière à conseil d'administration
62 5510 SA nationale à conseil d'administration
63 5515 SA d'économie mixte à conseil d'administration
64 5520 Fonds à forme sociétale à conseil d'administration
65 5522 SA immobilière pour le commerce et l'industrie (SICOMI) à conseil d'administration
66 5525 SA immobilière d'investissement à conseil d'administration
67 5530 SA d'aménagement foncier et d'équipement rural (SAFER) à conseil d'administration
68 5531 Société anonyme mixte d'intérêt agricole (SMIA) à conseil d'administration
69 5532 SA d'intérêt collectif agricole (SICA) à conseil d'administration
70 5542 SA d'attribution à conseil d'administration
71 5543 SA coopérative de construction à conseil d'administration
72 5546 SA de HLM à conseil d'administration
73 5547 SA coopérative de production de HLM à conseil d'administration
74 5548 SA de crédit immobilier à conseil d'administration
75 5551 SA coopérative de consommation à conseil d'administration
76 5552 SA coopérative de commerçants-détaillants à conseil d'administration
77 5553 SA coopérative artisanale à conseil d'administration
78 5554 SA coopérative (d'intérêt) maritime à conseil d'administration
79 5555 SA coopérative de transport à conseil d'administration
80 5558 SA coopérative de production (SCOP) à conseil d'administration
81 5559 SA union de sociétés coopératives à conseil d'administration
82 5560 Autre SA coopérative à conseil d'administration
83 5570 Société de Participations Financières de Profession Libérale Société anonyme à conseil d'administration (SPFPL SA à conseil d'administration)
84 5585 Société d'exercice libéral à forme anonyme à conseil d'administration
85 5599 SA à conseil d'administration (s.a.i.)
86 5605 SA à participation ouvrière à directoire
87 5610 SA nationale à directoire
88 5615 SA d'économie mixte à directoire
89 5620 Fonds à forme sociétale à directoire
90 5622 SA immobilière pour le commerce et l'industrie (SICOMI) à directoire
91 5625 SA immobilière d'investissement à directoire
92 5630 Safer anonyme à directoire
93 5631 SA mixte d'intérêt agricole (SMIA)
94 5632 SA d'intérêt collectif agricole (SICA)
95 5642 SA d'attribution à directoire
96 5643 SA coopérative de construction à directoire
97 5646 SA de HLM à directoire
98 5647 Société coopérative de production de HLM anonyme à directoire
99 5648 SA de crédit immobilier à directoire
100 5651 SA coopérative de consommation à directoire
101 5652 SA coopérative de commerçants-détaillants à directoire
102 5653 SA coopérative artisanale à directoire
103 5654 SA coopérative d'intérêt maritime à directoire
104 5655 SA coopérative de transport à directoire
105 5658 SA coopérative de production (SCOP) à directoire
106 5659 SA union de sociétés coopératives à directoire
107 5660 Autre SA coopérative à directoire
108 5670 Société de Participations Financières de Profession Libérale Société anonyme à Directoire (SPFPL SA à directoire)
109 5685 Société d'exercice libéral à forme anonyme à directoire
110 5699 SA à directoire (s.a.i.)
111 5710 SAS, société par actions simplifiée
112 5770 Société de Participations Financières de Profession Libérale Société par actions simplifiée (SPFPL SAS)
113 5785 Société d'exercice libéral par action simplifiée
114 5800 Société européenne
115 6100 Caisse d'Épargne et de Prévoyance
116 6210 Groupement européen d'intérêt économique (GEIE)
117 6220 Groupement d'intérêt économique (GIE)
118 6316 Coopérative d'utilisation de matériel agricole en commun (CUMA)
119 6317 Société coopérative agricole
120 6318 Union de sociétés coopératives agricoles
121 6411 Société d'assurance à forme mutuelle
122 6511 Sociétés Interprofessionnelles de Soins Ambulatoires 
123 6521 Société civile de placement collectif immobilier (SCPI)
124 6532 Société civile d'intérêt collectif agricole (SICA)
125 6533 Groupement agricole d'exploitation en commun (GAEC)
126 6534 Groupement foncier agricole
127 6535 Groupement agricole foncier
128 6536 Groupement forestier
129 6537 Groupement pastoral
130 6538 Groupement foncier et rural
131 6539 Société civile foncière
132 6540 Société civile immobilière
133 6541 Société civile immobilière de construction-vente
134 6542 Société civile d'attribution
135 6543 Société civile coopérative de construction
136 6544 Société civile immobilière d' accession progressive à la propriété
137 6551 Société civile coopérative de consommation
138 6554 Société civile coopérative d'intérêt maritime
139 6558 Société civile coopérative entre médecins
140 6560 Autre société civile coopérative
141 6561 SCP d'avocats
142 6562 SCP d'avocats aux conseils
143 6563 SCP d'avoués d'appel
144 6564 SCP d'huissiers
145 6565 SCP de notaires
146 6566 SCP de commissaires-priseurs
147 6567 SCP de greffiers de tribunal de commerce
148 6568 SCP de conseils juridiques
149 6569 SCP de commissaires aux comptes
150 6571 SCP de médecins
151 6572 SCP de dentistes
152 6573 SCP d'infirmiers
153 6574 SCP de masseurs-kinésithérapeutes
154 6575 SCP de directeurs de laboratoire d'analyse médicale
155 6576 SCP de vétérinaires
156 6577 SCP de géomètres experts
157 6578 SCP d'architectes
158 6585 Autre société civile professionnelle
159 6589 Société civile de moyens
160 6595 Caisse locale de crédit mutuel
161 6596 Caisse de crédit agricole mutuel
162 6597 Société civile d'exploitation agricole
163 6598 Exploitation agricole à responsabilité limitée
164 6599 Autre société civile
165 6901 Autre personne de droit privé inscrite au registre du commerce et des sociétés
166 7111 Autorité constitutionnelle
167 7112 Autorité administrative ou publique indépendante
168 7113 Ministère
169 7120 Service central d'un ministère
170 7150 Service du ministère de la Défense
171 7160 Service déconcentré à compétence nationale d'un ministère (hors Défense)
172 7171 Service déconcentré de l'État à compétence (inter) régionale
173 7172 Service déconcentré de l'État à compétence (inter) départementale
174 7179 (Autre) Service déconcentré de l'État à compétence territoriale
175 7190 Ecole nationale non dotée de la personnalité morale
176 7210 Commune et commune nouvelle
177 7220 Département
178 7225 Collectivité et territoire d'Outre Mer
179 7229 (Autre) Collectivité territoriale
180 7230 Région
181 7312 Commune associée et commune déléguée
182 7313 Section de commune
183 7314 Ensemble urbain
184 7321 Association syndicale autorisée
185 7322 Association foncière urbaine
186 7323 Association foncière de remembrement
187 7331 Établissement public local d'enseignement
188 7340 Pôle métropolitain
189 7341 Secteur de commune
190 7342 District urbain
191 7343 Communauté urbaine
192 7344 Métropole
193 7345 Syndicat intercommunal à vocation multiple (SIVOM)
194 7346 Communauté de communes
195 7347 Communauté de villes
196 7348 Communauté d'agglomération
197 7349 Autre établissement public local de coopération non spécialisé ou entente
198 7351 Institution interdépartementale ou entente
199 7352 Institution interrégionale ou entente
200 7353 Syndicat intercommunal à vocation unique (SIVU)
201 7354 Syndicat mixte fermé
202 7355 Syndicat mixte ouvert
203 7356 Commission syndicale pour la gestion des biens indivis des communes
204 7357 Pôle d'équilibre territorial et rural (PETR)
205 7361 Centre communal d'action sociale
206 7362 Caisse des écoles
207 7363 Caisse de crédit municipal
208 7364 Établissement d'hospitalisation
209 7365 Syndicat inter hospitalier
210 7366 Établissement public local social et médico-social
211 7367 Centre Intercommunal d'action sociale (CIAS)
212 7371 Office public d'habitation à loyer modéré (OPHLM)
213 7372 Service départemental d'incendie et de secours (SDIS)
214 7373 Établissement public local culturel
215 7378 Régie d'une collectivité locale à caractère administratif
216 7379 (Autre) Établissement public administratif local
217 7381 Organisme consulaire
218 7382 Établissement public national ayant fonction d'administration centrale
219 7383 Établissement public national à caractère scientifique culturel et professionnel
220 7384 Autre établissement public national d'enseignement
221 7385 Autre établissement public national administratif à compétence territoriale limitée
222 7389 Établissement public national à caractère administratif
223 7410 Groupement d'intérêt public (GIP)
224 7430 Établissement public des cultes d'Alsace-Lorraine
225 7450 Etablissement public administratif, cercle et foyer dans les armées
226 7470 Groupement de coopération sanitaire à gestion publique
227 7490 Autre personne morale de droit administratif
228 8110 Régime général de la Sécurité Sociale
229 8120 Régime spécial de Sécurité Sociale
230 8130 Institution de retraite complémentaire
231 8140 Mutualité sociale agricole
232 8150 Régime maladie des non-salariés non agricoles
233 8160 Régime vieillesse ne dépendant pas du régime général de la Sécurité Sociale
234 8170 Régime d'assurance chômage
235 8190 Autre régime de prévoyance sociale
236 8210 Mutuelle
237 8250 Assurance mutuelle agricole
238 8290 Autre organisme mutualiste
239 8310 Comité social économique d’entreprise
240 8311 Comité social économique d'établissement
241 8410 Syndicat de salariés
242 8420 Syndicat patronal
243 8450 Ordre professionnel ou assimilé
244 8470 Centre technique industriel ou comité professionnel du développement économique
245 8490 Autre organisme professionnel
246 8510 Institution de prévoyance
247 8520 Institution de retraite supplémentaire
248 9110 Syndicat de copropriété
249 9150 Association syndicale libre
250 9210 Association non déclarée
251 9220 Association déclarée
252 9221 Association déclarée d'insertion par l'économique
253 9222 Association intermédiaire
254 9223 Groupement d'employeurs
255 9224 Association d'avocats à responsabilité professionnelle individuelle
256 9230 Association déclarée, reconnue d'utilité publique
257 9240 Congrégation
258 9260 Association de droit local (Bas-Rhin, Haut-Rhin et Moselle)
259 9300 Fondation
260 9900 Autre personne morale de droit privé
261 9970 Groupement de coopération sanitaire à gestion privée
+100
View File
@@ -0,0 +1,100 @@
File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 940, in exec_module
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
File "/var/www/decpinfo/run.py", line 1, in <module>
from src.app import app
File "/var/www/decpinfo/src/app.py", line 30, in <module>
app: Dash = Dash(
^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 639, in __init__
self.init_app()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 752, in init_app
self.enable_pages()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 2535, in enable_pages
_import_layouts_from_pages(self.config.pages_folder)
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/_pages.py", line 442, in _import_layouts_from_pages
spec.loader.exec_module(page_module) # type: ignore[reportOptionalMemberAccess]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/pages/observatoire.py", line 19, in <module>
from src.db import schema
File "/var/www/decpinfo/src/db.py", line 126, in <module>
DB_PATH = _ensure_database()
^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 119, in _ensure_database
if should_rebuild(db_path, parquet_path):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 23, in should_rebuild
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/pathlib.py", line 1014, in stat
return os.stat(self, follow_symlinks=follow_symlinks)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/srv/shared/decp/prod/dist/decp.parquet'
[2026-05-01 08:00:30 +0200] [2216551] [INFO] Worker exiting (pid: 2216551)
[2026-05-01 08:00:30 +0200] [2216552] [ERROR] Exception in worker process
Traceback (most recent call last):
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/arbiter.py", line 608, in spawn_worker
worker.init_process()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/workers/base.py", line 135, in init_process
self.load_wsgi()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/workers/base.py", line 147, in load_wsgi
self.wsgi = self.app.wsgi()
^^^^^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/base.py", line 66, in wsgi
self.callable = self.load()
^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/wsgiapp.py", line 57, in load
return self.load_wsgiapp()
^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/wsgiapp.py", line 47, in load_wsgiapp
return util.import_app(self.app_uri)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/util.py", line 370, in import_app
mod = importlib.import_module(module)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/importlib/__init__.py", line 126, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<frozen importlib._bootstrap>", line 1206, in _gcd_import
File "<frozen importlib._bootstrap>", line 1178, in _find_and_load
File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 940, in exec_module
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
File "/var/www/decpinfo/run.py", line 1, in <module>
from src.app import app
File "/var/www/decpinfo/src/app.py", line 30, in <module>
app: Dash = Dash(
^^^^^
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 639, in __init__
self.init_app()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 752, in init_app
self.enable_pages()
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 2535, in enable_pages
_import_layouts_from_pages(self.config.pages_folder)
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/_pages.py", line 442, in _import_layouts_from_pages
spec.loader.exec_module(page_module) # type: ignore[reportOptionalMemberAccess]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/pages/observatoire.py", line 19, in <module>
from src.db import schema
File "/var/www/decpinfo/src/db.py", line 126, in <module>
DB_PATH = _ensure_database()
^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 119, in _ensure_database
if should_rebuild(db_path, parquet_path):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/www/decpinfo/src/db.py", line 23, in should_rebuild
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.11/pathlib.py", line 1014, in stat
return os.stat(self, follow_symlinks=follow_symlinks)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/srv/shared/decp/prod/dist/decp.parquet'
[2026-05-01 08:00:30 +0200] [2216552] [INFO] Worker exiting (pid: 2216552)
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216549) exited with code 3
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216550) was sent SIGTERM!
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216552) was sent SIGTERM!
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216551) was sent SIGTERM!
[2026-05-01 08:00:31 +0200] [2216548] [ERROR] Shutting down: Master
[2026-05-01 08:00:31 +0200] [2216548] [ERROR] Reason: Worker failed to boot.
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Sauvegarde horaire de users.sqlite (decp.info) vers S3
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=decpinfo
WorkingDirectory=/var/www/decpinfo
EnvironmentFile=/var/www/decpinfo/.env
ExecStart=/var/www/decpinfo/.venv/bin/python -m src.backup backup
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Déclenche la sauvegarde horaire de users.sqlite (decp.info)
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,318 @@
# Observatoire localStorage Filter Persistence 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:** Persist all 10 observatoire filter values in `localStorage` so they survive navigation and page reloads; URL params (`acheteur_id` / `titulaire_id`) override stored values.
**Architecture:** Add `dcc.Store(storage_type="local", id="observatoire-filters")` to the observatoire layout. A new save callback writes all 10 filter values to the store on any change (`prevent_initial_call=True` to avoid wiping localStorage on page mount). The existing `restore_filters_from_url` callback is expanded: it now outputs all 10 filters, accepts the store as `State`, and prioritises URL params over stored values.
**Tech Stack:** Dash 3.4 `dcc.Store` with `storage_type="local"`, Dash callbacks (Input / Output / State), pytest + Selenium (`DashComposite`).
---
## File Map
| Action | File |
| ------ | --------------------------------------------------------------------- |
| Modify | `src/pages/observatoire.py` — layout + 2 callbacks |
| Modify | `tests/test_main.py` — add `test_009_observatoire_filter_persistence` |
---
### Task 1: Write a failing integration test
**Files:**
- Modify: `tests/test_main.py`
- [ ] **Step 1: Add test_009 at the end of `tests/test_main.py`**
```python
def test_009_observatoire_filter_persistence(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)
# Clear localStorage to start from a clean state
dash_duo.driver.execute_script("localStorage.clear()")
# Navigate to observatoire without URL params
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
# Set the acheteur_id text input
# dcc.Input without debounce fires the save callback on every keystroke,
# so the value is written to localStorage as soon as typing finishes.
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
dash_duo.clear_input(acheteur_input)
acheteur_input.send_keys("999000000000")
import time
time.sleep(0.3) # allow the save callback to complete
# Navigate away
dash_duo.wait_for_page(f"{dash_duo.server_url}/")
# Navigate back without URL params
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
import time
time.sleep(0.5) # allow callback chain to complete
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
assert acheteur_input.get_attribute("value") == "999000000000", (
"acheteur_id should be restored from localStorage after navigating back"
)
# Also verify URL params still override localStorage
dash_duo.wait_for_page(
f"{dash_duo.server_url}/observatoire?acheteur_id=123"
)
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
time.sleep(0.5)
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
assert acheteur_input.get_attribute("value") == "123", (
"URL param acheteur_id should override the value stored in localStorage"
)
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: **FAIL** — the input value after navigating back will be `""`, not `"999000000000"`.
- [ ] **Step 3: Commit**
```bash
git add tests/test_main.py
git commit -m "test: failing test for observatoire localStorage filter persistence #65"
```
---
### Task 2: Add `dcc.Store` to the layout and debounce the text inputs
**Files:**
- Modify: `src/pages/observatoire.py`
- [ ] **Step 1: Add the Store component to the layout**
In `src/pages/observatoire.py`, add this line immediately after the existing `dcc.Location` (line 123):
```python
dcc.Store(id="observatoire-filters", storage_type="local"),
```
So the top of the `layout` list becomes:
```python
layout = [
dcc.Location(id="dashboard_url", refresh="callback-nav"),
dcc.Store(id="observatoire-filters", storage_type="local"),
dbc.Modal(
...
```
- [ ] **Step 2: Add `debounce=True` to both text inputs**
`dcc.Input` without debounce fires on every keystroke, which would trigger the save callback (and the existing heavy `udpate_dashboard_cards` callback) on every character typed. `debounce=True` delays the callback until the user presses Enter or moves focus away.
Change `dashboard_acheteur_id` (around line 178):
```python
dcc.Input(
id="dashboard_acheteur_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
),
```
Change `dashboard_titulaire_id` (around line 209):
```python
dcc.Input(
id="dashboard_titulaire_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
),
```
- [ ] **Step 3: Run the test — still fails (restore not implemented yet)**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: still **FAIL**.
- [ ] **Step 4: Commit**
```bash
git add src/pages/observatoire.py
git commit -m "feat: add dcc.Store and debounce text inputs on observatoire page #65"
```
---
### Task 3: Add a save-to-localStorage callback
**Files:**
- Modify: `src/pages/observatoire.py`
- [ ] **Step 1: Add the save callback after the existing `restore_filters_from_url` callback**
```python
@callback(
Output("observatoire-filters", "data"),
Input("dashboard_year", "value"),
Input("dashboard_acheteur_id", "value"),
Input("dashboard_acheteur_categorie", "value"),
Input("dashboard_acheteur_departement_code", "value"),
Input("dashboard_titulaire_id", "value"),
Input("dashboard_titulaire_categorie", "value"),
Input("dashboard_titulaire_departement_code", "value"),
Input("dashboard_marche_type", "value"),
Input("dashboard_marche_considerationsSociales", "value"),
Input("dashboard_marche_considerationsEnvironnementales", "value"),
prevent_initial_call=True,
)
def save_filters_to_storage(
year,
acheteur_id,
acheteur_categorie,
acheteur_departement_code,
titulaire_id,
titulaire_categorie,
titulaire_departement_code,
marche_type,
considerations_sociales,
considerations_environnementales,
):
return {
"year": year,
"acheteur_id": acheteur_id,
"acheteur_categorie": acheteur_categorie,
"acheteur_departement_code": acheteur_departement_code,
"titulaire_id": titulaire_id,
"titulaire_categorie": titulaire_categorie,
"titulaire_departement_code": titulaire_departement_code,
"marche_type": marche_type,
"considerations_sociales": considerations_sociales,
"considerations_environnementales": considerations_environnementales,
}
```
**Why `prevent_initial_call=True`:** Without it, this callback fires on every page load with all-`None` values (the component defaults), immediately overwriting any saved filters. Setting `prevent_initial_call=True` skips that first call; subsequent user-driven changes still fire normally.
- [ ] **Step 2: Run the test — still fails (restore callback not updated yet)**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: still **FAIL**.
- [ ] **Step 3: Commit**
```bash
git add src/pages/observatoire.py
git commit -m "feat: save observatoire filters to localStorage on change #65"
```
---
### Task 4: Update the restore callback to read from localStorage
**Files:**
- Modify: `src/pages/observatoire.py`
This is the core change. Replace the existing `restore_filters_from_url` callback entirely.
- [ ] **Step 1: Replace the existing callback**
Remove the current `restore_filters_from_url` function and its `@callback` decorator (lines 302323) and replace with:
```python
@callback(
Output("dashboard_year", "value"),
Output("dashboard_acheteur_id", "value"),
Output("dashboard_acheteur_categorie", "value"),
Output("dashboard_acheteur_departement_code", "value"),
Output("dashboard_titulaire_id", "value"),
Output("dashboard_titulaire_categorie", "value"),
Output("dashboard_titulaire_departement_code", "value"),
Output("dashboard_marche_type", "value"),
Output("dashboard_marche_considerationsSociales", "value"),
Output("dashboard_marche_considerationsEnvironnementales", "value"),
Input("dashboard_url", "search"),
Input("dashboard_url", "pathname"),
State("observatoire-filters", "data"),
)
def restore_filters(search, _pathname, stored_filters):
# URL params take absolute priority: clear all other filters and apply only
# the values present in the URL (acheteur_id and/or titulaire_id).
if search:
params = urllib.parse.parse_qs(search.lstrip("?"))
acheteur_id = (params.get("acheteur_id") or [None])[0] or None
titulaire_id = (params.get("titulaire_id") or [None])[0] or None
if acheteur_id or titulaire_id:
return None, acheteur_id, None, None, titulaire_id, None, None, None, None, None
# No URL params: restore from localStorage if available.
if stored_filters:
return (
stored_filters.get("year"),
stored_filters.get("acheteur_id"),
stored_filters.get("acheteur_categorie"),
stored_filters.get("acheteur_departement_code"),
stored_filters.get("titulaire_id"),
stored_filters.get("titulaire_categorie"),
stored_filters.get("titulaire_departement_code"),
stored_filters.get("marche_type"),
stored_filters.get("considerations_sociales"),
stored_filters.get("considerations_environnementales"),
)
return (no_update,) * 10
```
**Key design decisions:**
- `Input("dashboard_url", "pathname")` added alongside `search`: guarantees the callback fires on every navigation to the observatoire page, even when `search` stays `""` (no query string) across two navigations. The `_pathname` value itself is unused in the body; it exists only as a trigger.
- `State("observatoire-filters", "data")` (not `Input`): the store triggers no re-run when the save callback writes to it, avoiding an infinite loop.
- When URL params are present, _all_ non-URL filters are cleared (`None`), which causes the save callback to overwrite localStorage with only the URL-provided values.
- `(no_update,) * 10` when there is nothing to restore: leaves defaults intact.
- [ ] **Step 2: Run the target test**
```bash
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
```
Expected: **PASS**.
- [ ] **Step 3: Run the full test suite**
```bash
pytest -v
```
Expected: all tests **PASS**. Pay special attention to `test_006` and `test_007` (URL → input / share URL), which exercise the code path that was just rewritten.
- [ ] **Step 4: Commit**
```bash
git add src/pages/observatoire.py
git commit -m "feat: restore observatoire filters from localStorage on page load #65"
```
File diff suppressed because it is too large Load Diff
+2544
View File
File diff suppressed because it is too large Load Diff
+2583
View File
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
# Spécifications fonctionnelles et techniques d'un profil d'acheteur
> Source : [Arrêté du 22 mars 2019](https://www.legifrance.gouv.fr/loda/id/JORFTEXT000038318516) relatif aux fonctionnalités et exigences minimales des profils d'acheteurs (NOR : ECOM1831551A). Annexe 7 du Code de la commande publique. En vigueur depuis le 1er avril 2019.
## 1. Fonctionnalités pour l'acheteur (marchés publics)
_Réf. : Article 1, I_
Le profil d'acheteur doit permettre à l'acheteur de :
| ID | Fonctionnalité | Description |
| ------ | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ACH-01 | Identification et authentification | S'identifier et s'authentifier sur la plateforme |
| ACH-02 | Publication d'avis | Publier des avis d'appel à la concurrence et leurs éventuelles modifications |
| ACH-03 | Mise à disposition des DCE | Mettre à disposition des documents de la consultation |
| ACH-04 | Réception des candidatures | Réceptionner et conserver des candidatures, y compris sous forme de DUME (Document Unique de Marché Européen) électronique (échange de données structurées) |
| ACH-05 | Réception des offres | Réceptionner et conserver des offres, y compris hors délais |
| ACH-06 | Données essentielles | Compléter un formulaire de publication des données essentielles (DECP), ou importer ces données depuis un autre SI |
| ACH-07 | Courrier électronique | Accéder à un service de courrier électronique (au sens de l'article 1 de la loi n° 2004-575) |
| ACH-08 | Historique et traçabilité | Accéder à un historique des événements : enregistrement et traçabilité des actions (retrait, dépôt de documents, etc.) |
| ACH-09 | Réponses aux questions | Répondre aux questions soumises par les entreprises |
| ACH-10 | Justificatifs et preuves | Obtenir les documents justificatifs et moyens de preuve directement auprès d'autres administrations lorsque c'est possible |
## 2. Fonctionnalités pour l'opérateur économique (marchés publics)
_Réf. : Article 1, II_
Le profil d'acheteur doit permettre à l'opérateur économique de :
| ID | Fonctionnalité | Description |
| ----- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OE-01 | Identification et authentification | S'identifier et s'authentifier sur la plateforme |
| OE-02 | Prérequis techniques | Connaître les prérequis techniques et les modules d'extension nécessaires pour utiliser le profil d'acheteur |
| OE-03 | Test de configuration | Accéder à un espace de test permettant de vérifier l'adéquation de la configuration du poste de travail avec les prérequis techniques |
| OE-04 | Recherche | Effectuer une recherche donnant accès aux avis d'appel à la concurrence, aux consultations et aux données essentielles |
| OE-05 | Consultation des documents | Consulter et télécharger en accès gratuit, libre, direct et complet les documents de la consultation, les avis d'appel à la concurrence et leurs modifications |
| OE-06 | Simulation de dépôt | Accéder à un espace permettant de simuler le dépôt de documents |
| OE-07 | Dépôt de candidature | Déposer une candidature, y compris sous forme de DUME électronique (données structurées) |
| OE-08 | Dépôt d'offres | Déposer des offres, y compris les dépôts successifs (quand la procédure le requiert) et les offres signées électroniquement |
| OE-09 | Assistance | Solliciter une assistance ou consulter un support utilisateur pour les problématiques techniques |
| OE-10 | Questions à l'acheteur | Formuler des questions à l'acheteur |
| OE-11 | Données essentielles | Consulter et télécharger les données essentielles de la commande publique |
## 3. Exigences techniques, de sécurité et d'accessibilité (marchés publics)
_Réf. : Article 2_
### 3.1. Conformité aux référentiels
| ID | Exigence | Référentiel |
| ------ | ---------------- | ------------------------------------------------------------------------------------------------ |
| SEC-01 | Sécurité | Conformité au Référentiel Général de Sécurité (RGS) — art. 9 de l'ordonnance n° 2005-1516 |
| SEC-02 | Interopérabilité | Conformité au Référentiel Général d'Interopérabilité (RGI) — art. 9 de l'ordonnance n° 2005-1516 |
| SEC-03 | Accessibilité | Conformité au Référentiel Général d'Accessibilité (RGAA) — art. 11 de l'ordonnance n° 2005-1516 |
### 3.2. Exigences techniques
| ID | Exigence | Description |
| ------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TECH-01 | Formats de fichiers | Accepter les fichiers communément disponibles, et notamment les formats `.XML` et `.JSON` |
| TECH-02 | Information sur les formats | Indiquer la taille et les formats des documents et avis d'appel à la concurrence |
| TECH-03 | Horodatage qualifié | L'horodatage doit être qualifié conformément au règlement eIDAS (n° 910/2014) |
| TECH-04 | Intégrité des données | Assurer l'intégrité des données |
| TECH-05 | Responsive design | Permettre une visualisation adaptée au média utilisé (responsive) |
| TECH-06 | Confidentialité des soumissions | Garantir la confidentialité des candidatures, offres et demandes de participation jusqu'à l'expiration du délai de soumission. Les documents sont inaccessibles avant cette date, puis accessibles uniquement aux personnes autorisées. Recours obligatoire à des moyens de cryptologie, de gestion des droits d'accès/privilèges, ou technique équivalente |
| TECH-07 | Interopérabilité | Être interopérable avec les autres outils et dispositifs de communication électronique et d'échanges d'informations utilisés dans le cadre de la commande publique |
### 3.3. Accusé de réception
_Réf. : Article 2, III_
Chaque dépôt de documents par un opérateur économique doit déclencher **immédiatement** l'envoi d'un accusé de réception automatique contenant :
| ID | Champ | Description |
| ----- | -------------------------- | -------------------------------------------------------- |
| AR-01 | Identification du déposant | Identification de l'opérateur économique auteur du dépôt |
| AR-02 | Nom de l'acheteur | Nom de l'acheteur public |
| AR-03 | Consultation | Intitulé et objet de la consultation concernée |
| AR-04 | Horodatage | Date et heure de réception des documents |
| AR-05 | Liste des documents | Liste détaillée des documents transmis |
## 4. Fonctionnalités spécifiques aux concessions
_Réf. : Article 3_
### 4.1. Fonctionnalités pour l'autorité concédante
| ID | Fonctionnalité | Description |
| ----------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| CONC-ACH-01 | Identification et authentification | S'identifier et s'authentifier |
| CONC-ACH-02 | Mise à disposition des DCE | Mettre à disposition des documents de la consultation |
| CONC-ACH-03 | Réception des candidatures | Réceptionner et conserver des candidatures |
| CONC-ACH-04 | Réception des offres | Réceptionner et conserver des offres, y compris hors délais |
| CONC-ACH-05 | Données essentielles | Compléter un formulaire de publication des données essentielles ou importer ces données depuis un autre SI |
| CONC-ACH-06 | Historique et traçabilité | Accéder à un historique des événements : enregistrement et traçabilité des actions (retrait, dépôt de documents, etc.) |
### 4.2. Fonctionnalités pour l'opérateur économique (concessions)
| ID | Fonctionnalité | Description |
| ---------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- |
| CONC-OE-01 | Identification et authentification | S'identifier et s'authentifier |
| CONC-OE-02 | Prérequis techniques | Connaître les prérequis techniques et modules d'extension nécessaires |
| CONC-OE-03 | Test de configuration | Accéder à un espace de test de la configuration du poste de travail |
| CONC-OE-04 | Recherche | Effectuer une recherche donnant accès aux consultations et aux données essentielles |
| CONC-OE-05 | Consultation des documents | Consulter et télécharger en accès gratuit, libre, direct et complet les documents de la consultation |
| CONC-OE-06 | Simulation de dépôt | Accéder à un espace de simulation de dépôt de documents |
| CONC-OE-07 | Dépôt de candidature | Déposer une candidature |
| CONC-OE-08 | Dépôt d'offres | Déposer des offres |
| CONC-OE-09 | Assistance | Solliciter une assistance ou consulter un support utilisateur |
| CONC-OE-10 | Données essentielles | Consulter et télécharger les données essentielles |
### 4.3. Exigences techniques (concessions)
| ID | Exigence | Description |
| ----------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CONC-SEC-01 | Confidentialité | Garantir la confidentialité des candidatures, offres et demandes de participation jusqu'à expiration du délai. Recours à la cryptologie, gestion des droits d'accès, ou technique équivalente |
| CONC-SEC-02 | Intégrité | Assurer l'intégrité des données |
| CONC-SEC-03 | Responsive design | Permettre une visualisation adaptée au média utilisé |
| CONC-SEC-04 | Conformité aux référentiels | Conformité au RGS, RGI et RGAA (art. 9 et 11 de l'ordonnance n° 2005-1516) |
### 4.4. Accusé de réception (concessions)
Mêmes exigences que pour les marchés publics (section 3.3), à l'exception du champ AR-02 qui mentionne le nom de **l'autorité concédante** au lieu de l'acheteur public.
## 5. Référencement du profil d'acheteur
_Réf. : Article 4_
### 5.1. Publication
Le profil d'acheteur doit figurer sur une liste publiée sur le portail unique interministériel de données ouvertes (data.gouv.fr).
### 5.2. Identification
Chaque profil d'acheteur est identifié par :
| ID | Champ | Description |
| ------ | ------------- | ------------------------------------------------------ |
| REF-01 | SIRET | Numéro SIRET de l'acheteur |
| REF-02 | URL du profil | Adresse URL du profil d'acheteur |
| REF-03 | URL du DCAT | Adresse URL du catalogue DCAT des données essentielles |
| REF-04 | Coordonnées | Coordonnées du ou des acheteurs concernés |
### 5.3. Déclaration
La déclaration du profil est effectuée par l'acheteur ou une personne habilitée sur le portail de données ouvertes. Elle comporte :
- L'identité du déclarant
- L'identité de l'organisme chargé de la gestion du profil d'acheteur
- L'adresse URL du profil d'acheteur
- L'adresse URL du DCAT
- Les coordonnées du ou des acheteurs concernés
## 6. Dispositions particulières outre-mer
_Réf. : Article 5_
| Territoire | Adaptation |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Saint-Barthélemy, Saint-Pierre-et-Miquelon | Horodatage qualifié conformément aux dispositions applicables en métropole en vertu du règlement eIDAS |
| Nouvelle-Calédonie, Polynésie française | Horodatage qualifié conformément aux dispositions applicables localement. Applicable uniquement aux contrats de l'État et de ses établissements publics |
| Wallis-et-Futuna, TAAF | Horodatage qualifié conformément aux dispositions applicables en métropole en vertu du règlement eIDAS. Applicable uniquement aux contrats de l'État et de ses établissements publics |
## 7. Matrice de correspondance fonctionnelle
Récapitulatif des fonctionnalités par type de contrat et rôle utilisateur :
| Fonctionnalité | Acheteur (marchés) | OE (marchés) | Autorité concédante | OE (concessions) |
| ------------------------------------------ | :----------------: | :----------: | :-----------------: | :--------------: |
| Authentification | x | x | x | x |
| Publication d'avis | x | | | |
| Mise à disposition DCE | x | | x | |
| Consultation/téléchargement DCE | | x | | x |
| Réception candidatures | x | | x | |
| Dépôt candidature (dont DUME) | | x | | x |
| Réception offres | x | | x | |
| Dépôt offres (dont signature électronique) | | x | | x |
| Données essentielles (saisie/import) | x | | x | |
| Données essentielles (consultation) | | x | | x |
| Courrier électronique | x | | | |
| Historique/traçabilité | x | | x | |
| Réponse aux questions | x | | | |
| Questions à l'acheteur | | x | | |
| Justificatifs via autres administrations | x | | | |
| Prérequis techniques | | x | | x |
| Test de configuration | | x | | x |
| Recherche | | x | | x |
| Simulation de dépôt | | x | | x |
| Assistance/support | | x | | x |
+3
View File
@@ -28,6 +28,8 @@ dependencies = [
"flask-cors>=6.0.2",
"flask-smorest>=0.46.0",
"marshmallow>=3.20.0",
"boto3",
"cryptography",
]
[dependency-groups]
@@ -40,6 +42,7 @@ dev = [
"dash[testing]",
"fastexcel",
"openpyxl",
"moto[s3]",
]
[tool.pytest.ini_options]
+197
View File
@@ -0,0 +1,197 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "c5b66458e29113e9",
"metadata": {},
"outputs": [],
"source": [
"import polars as pl\n",
"\n",
"pl.Config(\n",
" fmt_str_lengths=120,\n",
" fmt_table_cell_list_len=50,\n",
" set_tbl_rows=100,\n",
" tbl_cols=-1,\n",
")\n",
"\n",
"for methode_dividendes in (\"flat\", \"progressif\"):\n",
" df: pl.DataFrame = pl.DataFrame()\n",
"\n",
" for i in range(0, 11):\n",
" benefice_reel = 3000\n",
" ca = 50000\n",
" depenses_pro = 5000\n",
" remuneration_plus_is = ca - depenses_pro - benefice_reel\n",
" is_minimal = benefice_reel * 0.15\n",
"\n",
" salaires_brut = (remuneration_plus_is - is_minimal) * i / 10\n",
"\n",
" def taux_dividendes():\n",
" taux = {}\n",
" if methode_dividendes == \"flat\":\n",
" taux = {\"retenue_source\": 0.7, \"abattement\": 0}\n",
" elif methode_dividendes == \"progressif\":\n",
" taux = {\"retenue_source\": 0.828, \"abattement\": 0.6}\n",
" return taux\n",
"\n",
" def calcul_cout_dividendes():\n",
" return remuneration_plus_is - salaires_brut - is_minimal\n",
"\n",
" def calcul_dividendes_bruts():\n",
" return calcul_cout_dividendes() / 1.15\n",
"\n",
" def calcul_cout_remuneration():\n",
" return calcul_dividendes_bruts() * 1.15 + salaires_brut\n",
"\n",
" def calcul_dividendes_net():\n",
" dividendes_bruts = calcul_dividendes_bruts()\n",
" dividendes_net = dividendes_bruts * taux_dividendes()[\"retenue_source\"]\n",
" return dividendes_net\n",
"\n",
" def calcul_salaires_net():\n",
" return salaires_brut * 0.563\n",
"\n",
" def calcul_revenu_imposable():\n",
" return (calcul_salaires_net() * 0.9) + (\n",
" calcul_dividendes_net() * taux_dividendes()[\"abattement\"]\n",
" )\n",
"\n",
" def calcul_revenu_net():\n",
" return calcul_salaires_net() + calcul_dividendes_net()\n",
"\n",
" def calcul_benefice():\n",
" return ca - depenses_pro - salaires_brut\n",
"\n",
" def calcul_is():\n",
" return (calcul_benefice() - is_minimal) * 0.15\n",
"\n",
" def calcul_ir(revenu):\n",
" tranches_ir = [[11488, 0], [29315, 0.11], [83283, 0.3]]\n",
" revenu_restant = revenu\n",
" ir = 0\n",
" for tranche in tranches_ir:\n",
" if revenu_restant > 0:\n",
" ir_tranche = min(revenu_restant, tranche[0]) * tranche[1]\n",
" ir = +ir_tranche\n",
" revenu_restant = revenu_restant - tranche[0]\n",
"\n",
" return ir\n",
"\n",
" def calcul_revenu_ae():\n",
" apres_cotisations = calcul_cout_remuneration() * 0.76 - depenses_pro\n",
" impots = calcul_ir(apres_cotisations * 0.66)\n",
" return apres_cotisations - impots\n",
"\n",
" def fmt(value):\n",
" return str(int(value))\n",
"\n",
" # print(salaires_brut)\n",
" # print(calcul_cout_dividendes())\n",
" # print(calcul_dividendes_bruts())\n",
" # print(calcul_is())\n",
" # print(\"\")\n",
" # print(\"\")\n",
" #\n",
" # print(salaires_brut + calcul_dividendes_bruts() + calcul_is(), \" == \", remuneration_plus_is)\n",
"\n",
" # assert salaires_brut + calcul_dividendes_bruts() + calcul_is() == remuneration_plus_is\n",
"\n",
" row: dict = {\n",
" \"Salaires bruts\": [fmt(salaires_brut / 12)],\n",
" \"Dividendes bruts\": [fmt(calcul_cout_dividendes() / 12)],\n",
" \"Benefice\": [fmt(calcul_benefice())],\n",
" \"Benefice réél\": [\n",
" fmt(calcul_benefice() - calcul_is() - calcul_dividendes_bruts())\n",
" ],\n",
" \"IS\": [fmt(calcul_is())],\n",
" \"IR\": [fmt(calcul_ir(calcul_revenu_imposable()) / 12)],\n",
" \"IS + IR\": [fmt(calcul_is() + calcul_ir(calcul_revenu_imposable()))],\n",
" \"Salaires net\": [fmt(calcul_salaires_net() / 12)],\n",
" \"Dividendes net\": [fmt(calcul_dividendes_net() / 12)],\n",
" \"Revenu net ap. impôts\": [\n",
" fmt((calcul_revenu_net() - calcul_ir(calcul_revenu_imposable())) / 12)\n",
" ],\n",
" \"Revenue AE ap. impôts\": [fmt(calcul_revenu_ae() / 12)],\n",
" }\n",
"\n",
" df = pl.concat([df, pl.from_dict(row)])\n",
"\n",
" print(df)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "initial_id",
"metadata": {
"collapsed": true
},
"outputs": [],
"source": [
"from dash import ALL, Dash, Input, Output, Patch, State, callback, dcc, html\n",
"\n",
"app = Dash()\n",
"\n",
"app.layout = html.Div(\n",
" [\n",
" html.Button(\"Add Filter\", id=\"add-filter-btn\", n_clicks=0),\n",
" html.Div(id=\"dropdown-container-div\", children=[]),\n",
" html.Div(id=\"dropdown-container-output-div\"),\n",
" ]\n",
")\n",
"\n",
"\n",
"@callback(\n",
" Output(\"dropdown-container-div\", \"children\"), Input(\"add-filter-btn\", \"n_clicks\")\n",
")\n",
"def display_dropdowns(n_clicks):\n",
" patched_children = Patch()\n",
" new_dropdown = dcc.Dropdown(\n",
" [\"NYC\", \"MTL\", \"LA\", \"TOKYO\"],\n",
" id={\"type\": \"city-filter-dropdown\", \"index\": n_clicks},\n",
" )\n",
" patched_children.append(new_dropdown)\n",
" return patched_children\n",
"\n",
"\n",
"@callback(\n",
" Output(\"dropdown-container-output-div\", \"children\"),\n",
" Input({\"type\": \"city-filter-dropdown\", \"index\": ALL}, \"value\"),\n",
" State({\"type\": \"city-dynamic-dropdown\", \"index\": ALL}, \"id\"),\n",
")\n",
"def display_output(values, ids):\n",
" return html.Div(\n",
" [html.Div(f\"Dropdown {i + 1} = {value}\") for (i, value) in enumerate(values)]\n",
" + [html.P(ids)],\n",
" )\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" app.run(debug=True)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 2
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython2",
"version": "2.7.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.
Binary file not shown.
View File
+5
View File
@@ -0,0 +1,5 @@
import sys
from src.backup.cli import main
sys.exit(main())
+58
View File
@@ -0,0 +1,58 @@
import argparse
import logging
import os
import sys
from datetime import datetime, timezone
from src.backup import service
from src.backup.config import load_config
from src.backup.storage import S3Storage
logger = logging.getLogger(__name__)
def main(argv=None, env=None, storage=None) -> int:
env = env if env is not None else os.environ
parser = argparse.ArgumentParser(prog="python -m src.backup")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("backup", help="Créer une sauvegarde et appliquer la rotation")
sub.add_parser("list", help="Lister les sauvegardes disponibles")
p_restore = sub.add_parser("restore", help="Restaurer une sauvegarde")
p_restore.add_argument("key", help="Clé S3 de la sauvegarde à restaurer")
args = parser.parse_args(argv)
config = load_config(env)
if storage is None:
storage = S3Storage.from_config(config)
now = datetime.now(timezone.utc)
if args.cmd == "backup":
try:
key = service.run_backup(config, storage, now)
print(f"sauvegarde créée : {key}")
return 0
except Exception as exc:
logger.error("Échec de la sauvegarde : %s", exc, exc_info=True)
print(f"Erreur : {exc}", file=sys.stderr)
return 1
if args.cmd == "list":
backups = service.list_backups(config, storage)
if not backups:
print("(aucune sauvegarde)")
return 0
for key, ts in backups:
print(f"{ts.isoformat()} {key}")
return 0
if args.cmd == "restore":
print("⚠ Arrêtez d'abord le service : systemctl stop decpinfo")
backup_copy = service.restore(config, storage, args.key, now)
print(f"restauré depuis : {args.key}")
print(f"copie de secours de l'ancienne base : {backup_copy}")
print("Redémarrez ensuite le service : systemctl start decpinfo")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+26
View File
@@ -0,0 +1,26 @@
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class BackupConfig:
db_path: Path
bucket: str
prefix: str
endpoint_url: str | None
access_key: str
secret_key: str
encryption_key: str
def load_config(env: Mapping[str, str]) -> BackupConfig:
return BackupConfig(
db_path=Path(env["USERS_DB_PATH"]),
bucket=env["S3_BUCKET"],
prefix=env.get("S3_BACKUP_PREFIX", "backups"),
endpoint_url=env.get("S3_ENDPOINT_URL") or None,
access_key=env["S3_ACCESS_KEY_ID"],
secret_key=env["S3_SECRET_ACCESS_KEY"],
encryption_key=env["BACKUP_ENCRYPTION_KEY"],
)
+9
View File
@@ -0,0 +1,9 @@
from cryptography.fernet import Fernet
def encrypt(data: bytes, key: str) -> bytes:
return Fernet(key.encode()).encrypt(data)
def decrypt(token: bytes, key: str) -> bytes:
return Fernet(key.encode()).decrypt(token)
+17
View File
@@ -0,0 +1,17 @@
import re
from datetime import datetime, timezone
_TS_FMT = "%Y%m%dT%H%M%SZ"
_KEY_RE = re.compile(r"users-(\d{8}T\d{6}Z)\.sqlite\.gz\.enc$")
def make_key(prefix: str, ts: datetime) -> str:
stamp = ts.astimezone(timezone.utc).strftime(_TS_FMT)
return f"{prefix.rstrip('/')}/users-{stamp}.sqlite.gz.enc"
def parse_timestamp(key: str) -> datetime:
match = _KEY_RE.search(key)
if not match:
raise ValueError(f"clé non reconnue : {key}")
return datetime.strptime(match.group(1), _TS_FMT).replace(tzinfo=timezone.utc)
+49
View File
@@ -0,0 +1,49 @@
from collections.abc import Iterable
from datetime import datetime, timedelta
# (période, horizon) pour les paliers à période fixe
_FIXED_TIERS = (
(timedelta(hours=1), timedelta(hours=12)), # horaire / 12 h
(timedelta(hours=12), timedelta(hours=72)), # 12 h / 72 h
(timedelta(days=1), timedelta(days=21)), # quotidien / 21 j
)
_MONTHLY_HORIZON = 12 # mois calendaires
def _select_fixed(timestamps, now, period, horizon):
cutoff = now - horizon
buckets: dict[int, datetime] = {}
for t in timestamps:
if t > now or t < cutoff:
continue
idx = int((now - t) // period)
if idx not in buckets or t > buckets[idx]:
buckets[idx] = t
return set(buckets.values())
def _month_index(d: datetime) -> int:
return d.year * 12 + (d.month - 1)
def _select_monthly(timestamps, now):
now_idx = _month_index(now)
buckets: dict[int, datetime] = {}
for t in timestamps:
if t > now:
continue
idx = _month_index(t)
if idx > now_idx or now_idx - idx >= _MONTHLY_HORIZON:
continue
if idx not in buckets or t > buckets[idx]:
buckets[idx] = t
return set(buckets.values())
def select_retained(timestamps: Iterable[datetime], now: datetime) -> set[datetime]:
items = list(timestamps)
retained: set[datetime] = set()
for period, horizon in _FIXED_TIERS:
retained |= _select_fixed(items, now, period, horizon)
retained |= _select_monthly(items, now)
return retained
+67
View File
@@ -0,0 +1,67 @@
import logging
import os
import tempfile
from datetime import datetime
from pathlib import Path
from src.backup import crypto, naming, snapshot
from src.backup.config import BackupConfig
from src.backup.rotation import select_retained
from src.backup.storage import Storage
logger = logging.getLogger(__name__)
def _dated_keys(storage: Storage, prefix: str) -> list[tuple[str, datetime]]:
dated: list[tuple[str, datetime]] = []
for key in storage.list_keys(prefix):
try:
dated.append((key, naming.parse_timestamp(key)))
except ValueError:
continue
return dated
def run_backup(config: BackupConfig, storage: Storage, now: datetime) -> str:
data = snapshot.make_snapshot(config.db_path)
encrypted = crypto.encrypt(data, config.encryption_key)
key = naming.make_key(config.prefix, now)
storage.upload_bytes(key, encrypted)
dated = _dated_keys(storage, config.prefix)
retained = select_retained((ts for _, ts in dated), now)
for k, ts in dated:
if ts not in retained:
storage.delete(k)
logger.info("Sauvegarde créée : %s", key)
return key
def list_backups(config: BackupConfig, storage: Storage) -> list[tuple[str, datetime]]:
dated = _dated_keys(storage, config.prefix)
dated.sort(key=lambda item: item[1])
return dated
def restore(config: BackupConfig, storage: Storage, key: str, now: datetime) -> Path:
encrypted = storage.download_bytes(key)
data = crypto.decrypt(encrypted, config.encryption_key)
with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
snapshot.write_snapshot(data, tmp_path)
if not snapshot.verify_integrity(tmp_path):
raise ValueError(f"Sauvegarde corrompue, restauration annulée : {key}")
db_path = config.db_path
stamp = now.strftime("%Y%m%dT%H%M%SZ")
backup_copy = db_path.with_name(f"{db_path.name}.bak-{stamp}")
if db_path.exists():
backup_copy.write_bytes(db_path.read_bytes())
os.replace(tmp_path, db_path)
except Exception:
logger.error("Échec de la restauration depuis %s", key, exc_info=True)
tmp_path.unlink(missing_ok=True)
raise
return backup_copy
+42
View File
@@ -0,0 +1,42 @@
import gzip
import sqlite3
import tempfile
from pathlib import Path
def make_snapshot(db_path: Path) -> bytes:
if not db_path.exists():
raise FileNotFoundError(f"Base de données introuvable : {db_path}")
with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
src = sqlite3.connect(str(db_path))
try:
dst = sqlite3.connect(str(tmp_path))
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
return gzip.compress(tmp_path.read_bytes())
finally:
tmp_path.unlink(missing_ok=True)
def write_snapshot(gzipped: bytes, dest: Path) -> None:
dest.write_bytes(gzip.decompress(gzipped))
def verify_integrity(db_path: Path) -> bool:
try:
conn = sqlite3.connect(str(db_path))
except sqlite3.DatabaseError:
return False
try:
row = conn.execute("PRAGMA integrity_check").fetchone()
return row is not None and row[0] == "ok"
except sqlite3.DatabaseError:
return False
finally:
conn.close()
+58
View File
@@ -0,0 +1,58 @@
from typing import Protocol
import boto3
from src.backup.config import BackupConfig
class Storage(Protocol):
def upload_bytes(self, key: str, data: bytes) -> None: ...
def download_bytes(self, key: str) -> bytes: ...
def list_keys(self, prefix: str) -> list[str]: ...
def delete(self, key: str) -> None: ...
class S3Storage:
def __init__(
self,
bucket: str,
endpoint_url: str | None,
access_key: str,
secret_key: str,
region: str = "us-east-1",
) -> None:
self._bucket = bucket
self._client = boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
)
@classmethod
def from_config(cls, config: BackupConfig) -> "S3Storage":
return cls(
bucket=config.bucket,
endpoint_url=config.endpoint_url,
access_key=config.access_key,
secret_key=config.secret_key,
)
def upload_bytes(self, key: str, data: bytes) -> None:
self._client.put_object(Bucket=self._bucket, Key=key, Body=data)
def download_bytes(self, key: str) -> bytes:
resp = self._client.get_object(Bucket=self._bucket, Key=key)
return resp["Body"].read()
def list_keys(self, prefix: str) -> list[str]:
keys: list[str] = []
paginator = self._client.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
for obj in page.get("Contents", []):
keys.append(obj["Key"])
return keys
def delete(self, key: str) -> None:
self._client.delete_object(Bucket=self._bucket, Key=key)
+71
View File
@@ -0,0 +1,71 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "initial_id",
"metadata": {
"collapsed": true
},
"outputs": [],
"source": [
"import duckdb\n",
"\n",
"conn = duckdb.connect(\"tests/\")\n",
"cursor = conn.cursor()\n",
"df = cursor.execute(\"select * from decp limit 1\").pl()\n",
"df.columns"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "859cd599b5befafb",
"metadata": {
"ExecuteTime": {
"end_time": "2026-04-22T15:00:32.430249Z",
"start_time": "2026-04-22T15:00:31.843470Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import polars as pl\n",
"\n",
"schema = pl.read_parquet(\"../decp-processing/decp_prod.parquet\").schema\n",
"schema[\"dureeMois\"].is_integer()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 2
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython2",
"version": "2.7.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+26
View File
@@ -0,0 +1,26 @@
import dash_ag_grid as dag
import pandas as pd
from dash import Dash, html
df = pd.read_csv(
"https://raw.githubusercontent.com/plotly/datasets/master/gapminder2007.csv"
)
app = Dash()
columnDefs = [
{"field": "country", "filter": True},
{"field": "pop", "headerName": "Population"},
{"field": "lifeExp", "headerName": "Life Expectancy", "filter": True},
]
grid = dag.AgGrid(
id="getting-started-filter",
rowData=df.to_dict("records"),
columnDefs=columnDefs,
)
app.layout = html.Div([grid])
if __name__ == "__main__":
app.run(debug=True)
View File
+15
View File
@@ -0,0 +1,15 @@
class FakeStorage:
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
def upload_bytes(self, key: str, data: bytes) -> None:
self.objects[key] = data
def download_bytes(self, key: str) -> bytes:
return self.objects[key]
def list_keys(self, prefix: str) -> list[str]:
return [k for k in self.objects if k.startswith(prefix)]
def delete(self, key: str) -> None:
self.objects.pop(key, None)
+46
View File
@@ -0,0 +1,46 @@
import sqlite3
from pathlib import Path
from cryptography.fernet import Fernet
from src.backup import cli
from tests.backup.fakes import FakeStorage
def _env(tmp_path: Path) -> dict:
db = tmp_path / "users.sqlite"
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE t (val TEXT)")
conn.execute("INSERT INTO t VALUES ('v1')")
conn.commit()
conn.close()
return {
"USERS_DB_PATH": str(db),
"S3_BUCKET": "b",
"S3_BACKUP_PREFIX": "backups",
"S3_ENDPOINT_URL": "",
"S3_ACCESS_KEY_ID": "AK",
"S3_SECRET_ACCESS_KEY": "SK",
"BACKUP_ENCRYPTION_KEY": Fernet.generate_key().decode(),
}
def test_backup_then_list(tmp_path, capsys):
env = _env(tmp_path)
storage = FakeStorage()
assert cli.main(["backup"], env=env, storage=storage) == 0
assert cli.main(["list"], env=env, storage=storage) == 0
out = capsys.readouterr().out
assert "users-" in out
assert len(storage.objects) == 1
def test_restore(tmp_path):
env = _env(tmp_path)
storage = FakeStorage()
# Create a backup first
assert cli.main(["backup"], env=env, storage=storage) == 0
key = next(iter(storage.objects))
# Restore it
ret = cli.main(["restore", key], env=env, storage=storage)
assert ret == 0
+39
View File
@@ -0,0 +1,39 @@
from pathlib import Path
from src.backup.config import BackupConfig, load_config
def test_load_config_reads_env():
env = {
"USERS_DB_PATH": "users.sqlite",
"S3_BUCKET": "decp-backups",
"S3_BACKUP_PREFIX": "backups",
"S3_ENDPOINT_URL": "https://s3.example.net",
"S3_ACCESS_KEY_ID": "AK",
"S3_SECRET_ACCESS_KEY": "SK",
"BACKUP_ENCRYPTION_KEY": "key123",
}
cfg = load_config(env)
assert cfg == BackupConfig(
db_path=Path("users.sqlite"),
bucket="decp-backups",
prefix="backups",
endpoint_url="https://s3.example.net",
access_key="AK",
secret_key="SK",
encryption_key="key123",
)
def test_load_config_defaults_prefix_and_empty_endpoint():
env = {
"USERS_DB_PATH": "users.sqlite",
"S3_BUCKET": "b",
"S3_ENDPOINT_URL": "",
"S3_ACCESS_KEY_ID": "AK",
"S3_SECRET_ACCESS_KEY": "SK",
"BACKUP_ENCRYPTION_KEY": "k",
}
cfg = load_config(env)
assert cfg.prefix == "backups"
assert cfg.endpoint_url is None
+17
View File
@@ -0,0 +1,17 @@
import pytest
from cryptography.fernet import Fernet
from src.backup.crypto import decrypt, encrypt
def test_roundtrip():
key = Fernet.generate_key().decode()
data = b"contenu de la base sqlite"
assert decrypt(encrypt(data, key), key) == data
def test_wrong_key_fails():
data = b"secret"
token = encrypt(data, Fernet.generate_key().decode())
with pytest.raises(Exception):
decrypt(token, Fernet.generate_key().decode())
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime, timezone
import pytest
from src.backup.naming import make_key, parse_timestamp
def test_make_key_format():
ts = datetime(2026, 6, 24, 14, 5, 9, tzinfo=timezone.utc)
assert make_key("backups", ts) == "backups/users-20260624T140509Z.sqlite.gz.enc"
def test_make_key_strips_trailing_slash():
ts = datetime(2026, 6, 24, 14, 5, 9, tzinfo=timezone.utc)
assert make_key("backups/", ts).startswith("backups/users-")
def test_roundtrip():
ts = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
assert parse_timestamp(make_key("p", ts)) == ts
def test_parse_rejects_unknown_key():
with pytest.raises(ValueError):
parse_timestamp("backups/users.sqlite.bak")
+52
View File
@@ -0,0 +1,52 @@
from datetime import datetime, timedelta, timezone
from src.backup.rotation import select_retained
NOW = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc)
def test_most_recent_always_kept():
ts = [NOW, NOW - timedelta(hours=2)]
assert NOW in select_retained(ts, NOW)
def test_recent_hours_kept_by_hourly_tier():
t = NOW - timedelta(hours=3)
assert t in select_retained([t], NOW)
def test_two_backups_same_hour_keep_newest():
older = NOW - timedelta(hours=2, minutes=50)
newer = NOW - timedelta(hours=2, minutes=10)
retained = select_retained([older, newer], NOW)
assert newer in retained
assert older not in retained
def test_daily_tier_keeps_old_daily_backup():
t = NOW - timedelta(days=10)
assert t in select_retained([t], NOW)
def test_monthly_tier_keeps_one_per_calendar_month():
older = NOW - timedelta(days=40) # mois M-2 ou M-1 selon calendrier
same_month_newer = older + timedelta(days=2)
retained = select_retained([older, same_month_newer], NOW)
# même mois calendaire -> seul le plus récent du mois est conservé
assert same_month_newer in retained
assert older not in retained
def test_keeps_backup_within_12_months():
t = NOW - timedelta(days=300)
assert t in select_retained([t], NOW)
def test_drops_backup_older_than_12_months():
t = NOW - timedelta(days=400)
assert t not in select_retained([t], NOW)
def test_future_timestamps_ignored():
t = NOW + timedelta(hours=1)
assert t not in select_retained([t], NOW)
+105
View File
@@ -0,0 +1,105 @@
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from src.backup import service
from src.backup.config import BackupConfig
from src.backup.naming import make_key
from tests.backup.fakes import FakeStorage
NOW = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc)
def _make_db(path: Path, val: str):
conn = sqlite3.connect(str(path))
conn.execute("CREATE TABLE t (val TEXT)")
conn.execute("INSERT INTO t VALUES (?)", (val,))
conn.commit()
conn.close()
def _config(db_path: Path) -> BackupConfig:
return BackupConfig(
db_path=db_path,
bucket="b",
prefix="backups",
endpoint_url=None,
access_key="AK",
secret_key="SK",
encryption_key=Fernet.generate_key().decode(),
)
def test_run_backup_uploads_key(tmp_path):
db = tmp_path / "users.sqlite"
_make_db(db, "v1")
storage = FakeStorage()
key = service.run_backup(_config(db), storage, NOW)
assert key in storage.objects
assert key == "backups/users-20260624T120000Z.sqlite.gz.enc"
def test_run_backup_applies_rotation(tmp_path):
db = tmp_path / "users.sqlite"
_make_db(db, "v1")
cfg = _config(db)
storage = FakeStorage()
# une vieille sauvegarde de >12 mois doit être supprimée par la rotation
stale = make_key(cfg.prefix, NOW - timedelta(days=400))
storage.objects[stale] = b"old"
service.run_backup(cfg, storage, NOW)
assert stale not in storage.objects
def test_list_backups_sorted(tmp_path):
db = tmp_path / "users.sqlite"
_make_db(db, "v1")
cfg = _config(db)
storage = FakeStorage()
storage.objects[make_key(cfg.prefix, NOW)] = b"x"
storage.objects[make_key(cfg.prefix, NOW - timedelta(hours=1))] = b"x"
storage.objects["backups/garbage.txt"] = b"x" # ignoré
result = service.list_backups(cfg, storage)
dates = [ts for _, ts in result]
assert dates == sorted(dates)
assert len(result) == 2
def test_restore_replaces_db_and_keeps_safety_copy(tmp_path):
db = tmp_path / "users.sqlite"
_make_db(db, "original")
cfg = _config(db)
storage = FakeStorage()
key = service.run_backup(cfg, storage, NOW) # sauvegarde "original"
# la base change ensuite
db.unlink()
_make_db(db, "modifie")
backup_copy = service.restore(cfg, storage, key, NOW)
conn = sqlite3.connect(str(db))
val = conn.execute("SELECT val FROM t").fetchone()[0]
conn.close()
assert val == "original"
assert backup_copy.exists() # copie de secours de la base "modifie"
def test_restore_rejects_corrupt_backup(tmp_path):
db = tmp_path / "users.sqlite"
_make_db(db, "original")
cfg = _config(db)
storage = FakeStorage()
from src.backup import crypto
bad_key = make_key(cfg.prefix, NOW)
import gzip
storage.objects[bad_key] = crypto.encrypt(
gzip.compress(b"not sqlite"), cfg.encryption_key
)
with pytest.raises(ValueError):
service.restore(cfg, storage, bad_key, NOW)
+36
View File
@@ -0,0 +1,36 @@
import sqlite3
from pathlib import Path
from src.backup.snapshot import make_snapshot, verify_integrity, write_snapshot
def _make_db(path: Path):
conn = sqlite3.connect(str(path))
conn.execute("CREATE TABLE t (id INTEGER, val TEXT)")
conn.execute("INSERT INTO t VALUES (1, 'a'), (2, 'b')")
conn.commit()
conn.close()
def test_snapshot_roundtrip_preserves_rows(tmp_path):
src = tmp_path / "users.sqlite"
_make_db(src)
gzipped = make_snapshot(src)
restored = tmp_path / "restored.sqlite"
write_snapshot(gzipped, restored)
conn = sqlite3.connect(str(restored))
rows = conn.execute("SELECT id, val FROM t ORDER BY id").fetchall()
conn.close()
assert rows == [(1, "a"), (2, "b")]
def test_verify_integrity_ok(tmp_path):
src = tmp_path / "users.sqlite"
_make_db(src)
assert verify_integrity(src) is True
def test_verify_integrity_detects_corruption(tmp_path):
bad = tmp_path / "bad.sqlite"
bad.write_bytes(b"this is not a sqlite database")
assert verify_integrity(bad) is False
+31
View File
@@ -0,0 +1,31 @@
import boto3
import pytest
from moto import mock_aws
from src.backup.storage import S3Storage
BUCKET = "decp-backups"
@pytest.fixture
def s3_storage():
with mock_aws():
boto3.client("s3", region_name="us-east-1").create_bucket(Bucket=BUCKET)
yield S3Storage(
bucket=BUCKET,
endpoint_url=None,
access_key="testing",
secret_key="testing",
)
def test_upload_list_download_delete(s3_storage):
s3_storage.upload_bytes("backups/a.enc", b"alpha")
s3_storage.upload_bytes("backups/b.enc", b"beta")
s3_storage.upload_bytes("autre/c.enc", b"gamma")
assert set(s3_storage.list_keys("backups/")) == {"backups/a.enc", "backups/b.enc"}
assert s3_storage.download_bytes("backups/a.enc") == b"alpha"
s3_storage.delete("backups/a.enc")
assert s3_storage.list_keys("backups/") == ["backups/b.enc"]
Binary file not shown.
Binary file not shown.
Generated
+111
View File
@@ -215,6 +215,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "boto3"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/9f/897287e955db0f50b12fd69ef45956e4fd2c7ddb48c736872f7ea2314443/boto3-1.43.36.tar.gz", hash = "sha256:587d7ee92a12e440ad12b0e7f11f3358f0c4d65b19f64726efc94aaf194aff28", size = 112690, upload-time = "2026-06-23T02:47:14.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/f1/274303f52483ecf199eae6f8d9b6f5951670397ee4d72c06cfd4eb644612/boto3-1.43.36-py3-none-any.whl", hash = "sha256:42942dde254673abcbc9e6e60017c88341a4f49d99d24e1f2e290fb38138c26f", size = 140031, upload-time = "2026-06-23T02:47:13.178Z" },
]
[[package]]
name = "botocore"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/37/da9e7f6ca73ac73afd7f0bb7f238aa5daba35c081e98d7f48a7c399599c0/botocore-1.43.36.tar.gz", hash = "sha256:4cae47d1b2d426316b85a0087d9e69e048f13bc003b5177d74639fe9dfd28205", size = 15625488, upload-time = "2026-06-23T02:47:03.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/19/934f81592527a3f7f9b943c893e334c721a4644948642bc33885d584e9ec/botocore-1.43.36-py3-none-any.whl", hash = "sha256:3c65fdc39ed01d8dfde1e961b34038aed03c459f8ddf80717a12ac006475e49d", size = 15313630, upload-time = "2026-06-23T02:46:59.327Z" },
]
[[package]]
name = "brevo-python"
version = "5.0.0rc1"
@@ -826,7 +854,9 @@ name = "decp-info"
version = "2.8.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
{ name = "brevo-python" },
{ name = "cryptography" },
{ name = "dash", extra = ["compress"] },
{ name = "dash-bootstrap-components" },
{ name = "dash-extensions", version = "1.0.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
@@ -857,6 +887,7 @@ dependencies = [
dev = [
{ name = "dash", extra = ["testing"] },
{ name = "fastexcel" },
{ name = "moto", extra = ["s3"] },
{ name = "openpyxl" },
{ name = "pre-commit" },
{ name = "pytest" },
@@ -867,7 +898,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "boto3" },
{ name = "brevo-python", specifier = "==5.0.0rc1" },
{ name = "cryptography" },
{ name = "dash", specifier = "==3.4.0" },
{ name = "dash", extras = ["compress"] },
{ name = "dash-bootstrap-components" },
@@ -896,6 +929,7 @@ requires-dist = [
dev = [
{ name = "dash", extras = ["testing"] },
{ name = "fastexcel" },
{ name = "moto", extras = ["s3"] },
{ name = "openpyxl" },
{ name = "pre-commit" },
{ name = "pytest" },
@@ -1273,6 +1307,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "jsbeautifier"
version = "1.15.4"
@@ -1566,6 +1609,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "moto"
version = "5.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
{ name = "botocore" },
{ name = "cryptography" },
{ name = "requests" },
{ name = "responses" },
{ name = "werkzeug" },
{ name = "xmltodict" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/63/d944f387582cc53f53febbff2b3fa36a6d2ed7c1feef8990bf646cfa9cba/moto-5.2.2.tar.gz", hash = "sha256:aac8023a429e125e91c91f8f4730a67b54f518cda587352f7e67252fe3168f75", size = 8678761, upload-time = "2026-06-06T18:57:54.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/45/13cff46f4f617a6e97e1d497d75abd913e250bb4c823a4985668c6e593e4/moto-5.2.2-py3-none-any.whl", hash = "sha256:3817f1e39721ca833579b921e53e3b68547ace6a34d848c9486fbb5905808de9", size = 6698689, upload-time = "2026-06-06T18:57:51.435Z" },
]
[package.optional-dependencies]
s3 = [
{ name = "py-partiql-parser" },
{ name = "pyyaml" },
]
[[package]]
name = "multiprocess"
version = "0.70.19"
@@ -2136,6 +2203,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "py-partiql-parser"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/7a/a0f6bda783eb4df8e3dfd55973a1ac6d368a89178c300e1b5b91cd181e5e/py_partiql_parser-0.6.3.tar.gz", hash = "sha256:09cecf916ce6e3da2c050f0cb6106166de42c33d34a078ec2eb19377ea70389a", size = 17456, upload-time = "2025-10-18T13:56:13.441Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl", hash = "sha256:deb0769c3346179d2f590dcbde556f708cdb929059fb654bad75f4cf6e07f582", size = 23752, upload-time = "2025-10-18T13:56:12.256Z" },
]
[[package]]
name = "pyarrow"
version = "24.0.0"
@@ -2518,6 +2594,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "responses"
version = "0.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/58/1fb6de3503428196df78638f991ec8095274f1ee9723e272ee4d9ff0092b/responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2", size = 83088, upload-time = "2026-05-21T19:56:39.747Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" },
]
[[package]]
name = "retrying"
version = "1.4.2"
@@ -2527,6 +2617,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" },
]
[[package]]
name = "s3transfer"
version = "0.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/94/dcdaeb1713cab9c84def276cfac7388b17c7d9855bbcfe88d77e4dbafd44/s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685", size = 165171, upload-time = "2026-06-16T19:44:51.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/5f/4c174edad94f82de888ac00a5ddd8d07b35609b6c94f0bdf4d74af57703e/s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262", size = 90101, upload-time = "2026-06-16T19:44:50.439Z" },
]
[[package]]
name = "selenium"
version = "4.2.0"
@@ -2838,6 +2940,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
]
[[package]]
name = "xmltodict"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
]
[[package]]
name = "zipp"
version = "4.1.0"