Merge branch 'feature/89_backup' into dev
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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 d’entreprise
|
||||
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
|
||||
|
+100
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 302–323) 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
File diff suppressed because it is too large
Load Diff
+2583
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
@@ -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
@@ -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.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from src.backup.cli import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user