Compare commits
337 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f36022443 | |||
| 0bfac680a5 | |||
| e59e2e50b3 | |||
| c81533aa8e | |||
| 055b0edc7e | |||
| 8efd8db07e | |||
| fbea2ebd53 | |||
| 436906911b | |||
| 4c1c1cc987 | |||
| 97e1d0b5de | |||
| 2ce52d9eab | |||
| a7662a8561 | |||
| 33ea1205cf | |||
| 979874760d | |||
| 68f615370f | |||
| 7454d0db32 | |||
| 74c5de0bda | |||
| 91d3153e10 | |||
| 41236df0fe | |||
| d69ae9b8de | |||
| 24bc1c08c1 | |||
| 082d3e24b1 | |||
| 903b1e4dfd | |||
| 5a6bd59297 | |||
| dee6e37932 | |||
| ee98aa8186 | |||
| 11da1e9d8d | |||
| 47694f2e5d | |||
| fb4ffaa3e2 | |||
| ef1e4503c9 | |||
| e2d1082e92 | |||
| 9303c5e206 | |||
| ce9a79f420 | |||
| 571c56ef71 | |||
| e407f87529 | |||
| 6bc7540941 | |||
| 11cada2ffc | |||
| 722694bec9 | |||
| 9cf065e0db | |||
| 07bb4a91d0 | |||
| d0e80b6be5 | |||
| ab1efdd3b1 | |||
| 0950e5c375 | |||
| cc1caa9671 | |||
| 0137981201 | |||
| 25df072202 | |||
| 73b06c324a | |||
| 2376987982 | |||
| 2150ade42b | |||
| 92e30979d9 | |||
| ad028b9e6b | |||
| fb3292d41e | |||
| e8f74aa529 | |||
| 7ae5b75db9 | |||
| a2962c2163 | |||
| b04f97cb8c | |||
| 3645c098c2 | |||
| ee1f64a53b | |||
| 6610f2d73e | |||
| ee214677bd | |||
| 081811bea7 | |||
| ae98e2cec4 | |||
| 1cfb3e91e2 | |||
| 7664fe368d | |||
| 3e3ba7c4f8 | |||
| 349fb17c90 | |||
| 315c500005 | |||
| 3b744d87f0 | |||
| c5c63cb68e | |||
| e904ec4a7d | |||
| 6464baebd2 | |||
| cc2b34e84a | |||
| a24a96288d | |||
| 38743cfb95 | |||
| b89da05c84 | |||
| e44e2940fc | |||
| c748cb944e | |||
| c9ece32601 | |||
| 6256adf826 | |||
| 1551faaf0c | |||
| eb8f54dbf2 | |||
| 6739e4926b | |||
| 9d3b0dd068 | |||
| e763a0b878 | |||
| 86977bd37e | |||
| f1c28acc96 | |||
| a96f772d7a | |||
| 22dd49f337 | |||
| acb8d5e0d1 | |||
| d201b7a3c6 | |||
| 8b69fee31c | |||
| 21d3a30d8b | |||
| ad872d3b2c | |||
| f301c2ab0f | |||
| caf800e5e8 | |||
| d229b58f3b | |||
| cf23863a30 | |||
| 19991dd22f | |||
| 6bec76535c | |||
| cfcbfe9768 | |||
| 8eb5c06198 | |||
| 43122a7c12 | |||
| 348da79175 | |||
| 7cc1fadf0f | |||
| 5d56f8180a | |||
| f8d1e60519 | |||
| 96358423df | |||
| e483d7af4d | |||
| 2f2cd151fb | |||
| c4851ff0ae | |||
| fb62c28e10 | |||
| 0209ec0d5c | |||
| 9ea8bee940 | |||
| e820041cef | |||
| e678399fe7 | |||
| 2cbee97afb | |||
| 0b88a65414 | |||
| 14ae1a7bfb | |||
| 1159697c0e | |||
| ea20491e9c | |||
| 8a428ac1aa | |||
| d08f7aeeac | |||
| 1bdcaaf5d8 | |||
| a38b2d5e71 | |||
| 74afb7aef8 | |||
| 2ed560d4e8 | |||
| cd107a0213 | |||
| 9ff2373ccb | |||
| 126834c7d4 | |||
| 872fc33dd7 | |||
| 0ffe647657 | |||
| 179e6e436d | |||
| 64158e9080 | |||
| f52b2b7b71 | |||
| b077fd318d | |||
| 17c379f0b3 | |||
| a2c20adb4e | |||
| 8515ba8d84 | |||
| 190b8154b4 | |||
| a1bf0b381b | |||
| 63a7ff3d89 | |||
| 502d3b390a | |||
| 2e9888998a | |||
| 7184b7768a | |||
| 9e8ad6c74b | |||
| 41491159a3 | |||
| 64d4821fae | |||
| 9b124deeea | |||
| b46d5d8244 | |||
| d8faccab46 | |||
| 60b59d2d03 | |||
| 5a64f0f682 | |||
| 69a8b847ae | |||
| 8825e76101 | |||
| c4aa90b0d1 | |||
| f04e1b4c44 | |||
| 9a93ec986a | |||
| 78534000a0 | |||
| ce3c3a5e6f | |||
| 9bed4e3fb9 | |||
| 8161a8b8b4 | |||
| 5b9faa5a76 | |||
| 147c943cdf | |||
| 0403d5212e | |||
| 50e3299ffb | |||
| fb45c8e7d5 | |||
| d98e7313de | |||
| 04e6fca765 | |||
| 4b754c9d77 | |||
| b7ca9c1ffa | |||
| 0cfafedeef | |||
| d436fd4d9e | |||
| 5578a9deb0 | |||
| f39c54210f | |||
| dc30d889c4 | |||
| d2f23ae6c8 | |||
| b2806d6176 | |||
| 2d88217bbf | |||
| 7c77524a3a | |||
| 3d09b489b9 | |||
| 22fc0cbab2 | |||
| db148eef90 | |||
| 03023a6b8c | |||
| cb14b7c68e | |||
| 9d4280f69d | |||
| 8cd5bfe821 | |||
| f8112274cf | |||
| 98b4d13f94 | |||
| b5f529c789 | |||
| 5e5c5170a7 | |||
| 0eb2807630 | |||
| b6a95e5464 | |||
| 401bf5e204 | |||
| 8faeaa5804 | |||
| 2bdcfdeb1a | |||
| 57dd4d22eb | |||
| b25150faa3 | |||
| 8926c5b02c | |||
| b4f7e94800 | |||
| e229caf320 | |||
| 5bd7029066 | |||
| 1bd15f627f | |||
| 6e0722e2d7 | |||
| 89bcd9f161 | |||
| e932c66af0 | |||
| 5f500a1aa8 | |||
| d3025e323b | |||
| 3d1cb69463 | |||
| 4b1d8bf5c3 | |||
| 4f90184ddd | |||
| b7a79d8d86 | |||
| 007cb28b21 | |||
| b0543c0c03 | |||
| a18d43dae4 | |||
| 421750957f | |||
| 78aea4a69a | |||
| 3f12e6e210 | |||
| 3528924857 | |||
| 6d07dcb0bb | |||
| 2701eb0422 | |||
| b93aca9005 | |||
| 82999ef056 | |||
| 590b927e31 | |||
| 5a702ac77e | |||
| aac3753226 | |||
| 9f10f1c4de | |||
| d79e990c04 | |||
| d72cad90ec | |||
| 44031efcd5 | |||
| 8037f2733e | |||
| 6038b44fa8 | |||
| ccdf476c4f | |||
| ff7aaa54cd | |||
| dba8669f4b | |||
| e2124024e1 | |||
| 95f7d80b45 | |||
| cce4d9873a | |||
| 6144e3c18d | |||
| 558df9627b | |||
| 90261d4f31 | |||
| 4602b45f30 | |||
| f301a0a336 | |||
| 442192cb35 | |||
| ba5ad2c2ac | |||
| 816c6d67b0 | |||
| 9baf02810f | |||
| 6161a103b0 | |||
| ac3ba89c0a | |||
| 02df6c10a0 | |||
| 196a9db8c1 | |||
| 1453990f7a | |||
| ded5e66ccc | |||
| cae0a528f3 | |||
| 0234505932 | |||
| 108592f289 | |||
| efefc3f5b0 | |||
| 2a691e37b7 | |||
| 927f56cb1c | |||
| 48c0649a68 | |||
| 1d2a945aa1 | |||
| 3ad328103c | |||
| 555a87fdf7 | |||
| 3f1c39d5a6 | |||
| c3408ecc3c | |||
| c0927c718d | |||
| 162bc550c0 | |||
| e4c291a785 | |||
| 570f9f4153 | |||
| 99abdfed0b | |||
| f41ee64c32 | |||
| afe99ea79f | |||
| 7be0332f02 | |||
| 8830b2ac91 | |||
| b1c33c29ed | |||
| c99f4d970e | |||
| c228f7be55 | |||
| 4e1c610405 | |||
| cbce646fd8 | |||
| a6f60ce3c4 | |||
| f9aefb55d8 | |||
| d99bb00d6c | |||
| afdd3c8904 | |||
| ad0a220953 | |||
| f508c821c2 | |||
| 45526f7c7b | |||
| 5e64a4553d | |||
| 2e22fcabe9 | |||
| 3c0cf6bc07 | |||
| 9c5fc9257a | |||
| 0edca05df5 | |||
| 099fba58f7 | |||
| 817aeedc6f | |||
| 1d27d517f3 | |||
| a5e802d20a | |||
| e5ca7d62a3 | |||
| 35e72645bd | |||
| 39dfa22b0d | |||
| 96c0b0dd35 | |||
| 10ed9c4c4b | |||
| ce4b06fe3d | |||
| 2413ffb4f0 | |||
| cb1685d055 | |||
| 4be82ac76b | |||
| 83d38f8489 | |||
| ec2b8389c3 | |||
| 3e305845dd | |||
| 68bd398759 | |||
| 0ed807f98c | |||
| f94701207b | |||
| 9e3f6765e0 | |||
| a906a40b7b | |||
| d4844140b4 | |||
| 40e593ffdc | |||
| 5151b32f3e | |||
| 20e7eed424 | |||
| af0efbe928 | |||
| 038858985c | |||
| 7b27c15515 | |||
| 88863415d7 | |||
| 5149582958 | |||
| 49bd1b3036 | |||
| 6b78abb7fd | |||
| ff9b3a6092 | |||
| 4d9a300e0c | |||
| e275f10dbd | |||
| 1c0fb0a960 | |||
| 8896726e09 | |||
| a02a9df081 | |||
| 7976257029 | |||
| 72fd495c5b | |||
| 92fe76d072 | |||
| 1a07e5ac0f | |||
| ae4a206de7 | |||
| 5c6e75e3d3 | |||
| 9ffa9c39f8 | |||
| 5a7a4da027 | |||
| 8342c6a0e6 |
@@ -12,3 +12,15 @@ build
|
||||
**/decp.duckdb.tmp
|
||||
**/decp.duckdb.lock
|
||||
**/schema.cache.json
|
||||
|
||||
# Runtime databases (never commit)
|
||||
users.sqlite
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
!tests/*.sqlite
|
||||
!tests/**/*.sqlite
|
||||
|
||||
# LLM plugins
|
||||
.superpowers/
|
||||
.codegraph/
|
||||
.claude
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
|
||||
DUCKDB_PATH=./decp.duckdb
|
||||
DUCKDB_PATH=./colibre.duckdb
|
||||
PORT=8050
|
||||
DEVELOPMENT=True
|
||||
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
||||
@@ -12,14 +12,10 @@ DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-be
|
||||
DATA_SCHEMA_CACHE=./schema.cache.json
|
||||
|
||||
# Colonnes masquées par défaut
|
||||
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset"
|
||||
DISPLAYED_COLUMNS="acheteur_nom, montant, objet, titulaire_nom, dateNotification, dureeMois, acheteur_departement_code"
|
||||
|
||||
# Formulaire de contact
|
||||
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
|
||||
LOGIN_PASSWORD="" # mot de passe du serveur
|
||||
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
|
||||
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
|
||||
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
|
||||
|
||||
# Matomo
|
||||
MATOMO_ID_SITE=
|
||||
@@ -27,8 +23,58 @@ MATOMO_BASE_URL=
|
||||
MATOMO_TOKEN=
|
||||
|
||||
# API privée
|
||||
DISABLE_API_AUTH="false"
|
||||
USERS_DB_PATH=./users.sqlite
|
||||
API_AUTH_DISABLED="false"
|
||||
MATOMO_URL=https://analytics.maudry.com/matomo.php
|
||||
MATOMO_SITE_ID=14
|
||||
MATOMO_TRACKING_ENABLED=true
|
||||
|
||||
# Comptes utilisateurs
|
||||
USERS_DB_PATH=./users.sqlite
|
||||
SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
|
||||
APP_BASE_URL=http://localhost:8050
|
||||
|
||||
# Panneau admin interne (accès à /admin, protégé par cette adresse)
|
||||
ADMIN_EMAIL=
|
||||
|
||||
# Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal,
|
||||
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
|
||||
# {APP_BASE_URL}/auth/linkedin/callback
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
|
||||
# Brevo — envoi des emails transactionnels (vérification email, reset mot de passe)
|
||||
BREVO_API_KEY=
|
||||
BREVO_TEMPLATE_VERIFY_ID=
|
||||
BREVO_TEMPLATE_RESET_ID=
|
||||
MAIL_FROM=noreply@colibre.fr # expéditeur (doit être un expéditeur vérifié dans Brevo)
|
||||
MAIL_FROM_NAME=colibre # nom d'expéditeur
|
||||
# Active/désactive le mode sandbox Brevo (X-Sib-Sandbox: drop) indépendamment de DEVELOPMENT.
|
||||
# Si non défini, suit DEVELOPMENT (sandbox actif par défaut en dev/tests).
|
||||
MAIL_SUPPRESS_SEND=
|
||||
|
||||
# Sauvegarde de users.sqlite vers un stockage S3-compatible
|
||||
S3_ENDPOINT_URL=https://s3.exemple.net
|
||||
S3_BUCKET=colibre-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=
|
||||
|
||||
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
|
||||
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
|
||||
FRISBII_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer)
|
||||
FRISBII_PLAN_SIMPLE=abonnement-colibre # handle du plan "abonnement simple" (20 € HT/mois)
|
||||
FRISBII_PLAN_SOUTIEN=soutien-colibre # handle du plan "abonnement de soutien" (50 € HT/mois)
|
||||
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
|
||||
|
||||
# Accès gratuit temporaire
|
||||
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte
|
||||
|
||||
# Serveur MCP (issue #111). Laisser à false tant que l'authentification
|
||||
# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP
|
||||
# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels :
|
||||
# créer un Custom Dimension slot 1 (scope Action).
|
||||
DASH_MCP_ENABLED=false
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
### 3.0.0
|
||||
|
||||
**Fonctionnalités par abonnement**
|
||||
|
||||
- Abonnement payant à colibre : période d'essai gratuite, souscription et gestion du moyen de paiement, résiliation, historique de facturation ([#90](https://github.com/ColinMaudry/colibre/issues/90))
|
||||
- Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau, réservée aux abonné·es ([#95](https://github.com/ColinMaudry/colibre/issues/95))
|
||||
- Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94))
|
||||
- Accès aux données de la commande publique via un serveur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111))
|
||||
|
||||
**Autres améliorations**
|
||||
|
||||
- Comptes utilisateurs : inscription avec vérification d'email, connexion (email/mot de passe ou LinkedIn), réinitialisation de mot de passe, gestion du compte ([#73](https://github.com/ColinMaudry/colibre/issues/73), [#88](https://github.com/ColinMaudry/colibre/issues/88))
|
||||
- Nouvel espace "Mon compte" avec navigation par sections
|
||||
- Sauvegarde régulière et chiffrée de la base des utilisateurs ([#89](https://github.com/ColinMaudry/colibre/issues/89))
|
||||
- Ajout des codes et libellés NAF des titulaires, affichés sur leur page /titulaire
|
||||
- Scroll horizontal ergonomique et en-têtes de colonnes collants dans la page Tableau ([#82](https://github.com/ColinMaudry/colibre/issues/82))
|
||||
- Export Excel mis en forme (styles) ([#83](https://github.com/ColinMaudry/colibre/issues/83))
|
||||
- Réduction du nombre de colonnes affichées par défaut et ajout de la colonne "Marché" dans la page Tableau ([#84](https://github.com/ColinMaudry/colibre/issues/84))
|
||||
- Barre d'outils de la page Tableau plus compacte, avec légende d'aide
|
||||
- Refonte et mise en page pleine largeur de la page À propos
|
||||
- Ajout des conditions générales d'utilisation (CGU) ([#93](https://github.com/ColinMaudry/colibre/issues/93))
|
||||
- Amélioration du référencement (sitemaps acheteurs/titulaires)
|
||||
- Amélioration des performances des pages acheteur et titulaire
|
||||
- Ajout des organismes liés sur la carte des pages acheteur et titulaire
|
||||
- decp.info devient **colibre** : nouveau nom, nouvelles icônes ([#57](https://github.com/ColinMaudry/colibre/issues/57))
|
||||
|
||||
**Bugs résolus**
|
||||
|
||||
- Redirection Frisbii corrigée après l'ajout d'un moyen de paiement lors du premier abonnement
|
||||
- Un abonnement en échec ne bloque plus une nouvelle tentative de réabonnement
|
||||
- Meilleure gestion de l'affichage d'un abonnement expiré
|
||||
- Correction de l'affichage du nombre de jours d'essai restants
|
||||
- Correction d'une erreur lors du renommage d'une vue sauvegardée, avec un meilleur retour visuel
|
||||
- Le champ de filtrage n'apparaît plus sur la colonne "Marché" (non filtrable)
|
||||
|
||||
##### 2.8.1 (25 juin 2026)
|
||||
|
||||
- Correction du bug dans la création de token d'API
|
||||
@@ -35,7 +70,7 @@
|
||||
|
||||
##### 2.7.4 (22 avril 2026)
|
||||
|
||||
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72)
|
||||
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/colibre/issues/72)
|
||||
|
||||
##### 2.7.3 (20 avril 2026)
|
||||
|
||||
@@ -44,11 +79,11 @@
|
||||
|
||||
##### 2.7.2 (19 avril 2026)
|
||||
|
||||
- Chargement des données depuis une base DuckDB plutôt qu'en mémoire (plus de stabilité) ([#71](https://github.com/ColinMaudry/decp.info/issues/71))
|
||||
- Chargement des données depuis une base DuckDB plutôt qu'en mémoire (plus de stabilité) ([#71](https://github.com/ColinMaudry/colibre/issues/71))
|
||||
- Mise en cache des vue sur l'observatoire pour un chargement plus rapide (remise à zéro quotidienne)
|
||||
- Correction de bug : la liste de colonnes par défaut est bien appliquée plutôt qu'afficher toutes les colonnes
|
||||
- Quelques corrections de bugs d'affichage
|
||||
- Refactorisation des fonctions utilitaires (`utils.py` approchait des 1 000 lignes)
|
||||
- Refactorisation des fonctions utilitaire (`utils.py` approchait des 1 000 lignes)
|
||||
|
||||
##### 2.7.1 (23 mars 2026)
|
||||
|
||||
@@ -79,7 +114,7 @@
|
||||
|
||||
##### 2.5.1 (29 janvier 2026)
|
||||
|
||||
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68))
|
||||
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/colibre/issues/67), [#68](https://github.com/ColinMaudry/colibre/issues/68))
|
||||
|
||||
#### 2.5.0 (29 janvier 2026)
|
||||
|
||||
@@ -94,11 +129,11 @@
|
||||
|
||||
#### 2.4.0 (22 janvier 2026)
|
||||
|
||||
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
|
||||
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/colibre/issues/63))
|
||||
- Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source)
|
||||
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50))
|
||||
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/colibre/issues/50))
|
||||
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
|
||||
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36))
|
||||
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/colibre/issues/36))
|
||||
- Désactivation du bloquage des robot d'agents de LLM (robots.txt)
|
||||
|
||||
##### 2.3.1 (16 janvier 2026)
|
||||
@@ -129,9 +164,9 @@
|
||||
|
||||
#### 2.2.0 (13 novembre 2025)
|
||||
|
||||
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
|
||||
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
|
||||
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
|
||||
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/colibre/issues/58))
|
||||
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/colibre/issues/55)))
|
||||
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/colibre/issues/54))
|
||||
|
||||
##### 2.1.7 (11 novembre 2025)
|
||||
|
||||
@@ -162,22 +197,22 @@
|
||||
|
||||
##### 2.1.1 (1er octobre 2025)
|
||||
|
||||
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43))
|
||||
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/colibre/issues/43))
|
||||
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
|
||||
|
||||
#### 2.1.0 (30 septembre 2025)
|
||||
|
||||
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
|
||||
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
|
||||
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨
|
||||
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅
|
||||
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖
|
||||
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/colibre/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/colibre/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/colibre/issues/40)) 🔎
|
||||
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/colibre/issues/39)) pour de beaux aperçus de liens 🖼️
|
||||
- Formulaire de contact ([#48](https://github.com/ColinMaudry/colibre/issues/48)) 📨
|
||||
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/colibre/issues/33)) 💅
|
||||
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/colibre/issues/33)) 📖
|
||||
- Affichage du numéro de version près du logo et lien vers ici 🤓
|
||||
- Variables globales uniquement en lecture (😁)
|
||||
|
||||
##### 2.0.1 (23 septembre 2025)
|
||||
|
||||
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
|
||||
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/colibre/issues/38)
|
||||
- Amélioration du script de déploiement (deploy.sh)
|
||||
- Meilleures instructions d'installation et lancement
|
||||
- Coquilles 🐚
|
||||
@@ -217,7 +252,7 @@
|
||||
|
||||
- ajout d'une page "Notes de version"
|
||||
- meilleur lien pour la documentation des champs
|
||||
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
|
||||
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/colibre)
|
||||
|
||||
### 1.1.0 (25/05/2021)
|
||||
|
||||
|
||||
@@ -4,21 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**decp.info** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
|
||||
**colibre** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
|
||||
|
||||
## Commands
|
||||
|
||||
### Setup
|
||||
|
||||
Setting up the virtual environment:
|
||||
|
||||
```bash
|
||||
python -m venv .venv # s'il n'existe pas déjà
|
||||
source .venv/bin/activate
|
||||
rtk pip install -U pip > /dev/null 2>&1
|
||||
rtk pip install -e . --group=dev
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
```bash
|
||||
@@ -28,24 +19,28 @@ cp .template.env .env # then customize .env
|
||||
### Development
|
||||
|
||||
```bash
|
||||
python run.py # starts Dash app
|
||||
uv run run.py # starts Dash app
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
gunicorn app:server
|
||||
gunicorn run:server
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
rtk pytest # run all tests (some are Selenium-based integration tests)
|
||||
rtk pytest tests/test_main.py::test_001_logo_and_search # run a single test
|
||||
uv run pytest # run all tests (some are Selenium-based integration tests)
|
||||
uv run pytest tests/test_main.py::test_001_logo_and_search # run a single test
|
||||
```
|
||||
|
||||
Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver.
|
||||
|
||||
## Ajouts git
|
||||
|
||||
Avant d'ajouter des fichier dans git (`git add` ou `git commit -a`), exécute `pre-commit` pour que ruff formate les fichiers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Multi-page Dash app
|
||||
@@ -92,7 +87,48 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from `
|
||||
- `DEVELOPMENT=true` enables debug logging and is set automatically during tests
|
||||
- `.env` file is required at runtime (copy from `template.env`)
|
||||
|
||||
### Migrations de schéma SQLite
|
||||
|
||||
Les migrations sont gérées dans `src/migrations.py` via une liste `_MIGRATIONS` de tuples `(id, sql)`. Elles sont appliquées automatiquement au démarrage de l'app (via `init_subscriptions`).
|
||||
|
||||
Pour ajouter une migration :
|
||||
|
||||
```python
|
||||
# src/migrations.py
|
||||
_MIGRATIONS = [
|
||||
("0001_add_prix_ht_to_subscriptions", "ALTER TABLE subscriptions ADD COLUMN prix_ht REAL"),
|
||||
("0002_ma_nouvelle_migration", "ALTER TABLE ... "), # ajouter ici
|
||||
]
|
||||
```
|
||||
|
||||
- L'ID doit être unique et croissant (convention `NNNN_description`)
|
||||
- Les migrations appliquées sont tracées dans la table `schema_migrations`
|
||||
- `apply_pending()` est idempotent : sans effet si la migration est déjà enregistrée, et tolère le cas où la colonne existe déjà dans le schéma (DB fraîche)
|
||||
|
||||
### Deployment
|
||||
|
||||
- `main` branch → manual deploy to decp.info via GitHub Actions
|
||||
- `dev` branch → auto-deploy to test.decp.info via GitHub Actions
|
||||
- `main` branch → manual deploy to colibre.fr via GitHub Actions
|
||||
- `dev` branch → auto-deploy to test.colibre.fr via GitHub Actions
|
||||
|
||||
#### Sauvegarde de la base utilisateurs
|
||||
|
||||
`users.sqlite` est sauvegardée toutes les heures sur S3 par un timer systemd
|
||||
(voir `deploy/colibre-backup.{service,timer}`). Installation initiale (une fois,
|
||||
sur le serveur, en root) :
|
||||
|
||||
```bash
|
||||
cp deploy/colibre-backup.service deploy/colibre-backup.timer /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now colibre-backup.timer
|
||||
systemctl list-timers colibre-backup.timer # vérifier le prochain déclenchement
|
||||
```
|
||||
|
||||
Restauration manuelle :
|
||||
|
||||
```bash
|
||||
cd /var/www/colibre && source .venv/bin/activate
|
||||
python -m src.backup list
|
||||
systemctl stop colibre
|
||||
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
|
||||
systemctl start colibre
|
||||
```
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# decp.info
|
||||
# colibre
|
||||
|
||||
> **Note:** Ce projet a été rebaptisé de **decp.info** à **colibre** en 2026.
|
||||
|
||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
|
||||
=> [decp.info](https://decp.info)
|
||||
=> [colibre.fr](https://colibre.fr)
|
||||
|
||||
## Installation et lancement
|
||||
|
||||
@@ -20,11 +22,39 @@ uv run run.py
|
||||
|
||||
## Déploiement
|
||||
|
||||
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml)
|
||||
- **Test** (branche `dev`, [test.decp.info](https://test.decp.info)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
|
||||
- **Production** (branche `main`, [colibre.fr](https://colibre.fr)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/colibre/actions/workflows/deploy.yaml)
|
||||
- **Test** (branche `dev`, [test.colibre.fr](https://test.colibre.fr)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
|
||||
|
||||
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 colibre
|
||||
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
|
||||
systemctl start colibre
|
||||
```
|
||||
|
||||
## Migrations de base de données
|
||||
|
||||
Les migrations SQLite s'appliquent **automatiquement au démarrage de l'app** — aucune action manuelle requise. Il suffit de redémarrer le service après un déploiement.
|
||||
|
||||
Pour vérifier quelles migrations ont été appliquées :
|
||||
|
||||
```bash
|
||||
sqlite3 users.sqlite "SELECT id, applied_at FROM schema_migrations ORDER BY applied_at;"
|
||||
```
|
||||
|
||||
Pour ajouter une migration, voir les instructions dans `src/migrations.py`.
|
||||
|
||||
## Liens connexes
|
||||
|
||||
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
|
||||
@@ -32,4 +62,4 @@ Ne pas oublier de mettre à jour les fichier .env.
|
||||
|
||||
## Notes de version
|
||||
|
||||
Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md).
|
||||
Voir [CHANGELOG](https://github.com/ColinMaudry/colibre/blob/main/CHANGELOG.md).
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
@@ -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 (colibre) vers S3
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=colibre
|
||||
WorkingDirectory=/var/www/colibre
|
||||
EnvironmentFile=/var/www/colibre/.env
|
||||
ExecStart=/var/www/colibre/.venv/bin/python -m src.backup backup
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Déclenche la sauvegarde horaire de users.sqlite (colibre)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,114 @@
|
||||
# Configuration nginx de référence pour colibre.fr + migration decp.info.
|
||||
#
|
||||
# Installation (sur le serveur, en root) :
|
||||
# cp deploy/nginx-colibre.conf /etc/nginx/sites-available/colibre
|
||||
# ln -s /etc/nginx/sites-available/colibre /etc/nginx/sites-enabled/colibre
|
||||
# nginx -t && systemctl reload nginx
|
||||
#
|
||||
# Les certificats TLS sont gérés par certbot (Let's Encrypt) :
|
||||
# certbot --nginx -d colibre.fr -d www.colibre.fr -d decp.info -d www.decp.info
|
||||
# Obtenir/installer les certificats AVANT d'activer les redirections 301 :
|
||||
# une 301 vers un https cassé est mise en cache par les navigateurs/Google.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Migration decp.info → colibre.fr (301 permanentes, chemin préservé)
|
||||
# La structure d'URL est identique entre les deux domaines (rebranding),
|
||||
# donc $request_uri suffit : /acheteurs/123 → https://colibre.fr/acheteurs/123.
|
||||
# On NE redirige PAS vers la home : chaque ancienne URL garde son équivalent,
|
||||
# ce qui transfère l'autorité SEO page par page.
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name decp.info www.decp.info;
|
||||
# Laisser certbot répondre aux challenges ACME avant la redirection.
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://colibre.fr$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name decp.info www.decp.info;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
|
||||
|
||||
return 301 https://colibre.fr$request_uri;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1b. Migration test.decp.info → test.colibre.fr (302 temporaire, chemin
|
||||
# préservé). Contrairement à la prod (section 1), on reste en 302 tant
|
||||
# que l'environnement de test est susceptible de bouger, pour ne pas
|
||||
# risquer un cache navigateur/moteur sur une redirection qui n'est pas
|
||||
# encore définitive.
|
||||
# Nécessite un certificat couvrant test.colibre.fr (et test.decp.info
|
||||
# pour le vhost 443 ci-dessous), par ex. :
|
||||
# certbot --nginx -d test.colibre.fr -d test.decp.info
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name test.decp.info;
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 302 https://test.colibre.fr$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name test.decp.info;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/test.colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/test.colibre.fr/privkey.pem;
|
||||
|
||||
return 302 https://test.colibre.fr$request_uri;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. www.colibre.fr → colibre.fr (canonicalisation : un seul hôte indexé)
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name colibre.fr www.colibre.fr;
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://colibre.fr$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name www.colibre.fr;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
|
||||
|
||||
return 301 https://colibre.fr$request_uri;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Hôte canonique : https://colibre.fr → application Dash (gunicorn)
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name colibre.fr;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
|
||||
|
||||
# Le sitemap acheteurs/titulaires peut être volumineux : autoriser de
|
||||
# gros corps de réponse en proxy et des timeouts généreux.
|
||||
proxy_read_timeout 120s;
|
||||
|
||||
location / {
|
||||
# gunicorn app:server — adapter le bind à l'unit systemd colibre.
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
```
|
||||
@@ -0,0 +1,321 @@
|
||||
# Excel Export Styling 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:** Améliorer les exports Excel de toutes les vues tableau : en-têtes stylisés (fond rouge `#b33821`, texte blanc, gras), largeurs de colonnes issues du `DataTable` commun, largeur minimale de 3.5 cm, et retour à la ligne automatique dans toutes les cellules.
|
||||
|
||||
**Architecture:** Une fonction utilitaire `write_styled_excel(df, buffer, worksheet)` centralisée dans `src/utils/table.py` crée le workbook xlsxwriter avec `default_format_properties: {text_wrap: True}`, puis délègue à `polars.DataFrame.write_excel` avec le `header_format` et les `column_widths` calculés. Les 6 callbacks de téléchargement appellent cette fonction à la place de `.write_excel()` direct.
|
||||
|
||||
**Tech Stack:** Python, Polars, xlsxwriter (déjà en dep), openpyxl (disponible, utilisé en test uniquement)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports de modules internes toujours via `src.` (ex. `from src.utils.table import …`)
|
||||
- `xlsxwriter` est en dep de prod — pas besoin de l'ajouter
|
||||
- `openpyxl` est disponible (dep transitive) — pas besoin de l'ajouter à `pyproject.toml`
|
||||
- Run tests : `uv run pytest` (pas `pytest` direct)
|
||||
- Couleur primaire de l'app : `#b33821`
|
||||
- Largeur minimale : 132 pixels (≈ 3.5 cm à 96 DPI)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Fichier | Action | Rôle |
|
||||
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/utils/table.py` | Modifier | Ajouter `import xlsxwriter`, constantes `_EXCEL_*`, fonction `write_styled_excel` |
|
||||
| `tests/test_excel.py` | Créer | Tests unitaires de `write_styled_excel` |
|
||||
| `src/pages/tableau.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_data` |
|
||||
| `src/pages/acheteur.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_acheteur_data` et `download_filtered_acheteur_data` |
|
||||
| `src/pages/titulaire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_titulaire_data` et `download_filtered_titulaire_data` |
|
||||
| `src/pages/observatoire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_observatoire` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : `write_styled_excel` dans `src/utils/table.py`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py:1` (ajouter import), `src/utils/table.py:559` (fin de fichier)
|
||||
- Create: `tests/test_excel.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` — exporté publiquement depuis `src.utils.table`
|
||||
|
||||
- [ ] **Step 1 : Créer `tests/test_excel.py` avec les tests en échec**
|
||||
|
||||
```python
|
||||
import io
|
||||
|
||||
import openpyxl
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from src.utils.table import write_styled_excel
|
||||
|
||||
|
||||
def test_write_styled_excel_header_bold_and_color():
|
||||
df = pl.DataFrame({"objet": ["marché A"], "montant": [1000.0]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf)
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
ws = wb.active
|
||||
cell = ws.cell(row=1, column=1)
|
||||
assert cell.font.bold is True
|
||||
assert cell.fill.fgColor.rgb == "FFB33821"
|
||||
assert cell.font.color.rgb == "FFFFFFFF"
|
||||
|
||||
|
||||
def test_write_styled_excel_data_cell_text_wrap():
|
||||
df = pl.DataFrame({"objet": ["marché A"]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf)
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
ws = wb.active
|
||||
cell = ws.cell(row=2, column=1)
|
||||
assert cell.alignment.wrap_text is True
|
||||
|
||||
|
||||
def test_write_styled_excel_known_column_wider_than_minimum():
|
||||
# "objet" a une largeur explicite (350px) > minimum (132px)
|
||||
# "autre" n'a pas de largeur explicite → reçoit le minimum
|
||||
df = pl.DataFrame({"autre": ["x"], "objet": ["y"]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf)
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
ws = wb.active
|
||||
width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre"
|
||||
width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet"
|
||||
assert width_autre > 0
|
||||
assert width_objet > width_autre
|
||||
|
||||
|
||||
def test_write_styled_excel_custom_worksheet_name():
|
||||
df = pl.DataFrame({"col": ["val"]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf, worksheet="2025")
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
assert "2025" in wb.sheetnames
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que les tests échouent**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_excel.py -v
|
||||
```
|
||||
|
||||
Attendu : `ImportError` ou `AttributeError: module 'src.utils.table' has no attribute 'write_styled_excel'`
|
||||
|
||||
- [ ] **Step 3 : Ajouter `import xlsxwriter` dans `src/utils/table.py`**
|
||||
|
||||
Ligne 1 du fichier, après les imports existants. Le bloc imports actuel (lignes 1–14) devient :
|
||||
|
||||
```python
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import polars as pl
|
||||
import xlsxwriter
|
||||
from dash import no_update
|
||||
from polars import selectors as cs
|
||||
from unidecode import unidecode
|
||||
|
||||
from src.db import count_marches, count_unique_marches, query_marches, schema
|
||||
from src.utils import logger
|
||||
from src.utils.cache import cache
|
||||
from src.utils.data import DATA_SCHEMA
|
||||
from src.utils.frontend import get_button_properties
|
||||
from src.utils.tracking import track_search
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter les constantes et `write_styled_excel` à la fin de `src/utils/table.py`**
|
||||
|
||||
Après la ligne `COLUMNS = schema.names()` (actuellement dernière ligne, 559) :
|
||||
|
||||
```python
|
||||
|
||||
_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI
|
||||
_EXCEL_HEADER_FORMAT = {
|
||||
"bold": True,
|
||||
"bg_color": "#b33821",
|
||||
"font_color": "white",
|
||||
}
|
||||
_EXCEL_COLUMN_WIDTHS = {
|
||||
"objet": 350,
|
||||
"acheteur_nom": 250,
|
||||
"titulaire_nom": 250,
|
||||
"acheteur_id": 160,
|
||||
}
|
||||
|
||||
|
||||
def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None:
|
||||
col_widths = {
|
||||
col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0))
|
||||
for col in df.columns
|
||||
}
|
||||
wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}})
|
||||
ws = wb.add_worksheet(worksheet)
|
||||
df.write_excel(
|
||||
workbook=wb,
|
||||
worksheet=ws,
|
||||
header_format=_EXCEL_HEADER_FORMAT,
|
||||
column_widths=col_widths,
|
||||
)
|
||||
wb.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_excel.py -v
|
||||
```
|
||||
|
||||
Attendu : 4 tests PASS
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py tests/test_excel.py
|
||||
git commit -m "feat: add write_styled_excel utility for styled Excel exports #83"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Mettre à jour les 6 callbacks de téléchargement
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/tableau.py:26-33,344-345`
|
||||
- Modify: `src/pages/acheteur.py:30-37,399-402,441-442`
|
||||
- Modify: `src/pages/titulaire.py:29-36,421-423,462-463`
|
||||
- Modify: `src/pages/observatoire.py:43,813-814`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` depuis Task 1
|
||||
|
||||
- [ ] **Step 1 : Mettre à jour `src/pages/tableau.py`**
|
||||
|
||||
Ajouter `write_styled_excel` à l'import existant (lignes 26–33) :
|
||||
|
||||
```python
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
filter_table_data,
|
||||
get_default_hidden_columns,
|
||||
invert_columns,
|
||||
prepare_table_data,
|
||||
sort_table_data,
|
||||
write_styled_excel,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_data` (lignes 344–345) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Mettre à jour `src/pages/acheteur.py`**
|
||||
|
||||
Ajouter `write_styled_excel` à l'import existant (lignes 30–37) :
|
||||
|
||||
```python
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
filter_table_data,
|
||||
format_number,
|
||||
get_default_hidden_columns,
|
||||
prepare_table_data,
|
||||
sort_table_data,
|
||||
write_styled_excel,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_acheteur_data` (lignes 399–402) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(
|
||||
df_to_download,
|
||||
buffer,
|
||||
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_filtered_acheteur_data` (ligne 441–442) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Mettre à jour `src/pages/titulaire.py`**
|
||||
|
||||
Ajouter `write_styled_excel` à l'import existant (lignes 29–36) :
|
||||
|
||||
```python
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
filter_table_data,
|
||||
format_number,
|
||||
get_default_hidden_columns,
|
||||
prepare_table_data,
|
||||
sort_table_data,
|
||||
write_styled_excel,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_titulaire_data` (lignes 421–423) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(
|
||||
df_to_download,
|
||||
buffer,
|
||||
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_filtered_titulaire_data` (lignes 462–463) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Mettre à jour `src/pages/observatoire.py`**
|
||||
|
||||
Modifier l'import (ligne 43) :
|
||||
|
||||
```python
|
||||
from src.utils.table import COLUMNS, get_default_hidden_columns, prepare_table_data, write_styled_excel
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_observatoire` (ligne 813–814) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(dff, buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier les tests existants et les nouveaux**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_excel.py tests/test_main.py::test_003_tableau_download -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS (le test existant vérifie que le contenu base64 est non vide et que le nom de fichier commence par `decp_` — comportement inchangé)
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/tableau.py src/pages/acheteur.py src/pages/titulaire.py src/pages/observatoire.py
|
||||
git commit -m "refactor: use write_styled_excel in all download callbacks #83"
|
||||
```
|
||||
@@ -0,0 +1,350 @@
|
||||
# Défilement horizontal ergonomique des tableaux (#82) — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rendre le défilement horizontal des tableaux toujours accessible (barre miroir en haut, synchronisée) et garder les en-têtes de colonnes visibles pendant le défilement vertical de la page.
|
||||
|
||||
**Architecture:** Option B (sticky au niveau page). Le tableau reste dans le flux de la page. CSS rend les en-têtes `position: sticky; top: 0`. Une barre de défilement « miroir » est injectée par JS au-dessus de chaque tableau, sticky en haut, et synchronisée avec le conteneur scrollable du tableau. Aucun scroll vertical imbriqué.
|
||||
|
||||
**Tech Stack:** Dash 3.4 `dash_table.DataTable`, CSS (`src/assets/css/style.css`), JS vanilla auto-chargé depuis `src/assets/` (pattern existant : MutationObserver, cf. `dash_clientside.js`), tests Selenium (`dash[testing]` / `DashComposite`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Importer les modules de l'app avec le préfixe `src.` (ex. `src.figures`), jamais `figures`.
|
||||
- UI en français.
|
||||
- Cibler la classe partagée `marches_table` (présente sur les 4 pages : `/tableau`, `/acheteur`, `/titulaire`, `/observatoire`) — pas de duplication par page.
|
||||
- Ne pas modifier la hauteur du tableau ni introduire de conteneur à hauteur fixe (option A explicitement écartée).
|
||||
- Les commits référencent `#82`.
|
||||
- Le pre-commit hook lance `prettier` (CSS/JS/MD) et `ruff` (Python) et **modifie les fichiers** : après un échec dû au reformatage, refaire `git add` puis recommiter.
|
||||
- Terminer chaque message de commit par : `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Fichier | Responsabilité | Action |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||
| `src/assets/css/style.css` | En-têtes sticky + neutralisation overflow interne Dash + styles de la barre miroir | Modifier |
|
||||
| `src/assets/table_hscroll.js` | Injecter la barre miroir au-dessus de chaque `.marches_table`, synchroniser le scroll, masquer si pas de débordement, recalculer au resize / re-render | Créer |
|
||||
| `tests/test_tableau_hscroll.py` | Test Selenium : barre miroir présente + en-tête sticky sur `/tableau` | Créer |
|
||||
| `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` | Consigner le verdict du spike (Task 1) | Modifier |
|
||||
|
||||
**Décision DOM clé :** la barre miroir est **injectée par JS** (et non ajoutée dans le markup Python), ce qui évite de toucher au markup des 4 pages et fonctionne quel que soit le wrapper. Le conteneur scrollable du tableau est l'élément Dash `.dash-spreadsheet-container` (ou son parent direct), confirmé au spike.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Spike — vérifier le DOM réel et la technique sticky
|
||||
|
||||
**But :** lever l'inconnue principale (structure DOM de DataTable + compatibilité `position: sticky` des en-têtes avec un conteneur `overflow-x`). Produit un verdict écrit qui pilote les tâches suivantes.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` (section « Verdict du spike »)
|
||||
|
||||
- [ ] **Step 1 : Lancer l'app**
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python run.py
|
||||
```
|
||||
|
||||
Ouvrir `http://127.0.0.1:8050/tableau` dans le navigateur.
|
||||
|
||||
- [ ] **Step 2 : Inspecter le DOM du tableau**
|
||||
|
||||
Dans les DevTools, sur le tableau, relever et noter :
|
||||
|
||||
- l'élément qui porte la **largeur totale** du tableau (table `.cell-table`) et sa largeur (`scrollWidth`) vs. celle de son parent (`clientWidth`) → confirme le débordement ;
|
||||
- l'élément qui (le cas échéant) porte déjà un `overflow`/`overflow-x` (regarder `.dash-spreadsheet-container`, `.dash-spreadsheet-inner`) via l'onglet _Computed_ ;
|
||||
- le sélecteur exact de la ligne d'en-tête (attendu : `th.dash-header` dans un `tr`).
|
||||
|
||||
- [ ] **Step 3 : Tester l'hypothèse sticky en live**
|
||||
|
||||
Dans la console DevTools, appliquer à chaud :
|
||||
|
||||
```js
|
||||
document
|
||||
.querySelectorAll(
|
||||
".marches_table .dash-spreadsheet-container, .marches_table .dash-spreadsheet-inner"
|
||||
)
|
||||
.forEach((e) => (e.style.overflow = "visible"));
|
||||
document.querySelectorAll(".marches_table th.dash-header").forEach((e) => {
|
||||
e.style.position = "sticky";
|
||||
e.style.top = "0";
|
||||
e.style.zIndex = "10";
|
||||
e.style.background = "#fff";
|
||||
});
|
||||
```
|
||||
|
||||
Scroller verticalement la page → **les en-têtes restent-ils collés en haut ?**
|
||||
|
||||
- [ ] **Step 4 : Consigner le verdict**
|
||||
|
||||
Ajouter une section « Verdict du spike » à la spec, répondant à :
|
||||
|
||||
- sélecteur exact de l'en-tête et du conteneur scrollable ;
|
||||
- l'astuce `overflow: visible` sur les conteneurs Dash suffit-elle à faire fonctionner le sticky page ? (attendu : oui) ;
|
||||
- si **non** : noter que les en-têtes sticky devront être pilotés en JS (repositionnement au scroll) — le reste du plan reste valable, seul l'implémentation du sticky de la Task 2 change.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md
|
||||
git commit -m "docs: verdict spike DOM tableaux #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : En-têtes collants (CSS)
|
||||
|
||||
**But :** les en-têtes de colonnes restent visibles en haut de la fenêtre pendant le défilement vertical de la page.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/assets/css/style.css` (ajouter après le bloc `.marches_table.stuck`, vers la ligne 360)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: sélecteurs confirmés au spike (Task 1) : `.marches_table th.dash-header`, conteneurs `.dash-spreadsheet-container` / `.dash-spreadsheet-inner`.
|
||||
- Produces: classe `marches_table` dont les conteneurs Dash internes sont en `overflow: visible` et dont les en-têtes sont sticky — la Task 3 (JS) s'appuie dessus pour poser le conteneur scrollable et la barre miroir.
|
||||
|
||||
- [ ] **Step 1 : Écrire le CSS des en-têtes sticky**
|
||||
|
||||
Dans `src/assets/css/style.css`, ajouter :
|
||||
|
||||
```css
|
||||
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
|
||||
|
||||
/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */
|
||||
.marches_table .dash-spreadsheet-container,
|
||||
.marches_table .dash-spreadsheet-inner {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* En-têtes de colonnes collants en haut de la fenêtre */
|
||||
.marches_table th.dash-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #fff;
|
||||
}
|
||||
```
|
||||
|
||||
> Si le verdict du spike indique que le sticky CSS ne tient pas, remplacer ce bloc par le repositionnement JS décrit dans la spec et le porter en Task 3 ; documenter le choix dans le commit.
|
||||
|
||||
- [ ] **Step 2 : Vérifier dans le navigateur**
|
||||
|
||||
App lancée (`python run.py`), sur `/tableau` : scroller verticalement → l'en-tête reste figé en haut, sur fond opaque, au-dessus des lignes. Vérifier aussi `/acheteur` et `/observatoire`.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/assets/css/style.css
|
||||
git commit -m "feat(tableaux): en-têtes de colonnes collants #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Barre de défilement miroir (JS + CSS)
|
||||
|
||||
**But :** une barre de défilement horizontale toujours visible en haut du tableau, synchronisée avec le défilement horizontal du tableau, masquée si le tableau ne déborde pas.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/assets/table_hscroll.js`
|
||||
- Modify: `src/assets/css/style.css` (styles de `.dt-hscroll`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: les `.marches_table` rendues par Dash ; le conteneur scrollable interne (`.dash-spreadsheet-container`, confirmé au spike) dont on lit `scrollWidth`/`clientWidth` et qu'on pilote via `scrollLeft`.
|
||||
- Produces: pour chaque `.marches_table`, un nœud `<div class="dt-hscroll">` inséré en première position, contenant `<div class="dt-hscroll-inner">` dont la largeur = largeur totale du tableau.
|
||||
|
||||
- [ ] **Step 1 : Écrire le CSS de la barre miroir**
|
||||
|
||||
Dans `src/assets/css/style.css`, à la suite du bloc de la Task 2 :
|
||||
|
||||
```css
|
||||
/* Barre de défilement horizontale miroir, collée en haut */
|
||||
.marches_table .dt-hscroll {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 11; /* au-dessus des en-têtes sticky */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.marches_table .dt-hscroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.marches_table .dt-hscroll.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire le JS de synchronisation**
|
||||
|
||||
Créer `src/assets/table_hscroll.js` :
|
||||
|
||||
```js
|
||||
// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Renvoie le conteneur réellement scrollable horizontalement du tableau.
|
||||
function getScrollEl(wrapper) {
|
||||
return wrapper.querySelector(".dash-spreadsheet-container") || wrapper;
|
||||
}
|
||||
|
||||
function setup(wrapper) {
|
||||
if (wrapper.dataset.hscrollReady === "1") return;
|
||||
const scrollEl = getScrollEl(wrapper);
|
||||
if (!scrollEl) return;
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "dt-hscroll is-hidden";
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "dt-hscroll-inner";
|
||||
bar.appendChild(inner);
|
||||
wrapper.insertBefore(bar, wrapper.firstChild);
|
||||
|
||||
let syncing = false;
|
||||
const onBar = () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
scrollEl.scrollLeft = bar.scrollLeft;
|
||||
syncing = false;
|
||||
};
|
||||
const onTable = () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
bar.scrollLeft = scrollEl.scrollLeft;
|
||||
syncing = false;
|
||||
};
|
||||
bar.addEventListener("scroll", onBar);
|
||||
scrollEl.addEventListener("scroll", onTable);
|
||||
|
||||
const refresh = () => {
|
||||
const total = scrollEl.scrollWidth;
|
||||
const visible = scrollEl.clientWidth;
|
||||
inner.style.width = total + "px";
|
||||
bar.classList.toggle("is-hidden", total <= visible + 1);
|
||||
bar.scrollLeft = scrollEl.scrollLeft;
|
||||
};
|
||||
|
||||
// Recalcule quand le tableau change (pagination, tri, filtre, données).
|
||||
const obs = new MutationObserver(() => refresh());
|
||||
obs.observe(scrollEl, { childList: true, subtree: true, attributes: true });
|
||||
window.addEventListener("resize", refresh);
|
||||
|
||||
wrapper.dataset.hscrollReady = "1";
|
||||
refresh();
|
||||
}
|
||||
|
||||
function scan() {
|
||||
document.querySelectorAll(".marches_table").forEach(setup);
|
||||
}
|
||||
|
||||
// Les tableaux apparaissent après le rendu Dash : observer le body.
|
||||
const rootObs = new MutationObserver(() => scan());
|
||||
rootObs.observe(document.body, { childList: true, subtree: true });
|
||||
scan();
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Vérifier dans le navigateur**
|
||||
|
||||
App lancée, sur `/tableau` : une barre fine apparaît en haut du tableau ; la faire glisser déplace le tableau horizontalement, et inversement. Réduire la fenêtre / élargir → la barre apparaît/disparaît selon le débordement. Changer de page de pagination → la barre se recalcule. Vérifier que `/acheteur`, `/titulaire`, `/observatoire` (tableaux multiples) fonctionnent chacun indépendamment.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/assets/table_hscroll.js src/assets/css/style.css
|
||||
git commit -m "feat(tableaux): barre de défilement horizontale miroir synchronisée #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Test Selenium de non-régression
|
||||
|
||||
**But :** garantir que la barre miroir est rendue et que les en-têtes sont sticky, et que rien ne casse le chargement de `/tableau`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/test_tableau_hscroll.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: éléments DOM produits par Task 2 (`th.dash-header` sticky) et Task 3 (`.marches_table .dt-hscroll`).
|
||||
|
||||
- [ ] **Step 1 : Écrire le test**
|
||||
|
||||
Créer `tests/test_tableau_hscroll.py` (s'aligner sur le style des tests existants dans `tests/`, notamment l'usage de `dash_duo`/`DashComposite` et l'import de l'app) :
|
||||
|
||||
```python
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
|
||||
def test_tableau_hscroll_bar_present(dash_duo, start_app):
|
||||
"""La barre miroir est injectée et l'en-tête est collant sur /tableau."""
|
||||
dash_duo.wait_for_element(".marches_table", timeout=20)
|
||||
# Barre miroir injectée par table_hscroll.js
|
||||
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
|
||||
|
||||
header = dash_duo.find_element(".marches_table th.dash-header")
|
||||
position = header.value_of_css_property("position")
|
||||
assert position == "sticky"
|
||||
```
|
||||
|
||||
> Adapter les fixtures (`dash_duo`, `start_app`, navigation initiale vers `/tableau`) à ce qui existe déjà dans `tests/` : reprendre le mécanisme d'amorçage utilisé par les autres tests (par ex. `tests/test_main.py`) plutôt que d'en inventer un.
|
||||
|
||||
- [ ] **Step 2 : Lancer le test et vérifier qu'il échoue si on retire le JS** (sanity)
|
||||
|
||||
```bash
|
||||
rtk pytest tests/test_tableau_hscroll.py -v
|
||||
```
|
||||
|
||||
Expected: PASS avec le JS/CSS en place.
|
||||
|
||||
- [ ] **Step 3 : Lancer la suite Selenium impactée**
|
||||
|
||||
```bash
|
||||
rtk pytest tests/test_main.py -v
|
||||
```
|
||||
|
||||
Expected: pas de régression (mêmes résultats qu'avant la branche).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_tableau_hscroll.py
|
||||
git commit -m "test(tableaux): barre miroir et en-tête sticky sur /tableau #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Couverture spec :**
|
||||
|
||||
- En-têtes sticky → Task 2. ✓
|
||||
- Barre miroir synchronisée en haut → Task 3. ✓
|
||||
- Masquage si pas de débordement → Task 3 (`is-hidden`). ✓
|
||||
- Recalcul pagination/tri/filtre/resize → Task 3 (MutationObserver + resize). ✓
|
||||
- Plusieurs tableaux par page → Task 3 (`querySelectorAll` + `dataset.hscrollReady`). ✓
|
||||
- Contrainte overflow CSS → Task 1 (spike) + Task 2 (`overflow: visible`). ✓
|
||||
- Portée 4 pages via `marches_table` → ciblage CSS/JS global. ✓
|
||||
- Validation navigateur + non-régression Selenium → Task 1/2/3 (manuel) + Task 4. ✓
|
||||
|
||||
**Placeholders :** les renvois « adapter aux fixtures existantes » de la Task 4 pointent vers `tests/test_main.py` comme référence concrète (les fixtures Selenium du projet ne sont pas réinventées ici à dessein) ; aucun TODO/TBD ailleurs.
|
||||
|
||||
**Cohérence des noms :** classes `dt-hscroll` / `dt-hscroll-inner` / `is-hidden` et flag `dataset.hscrollReady` utilisés de façon identique entre CSS (Task 3 step 1) et JS (Task 3 step 2). `marches_table` et `th.dash-header` cohérents entre Task 2 et Task 4.
|
||||
@@ -0,0 +1,452 @@
|
||||
# Migration des emails transactionnels vers Brevo — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer le transport SMTP/Flask-Mail des emails transactionnels (vérification d'adresse, reset de mot de passe) par l'API Brevo et ses templates hébergés, sans changer l'interface publique du mailer.
|
||||
|
||||
**Architecture:** `src/auth/mailer.py` est réécrit pour construire un client `Brevo` (SDK v5) et envoyer via `client.transactional_emails.send_transac_email(template_id, params, sender, to)`. Les deux fonctions publiques (`send_verification_email`, `send_reset_email`) gardent leur signature, donc `src/auth/routes.py` n'est pas touché. Seul l'appel d'init dans `setup.py` change (`init_mailer()` sans `app`).
|
||||
|
||||
**Tech Stack:** Python, Flask, SDK `brevo-python` v5 (`5.0.0rc1`), pytest, monkeypatch.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex: `from src.utils import logger`).
|
||||
- SDK épinglé strictement : `brevo-python==5.0.0rc1` (pré-release ; un specifier qui pointe exactement une rc autorise pip à l'installer sans `--pre` global).
|
||||
- API v5 vérifiée par introspection :
|
||||
- `from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem`
|
||||
- `from brevo.core.api_error import ApiError`
|
||||
- `Brevo(api_key: str, headers: dict | None = None)`
|
||||
- `client.transactional_emails.send_transac_email(template_id=int, params=dict, sender=SendTransacEmailRequestSender, to=[SendTransacEmailRequestToItem], headers=dict|None)`
|
||||
- `SendTransacEmailRequestSender(email=..., name=...)`, `SendTransacEmailRequestToItem(email=..., name=...)`
|
||||
- Tests unitaires hermétiques : aucun appel réseau (le client est monkeypatché).
|
||||
- Commits fréquents, un par tâche.
|
||||
- Lancer les tests avec `uv run pytest` (pas `source .venv/bin/activate`).
|
||||
- Le hook pre-commit (prettier/ruff) peut reformater des fichiers : si un commit échoue pour cause de reformatage, `git add` les fichiers modifiés et recommitter.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Fichier | Responsabilité | Action |
|
||||
| --------------------------------------- | ------------------------------------------ | ----------------------------------------- |
|
||||
| `pyproject.toml` | Dépendances | Modifier (swap flask-mail → brevo-python) |
|
||||
| `src/auth/mailer.py` | Envoi des emails transactionnels via Brevo | Réécrire |
|
||||
| `src/auth/setup.py` | Initialisation du blueprint auth | Modifier (1 ligne) |
|
||||
| `tests/auth/test_mailer.py` | Tests unitaires du mailer (mock) | Réécrire |
|
||||
| `tests/auth/test_mailer_integration.py` | Test d'intégration sandbox optionnel | Créer |
|
||||
| `.template.env` | Documentation des variables d'env | Modifier |
|
||||
| `src/auth/templates/emails/*` | Anciens templates Jinja | Supprimer |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Basculer la dépendance vers le SDK Brevo
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `pyproject.toml:25`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: (rien)
|
||||
- Produces: le paquet `brevo` importable dans l'environnement.
|
||||
|
||||
- [ ] **Step 1: Remplacer la dépendance dans `pyproject.toml`**
|
||||
|
||||
Remplacer la ligne 25 :
|
||||
|
||||
```toml
|
||||
"flask-mail",
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```toml
|
||||
"brevo-python==5.0.0rc1",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Réinstaller les dépendances du projet**
|
||||
|
||||
Run: `uv pip install -e . --group=dev`
|
||||
Expected: installation réussie, `brevo-python 5.0.0rc1` installé, `flask-mail` retiré.
|
||||
|
||||
- [ ] **Step 3: Vérifier que le SDK s'importe avec les symboles attendus**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run python -c "from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem; from brevo.core.api_error import ApiError; print('brevo v5 OK')"
|
||||
```
|
||||
|
||||
Expected: affiche `brevo v5 OK` sans erreur.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add pyproject.toml
|
||||
git commit -m "build(brevo): remplacer flask-mail par brevo-python v5 (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Réécrire le mailer pour utiliser l'API Brevo (TDD)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `tests/auth/test_mailer.py` (réécriture complète)
|
||||
- Modify: `src/auth/mailer.py` (réécriture complète)
|
||||
- Modify: `src/auth/setup.py:39`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `brevo.Brevo`, `brevo.SendTransacEmailRequestSender`, `brevo.SendTransacEmailRequestToItem`, `brevo.core.api_error.ApiError`, `src.utils.logger`.
|
||||
- Produces (interface publique, inchangée pour `routes.py`) :
|
||||
- `init_mailer() -> None`
|
||||
- `send_verification_email(email: str, token: str) -> None`
|
||||
- `send_reset_email(email: str, token: str) -> None`
|
||||
- Variables d'env lues : `BREVO_API_KEY`, `BREVO_SANDBOX`, `BREVO_TEMPLATE_VERIFY_ID`, `BREVO_TEMPLATE_RESET_ID`, `MAIL_FROM`, `MAIL_FROM_NAME`, `APP_BASE_URL`.
|
||||
|
||||
- [ ] **Step 1: Réécrire le test du mailer (mock du client Brevo)**
|
||||
|
||||
Remplacer **tout** le contenu de `tests/auth/test_mailer.py` par :
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
from src.auth import mailer
|
||||
|
||||
|
||||
class _FakeTransac:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def send_transac_email(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self):
|
||||
self.transactional_emails = _FakeTransac()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_client(monkeypatch):
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(mailer, "_client", client)
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
|
||||
monkeypatch.setenv("BREVO_TEMPLATE_RESET_ID", "22")
|
||||
monkeypatch.setenv("MAIL_FROM", "noreply@decp.info")
|
||||
monkeypatch.setenv("MAIL_FROM_NAME", "decp.info")
|
||||
return client
|
||||
|
||||
|
||||
def test_send_verification_email(fake_client):
|
||||
mailer.send_verification_email("a@b.c", "TOKEN123")
|
||||
calls = fake_client.transactional_emails.calls
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["template_id"] == 11
|
||||
assert call["to"][0].email == "a@b.c"
|
||||
assert call["sender"].email == "noreply@decp.info"
|
||||
assert "/auth/verify-email?token=TOKEN123" in call["params"]["link"]
|
||||
|
||||
|
||||
def test_send_reset_email(fake_client):
|
||||
mailer.send_reset_email("a@b.c", "RESET456")
|
||||
calls = fake_client.transactional_emails.calls
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["template_id"] == 22
|
||||
assert "reinitialiser-mot-de-passe?token=RESET456" in call["params"]["link"]
|
||||
|
||||
|
||||
def test_init_mailer_builds_client(monkeypatch):
|
||||
monkeypatch.setenv("BREVO_API_KEY", "test-key")
|
||||
monkeypatch.delenv("BREVO_SANDBOX", raising=False)
|
||||
monkeypatch.setattr(mailer, "_client", None)
|
||||
mailer.init_mailer()
|
||||
assert mailer._client is not None
|
||||
|
||||
|
||||
def test_send_without_init_raises(monkeypatch):
|
||||
monkeypatch.setattr(mailer, "_client", None)
|
||||
monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
|
||||
with pytest.raises(AssertionError):
|
||||
mailer.send_verification_email("a@b.c", "TOKEN123")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour les voir échouer**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_mailer.py -v`
|
||||
Expected: FAIL — l'ancien `mailer.py` importe `flask_mail` (désinstallé en Task 1) et n'expose pas `_client` ; erreurs d'import / d'attribut.
|
||||
|
||||
- [ ] **Step 3: Réécrire `src/auth/mailer.py`**
|
||||
|
||||
Remplacer **tout** le contenu de `src/auth/mailer.py` par :
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from brevo import (
|
||||
Brevo,
|
||||
SendTransacEmailRequestSender,
|
||||
SendTransacEmailRequestToItem,
|
||||
)
|
||||
from brevo.core.api_error import ApiError
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
_client: Brevo | None = None
|
||||
|
||||
|
||||
def init_mailer() -> None:
|
||||
"""Construit le client Brevo à partir des variables d'environnement."""
|
||||
global _client
|
||||
api_key = os.getenv("BREVO_API_KEY", "")
|
||||
sandbox = os.getenv("BREVO_SANDBOX", "").lower() == "true"
|
||||
headers = {"X-Sib-Sandbox": "drop"} if sandbox else None
|
||||
_client = Brevo(api_key=api_key, headers=headers)
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.getenv("APP_BASE_URL", "http://localhost:8050").rstrip("/")
|
||||
|
||||
|
||||
def _sender() -> SendTransacEmailRequestSender:
|
||||
return SendTransacEmailRequestSender(
|
||||
email=os.getenv("MAIL_FROM", "noreply@decp.info"),
|
||||
name=os.getenv("MAIL_FROM_NAME", "decp.info"),
|
||||
)
|
||||
|
||||
|
||||
def _template_id(env_var: str) -> int:
|
||||
raw = os.getenv(env_var, "")
|
||||
if not raw:
|
||||
raise RuntimeError(f"{env_var} non défini (template Brevo)")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _send_template(template_id: int, recipient: str, params: dict) -> None:
|
||||
assert _client is not None, "Mailer non initialisé (init_mailer() non appelé)"
|
||||
try:
|
||||
_client.transactional_emails.send_transac_email(
|
||||
template_id=template_id,
|
||||
params=params,
|
||||
sender=_sender(),
|
||||
to=[SendTransacEmailRequestToItem(email=recipient)],
|
||||
)
|
||||
except ApiError:
|
||||
logger.exception(
|
||||
"Échec d'envoi Brevo (template %s) à %s", template_id, recipient
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def send_verification_email(email: str, token: str) -> None:
|
||||
link = f"{_base_url()}/auth/verify-email?token={token}"
|
||||
_send_template(
|
||||
_template_id("BREVO_TEMPLATE_VERIFY_ID"), email, {"link": link}
|
||||
)
|
||||
|
||||
|
||||
def send_reset_email(email: str, token: str) -> None:
|
||||
link = f"{_base_url()}/reinitialiser-mot-de-passe?token={token}"
|
||||
_send_template(
|
||||
_template_id("BREVO_TEMPLATE_RESET_ID"), email, {"link": link}
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mettre à jour l'appel d'init dans `setup.py`**
|
||||
|
||||
Dans `src/auth/setup.py`, ligne 39, remplacer :
|
||||
|
||||
```python
|
||||
mailer.init_mailer(app)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
mailer.init_mailer()
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer les tests du mailer pour les voir passer**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_mailer.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 6: Lancer toute la suite auth pour vérifier l'absence de régression**
|
||||
|
||||
Run: `uv run pytest tests/auth/ -v`
|
||||
Expected: PASS (aucune dépendance résiduelle à flask-mail ; `routes.py` inchangé fonctionne).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/mailer.py src/auth/setup.py tests/auth/test_mailer.py
|
||||
git commit -m "feat(brevo): envoyer les emails transactionnels via l'API Brevo v5 (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Nettoyer la configuration et les templates Jinja
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `.template.env`
|
||||
- Delete: `src/auth/templates/emails/verify_email.html`, `verify_email.txt`, `reset_password.html`, `reset_password.txt` (tout le dossier `src/auth/templates/`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: (rien — `mailer.py` n'utilise plus de templates locaux après Task 2)
|
||||
- Produces: (rien)
|
||||
|
||||
- [ ] **Step 1: Vérifier que les variables legacy ne sont pas utilisées ailleurs**
|
||||
|
||||
Run: `grep -rn "SENDER_SERVER_DOMAIN\|LOGIN_EMAIL\|FROM_EMAIL\|TO_EMAIL" . --include=*.py`
|
||||
Expected: aucun résultat. (Si des résultats apparaissent hors `.template.env`, NE PAS supprimer ces variables et le signaler.)
|
||||
|
||||
- [ ] **Step 2: Mettre à jour `.template.env`**
|
||||
|
||||
Dans la section SMTP de `.template.env`, supprimer les lignes :
|
||||
|
||||
```
|
||||
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
|
||||
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
|
||||
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
|
||||
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
|
||||
# SMTP pour envoi d'emails (vérification email, reset mot de passe)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=True
|
||||
MAIL_SUPPRESS_SEND= # laisser vide pour hériter de DEVELOPMENT ; mettre false pour forcer l'envoi en mode dev
|
||||
```
|
||||
|
||||
et les remplacer par :
|
||||
|
||||
```
|
||||
# Brevo — envoi des emails transactionnels (vérification email, reset mot de passe)
|
||||
BREVO_API_KEY= # clé API transactionnelle Brevo
|
||||
BREVO_TEMPLATE_VERIFY_ID= # ID numérique du template "vérification d'adresse"
|
||||
BREVO_TEMPLATE_RESET_ID= # ID numérique du template "réinitialisation mot de passe"
|
||||
BREVO_SANDBOX= # mettre "true" pour valider sans délivrer (dev / intégration)
|
||||
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
|
||||
```
|
||||
|
||||
Conserver la variable `APP_BASE_URL` si elle est déjà présente ailleurs dans le fichier ; sinon l'ajouter :
|
||||
|
||||
```
|
||||
APP_BASE_URL=http://localhost:8050 # base des liens dans les emails
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Supprimer les templates Jinja d'email devenus inutiles**
|
||||
|
||||
Run: `git rm -r src/auth/templates`
|
||||
Expected: les 4 fichiers `emails/*.{html,txt}` sont supprimés.
|
||||
|
||||
- [ ] **Step 4: Vérifier qu'aucun code ne référence encore ces templates**
|
||||
|
||||
Run: `grep -rn "verify_email\|reset_password\|render_template\|jinja_loader\|MAIL_SUPPRESS_SEND\|flask_mail" src/ tests/ --include=*.py`
|
||||
Expected: aucun résultat.
|
||||
|
||||
- [ ] **Step 5: Relancer la suite auth complète**
|
||||
|
||||
Run: `uv run pytest tests/auth/ -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add .template.env src/auth/templates
|
||||
git commit -m "chore(brevo): retirer la config SMTP et les templates Jinja d'email (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Test d'intégration sandbox optionnel
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/auth/test_mailer_integration.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: interface publique `mailer.init_mailer()`, `mailer.send_verification_email(email, token)`.
|
||||
- Produces: un test `@pytest.mark.integration` skippé sauf si `BREVO_API_KEY` est défini.
|
||||
|
||||
- [ ] **Step 1: Créer le test d'intégration**
|
||||
|
||||
Créer `tests/auth/test_mailer_integration.py` :
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from src.auth import mailer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("BREVO_API_KEY"),
|
||||
reason="BREVO_API_KEY absent — test d'intégration Brevo ignoré",
|
||||
)
|
||||
def test_send_verification_email_sandbox(monkeypatch):
|
||||
# Force le mode sandbox : Brevo valide la requête sans délivrer.
|
||||
monkeypatch.setenv("BREVO_SANDBOX", "true")
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
mailer.init_mailer()
|
||||
# Ne doit pas lever : l'API Brevo accepte la requête en sandbox.
|
||||
mailer.send_verification_email(
|
||||
os.getenv("MAIL_FROM", "noreply@decp.info"), "INTEGRATION_TOKEN"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Enregistrer le marker `integration` (si absent)**
|
||||
|
||||
Vérifier la présence du marker dans `pyproject.toml` :
|
||||
|
||||
Run: `grep -n "integration" pyproject.toml`
|
||||
|
||||
Si aucune section `[tool.pytest.ini_options]` avec `markers` ne déclare `integration`, l'ajouter. Exemple à insérer/compléter dans `pyproject.toml` :
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"integration: tests touchant des services externes (Brevo) ; skippés par défaut en CI",
|
||||
]
|
||||
```
|
||||
|
||||
(Si la section existe déjà, n'ajouter que la ligne `integration: ...` dans la liste `markers` existante, sans dupliquer la section.)
|
||||
|
||||
- [ ] **Step 3: Vérifier que le test est bien collecté puis skippé sans clé**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_mailer_integration.py -v`
|
||||
Expected: 1 test SKIPPED (motif « BREVO_API_KEY absent »), aucun appel réseau.
|
||||
|
||||
- [ ] **Step 4: Vérifier que la suite par défaut reste verte**
|
||||
|
||||
Run: `uv run pytest tests/auth/ -v`
|
||||
Expected: PASS, avec le test d'intégration SKIPPED.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/auth/test_mailer_integration.py pyproject.toml
|
||||
git commit -m "test(brevo): test d'intégration sandbox optionnel (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes de vérification finale (manuel, hors CI)
|
||||
|
||||
Avant de merger, vérification manuelle réelle :
|
||||
|
||||
1. Renseigner `.env` avec `BREVO_API_KEY`, `BREVO_TEMPLATE_VERIFY_ID`, `BREVO_TEMPLATE_RESET_ID`, `MAIL_FROM` (expéditeur vérifié Brevo) et `BREVO_SANDBOX=true`.
|
||||
2. Lancer `python run.py`, déclencher une inscription, vérifier dans les logs Brevo (tableau de bord) que la requête est reçue (sandbox = acceptée, non délivrée).
|
||||
3. Passer `BREVO_SANDBOX=` (vide), refaire une inscription avec une vraie adresse, confirmer la réception et que `{{ params.link }}` est correctement substitué dans le template.
|
||||
4. Confirmer la valeur du header sandbox (`X-Sib-Sandbox: drop`) si le comportement diffère de l'attendu.
|
||||
@@ -0,0 +1,852 @@
|
||||
# Connexion avec LinkedIn (OIDC) — 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:** Permettre la création de compte / connexion via LinkedIn (OIDC) en parallèle de l'auth email+mot de passe existante.
|
||||
|
||||
**Architecture:** Authlib (intégration Flask) gère le flux OIDC LinkedIn. Deux routes (`/auth/linkedin`, `/auth/linkedin/callback`) s'ajoutent au blueprint `auth` existant. Une fonction `resolve_oauth_user` relie l'identité LinkedIn à un compte (existant par email, ou nouveau). Le schéma SQLite gagne une table `oauth_identities` et rend `password_hash` nullable.
|
||||
|
||||
**Tech Stack:** Flask, Flask-Login, Authlib, SQLite, Dash (pages), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `from src.auth import db`).
|
||||
- Tests : `uv run pytest` (pas de `source .venv/bin/activate`).
|
||||
- Provider unique : `linkedin`. Pas d'abstraction multi-provider.
|
||||
- On ne stocke que l'email (pas de nom/photo).
|
||||
- Liaison automatique par email (l'email LinkedIn est vérifié).
|
||||
- Libellé du bouton : exactement « Connexion avec LinkedIn ».
|
||||
- Couleur du bouton : fond `rgb(10, 102, 194)`, texte blanc.
|
||||
- Discovery OIDC LinkedIn : `https://www.linkedin.com/oauth/.well-known/openid-configuration`.
|
||||
- Scopes : `openid profile email`.
|
||||
- URL de callback construite depuis `APP_BASE_URL` : `{APP_BASE_URL}/auth/linkedin/callback`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schéma DB — `password_hash` nullable + table `oauth_identities` + helpers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/db.py`
|
||||
- Test: `tests/auth/test_oauth_db.py` (create)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: helpers existants `get_conn`, `init_schema`, `get_user_by_email`, `get_user_by_id`, `_now`.
|
||||
- Produces:
|
||||
|
||||
- `create_oauth_user(email: str) -> int` — crée un user `password_hash = NULL`, `email_verified = 1`, renvoie l'id.
|
||||
- `get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None`.
|
||||
- `link_oauth_identity(provider: str, subject: str, user_id: int) -> None`.
|
||||
- Schéma : `users.password_hash` nullable ; table `oauth_identities(provider, subject, user_id, created_at)` PK `(provider, subject)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/auth/test_oauth_db.py`:
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.auth import db
|
||||
|
||||
|
||||
def test_oauth_identities_table_created(users_db_path):
|
||||
db.init_schema()
|
||||
tables = {
|
||||
r[0]
|
||||
for r in db.get_conn().execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
assert "oauth_identities" in tables
|
||||
|
||||
|
||||
def test_password_hash_is_nullable_on_fresh_schema(users_db_path):
|
||||
db.init_schema()
|
||||
cols = {r["name"]: r for r in db.get_conn().execute("PRAGMA table_info(users)")}
|
||||
assert cols["password_hash"]["notnull"] == 0
|
||||
|
||||
|
||||
def test_create_oauth_user(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_oauth_user("oauth@example.com")
|
||||
row = db.get_user_by_id(uid)
|
||||
assert row["email"] == "oauth@example.com"
|
||||
assert row["password_hash"] is None
|
||||
assert row["email_verified"] == 1
|
||||
|
||||
|
||||
def test_link_and_get_oauth_identity(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_oauth_user("oauth@example.com")
|
||||
assert db.get_oauth_identity("linkedin", "sub-123") is None
|
||||
db.link_oauth_identity("linkedin", "sub-123", uid)
|
||||
row = db.get_oauth_identity("linkedin", "sub-123")
|
||||
assert row["user_id"] == uid
|
||||
|
||||
|
||||
def test_oauth_identity_pk_prevents_duplicate(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_oauth_user("oauth@example.com")
|
||||
db.link_oauth_identity("linkedin", "sub-123", uid)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.link_oauth_identity("linkedin", "sub-123", uid)
|
||||
|
||||
|
||||
def test_migration_makes_legacy_password_hash_nullable(users_db_path):
|
||||
# Simule un schéma hérité où password_hash est NOT NULL.
|
||||
conn = db.get_conn()
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE email_verification_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
now = db._now()
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, email, password_hash, email_verified, created_at, updated_at)"
|
||||
" VALUES (1, 'legacy@example.com', 'hash', 1, ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO email_verification_tokens (token_hash, user_id, expires_at, created_at)"
|
||||
" VALUES ('tok', 1, ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
|
||||
db.init_schema() # déclenche _migrate
|
||||
|
||||
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(users)")}
|
||||
assert cols["password_hash"]["notnull"] == 0
|
||||
# Données préservées, pas de cascade-delete déclenchée pendant la reconstruction.
|
||||
assert db.get_user_by_id(1)["email"] == "legacy@example.com"
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM email_verification_tokens"
|
||||
).fetchone()[0] == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_db.py -v`
|
||||
Expected: FAIL (`oauth_identities` absent, `create_oauth_user` non défini, `password_hash` encore `notnull=1`).
|
||||
|
||||
- [ ] **Step 3: Update schema and add helpers in `src/auth/db.py`**
|
||||
|
||||
In `USERS_SCHEMA`, change the `password_hash` line to nullable and append the new table. The `users` CREATE becomes:
|
||||
|
||||
```python
|
||||
USERS_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, subject),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
```
|
||||
|
||||
Replace `_migrate` with a version that also drops the `NOT NULL` on legacy DBs, and add the rebuild helper:
|
||||
|
||||
```python
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
|
||||
if "pending_email" not in cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN pending_email TEXT")
|
||||
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
|
||||
if cols.get("password_hash") and cols["password_hash"]["notnull"] == 1:
|
||||
_rebuild_users_password_nullable(conn)
|
||||
|
||||
|
||||
def _rebuild_users_password_nullable(conn: sqlite3.Connection) -> None:
|
||||
# SQLite ne peut pas retirer un NOT NULL via ALTER : on reconstruit la table.
|
||||
# foreign_keys OFF pour éviter le cascade-delete pendant le DROP.
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO users_new (id, email, password_hash, email_verified, "
|
||||
"pending_email, created_at, updated_at) "
|
||||
"SELECT id, email, password_hash, email_verified, pending_email, "
|
||||
"created_at, updated_at FROM users"
|
||||
)
|
||||
conn.execute("DROP TABLE users")
|
||||
conn.execute("ALTER TABLE users_new RENAME TO users")
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
```
|
||||
|
||||
Append the new helpers at the end of `src/auth/db.py`:
|
||||
|
||||
```python
|
||||
def create_oauth_user(email: str) -> int:
|
||||
conn = get_conn()
|
||||
now = _now()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (email, password_hash, email_verified, created_at, updated_at) "
|
||||
"VALUES (?, NULL, 1, ?, ?)",
|
||||
(email.lower(), now, now),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM oauth_identities WHERE provider = ? AND subject = ?",
|
||||
(provider, subject),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def link_oauth_identity(provider: str, subject: str, user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT INTO oauth_identities (provider, subject, user_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(provider, subject, user_id, _now()),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_db.py tests/auth/test_db.py -v`
|
||||
Expected: PASS (new tests + existing db tests still green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/db.py tests/auth/test_oauth_db.py
|
||||
git commit -m "feat(auth): oauth_identities table + password_hash nullable
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Résolution de compte `resolve_oauth_user`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/routes.py`
|
||||
- Test: `tests/auth/test_oauth_resolve.py` (create)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes (from Task 1): `db.get_oauth_identity`, `db.link_oauth_identity`, `db.create_oauth_user`, plus existants `db.get_user_by_email`, `db.get_user_by_id`, `db.set_email_verified`.
|
||||
- Produces:
|
||||
- `resolve_oauth_user(provider: str, subject: str, email: str, email_verified: bool) -> User` — renvoie l'utilisateur (existant lié, lié par email, ou nouvellement créé).
|
||||
|
||||
Placée dans `routes.py` (et non `db.py`) car elle construit un `User`, ce qui éviterait un import circulaire `db` → `models`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/auth/test_oauth_resolve.py`:
|
||||
|
||||
```python
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from src.auth import db
|
||||
from src.auth.models import User
|
||||
from src.auth.routes import resolve_oauth_user
|
||||
|
||||
|
||||
def test_creates_new_user_when_unknown(users_db_path):
|
||||
db.init_schema()
|
||||
user = resolve_oauth_user("linkedin", "sub-1", "new@example.com", True)
|
||||
assert isinstance(user, User)
|
||||
row = db.get_user_by_id(int(user.get_id()))
|
||||
assert row["email"] == "new@example.com"
|
||||
assert row["password_hash"] is None
|
||||
assert row["email_verified"] == 1
|
||||
assert db.get_oauth_identity("linkedin", "sub-1")["user_id"] == row["id"]
|
||||
|
||||
|
||||
def test_links_to_existing_email_account(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_user("alice@example.com", generate_password_hash("password12"))
|
||||
db.set_email_verified(uid)
|
||||
|
||||
user = resolve_oauth_user("linkedin", "sub-2", "alice@example.com", True)
|
||||
assert int(user.get_id()) == uid
|
||||
assert db.get_oauth_identity("linkedin", "sub-2")["user_id"] == uid
|
||||
# Le compte garde son mot de passe.
|
||||
assert db.get_user_by_id(uid)["password_hash"] is not None
|
||||
|
||||
|
||||
def test_links_and_verifies_unverified_existing_account(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_user("bob@example.com", generate_password_hash("password12"))
|
||||
assert db.get_user_by_id(uid)["email_verified"] == 0
|
||||
|
||||
resolve_oauth_user("linkedin", "sub-3", "bob@example.com", True)
|
||||
assert db.get_user_by_id(uid)["email_verified"] == 1
|
||||
|
||||
|
||||
def test_returns_same_user_for_known_identity(users_db_path):
|
||||
db.init_schema()
|
||||
first = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
|
||||
second = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
|
||||
assert first.get_id() == second.get_id()
|
||||
# Pas de doublon d'identité.
|
||||
count = db.get_conn().execute(
|
||||
"SELECT COUNT(*) FROM oauth_identities WHERE provider='linkedin' AND subject='sub-4'"
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_resolve.py -v`
|
||||
Expected: FAIL with `ImportError: cannot import name 'resolve_oauth_user'`.
|
||||
|
||||
- [ ] **Step 3: Implement `resolve_oauth_user` in `src/auth/routes.py`**
|
||||
|
||||
Add after the imports / `_DUMMY_HASH` definition:
|
||||
|
||||
```python
|
||||
def resolve_oauth_user(
|
||||
provider: str, subject: str, email: str, email_verified: bool
|
||||
) -> User:
|
||||
identity = db.get_oauth_identity(provider, subject)
|
||||
if identity is not None:
|
||||
return User(db.get_user_by_id(identity["user_id"]))
|
||||
|
||||
row = db.get_user_by_email(email)
|
||||
if row is not None:
|
||||
db.link_oauth_identity(provider, subject, row["id"])
|
||||
if email_verified and not row["email_verified"]:
|
||||
db.set_email_verified(row["id"])
|
||||
return User(db.get_user_by_id(row["id"]))
|
||||
|
||||
user_id = db.create_oauth_user(email)
|
||||
db.link_oauth_identity(provider, subject, user_id)
|
||||
return User(db.get_user_by_id(user_id))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_resolve.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/routes.py tests/auth/test_oauth_resolve.py
|
||||
git commit -m "feat(auth): resolve_oauth_user (liaison/création de compte OIDC)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Initialisation Authlib + dépendance + variables d'env
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/auth/oauth.py`
|
||||
- Modify: `src/auth/setup.py`
|
||||
- Modify: `pyproject.toml`
|
||||
- Modify: `.template.env`
|
||||
- Modify: `tests/auth/conftest.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
- `src.auth.oauth.oauth` — instance `authlib.integrations.flask_client.OAuth` (registre).
|
||||
- `src.auth.oauth.init_oauth(app: Flask) -> None` — init + register du provider `linkedin`.
|
||||
- `init_auth` appelle `init_oauth(app)`.
|
||||
- Consumes: env `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
|
||||
|
||||
- [ ] **Step 1: Add the dependency**
|
||||
|
||||
In `pyproject.toml`, add `"authlib"` to the `dependencies` list (alphabetical-ish, near `email-validator`):
|
||||
|
||||
```toml
|
||||
"email-validator",
|
||||
"authlib",
|
||||
```
|
||||
|
||||
Then install it:
|
||||
|
||||
Run: `uv pip install authlib`
|
||||
Expected: installs Authlib successfully.
|
||||
|
||||
- [ ] **Step 2: Create `src/auth/oauth.py`**
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from flask import Flask
|
||||
|
||||
LINKEDIN_DISCOVERY_URL = (
|
||||
"https://www.linkedin.com/oauth/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
oauth = OAuth()
|
||||
|
||||
|
||||
def init_oauth(app: Flask) -> None:
|
||||
oauth.init_app(app)
|
||||
oauth.register(
|
||||
name="linkedin",
|
||||
client_id=os.getenv("LINKEDIN_CLIENT_ID"),
|
||||
client_secret=os.getenv("LINKEDIN_CLIENT_SECRET"),
|
||||
server_metadata_url=LINKEDIN_DISCOVERY_URL,
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire it into `init_auth`**
|
||||
|
||||
In `src/auth/setup.py`, after `app.register_blueprint(auth_bp)` and before/after the CSRF init, add the import and call. Add near the other `from src.auth...` imports at top:
|
||||
|
||||
```python
|
||||
from src.auth.oauth import init_oauth
|
||||
```
|
||||
|
||||
And inside `init_auth`, after `app.register_blueprint(auth_bp)`:
|
||||
|
||||
```python
|
||||
init_oauth(app)
|
||||
```
|
||||
|
||||
Also warn if LinkedIn isn't configured — append to the bottom of `init_auth`, next to the existing BREVO warning:
|
||||
|
||||
```python
|
||||
if not os.getenv("LINKEDIN_CLIENT_ID"):
|
||||
logger.warning(
|
||||
"LINKEDIN_CLIENT_ID non défini : la connexion LinkedIn échouera. "
|
||||
"Définissez LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET dans .env."
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add env template entries**
|
||||
|
||||
In `.template.env`, under the `# Comptes utilisateurs` section, add:
|
||||
|
||||
```bash
|
||||
# Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal,
|
||||
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
|
||||
# {APP_BASE_URL}/auth/linkedin/callback
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Set test env so `init_auth` can register the provider**
|
||||
|
||||
In `tests/auth/conftest.py`, in the `app` fixture, set dummy LinkedIn credentials before `init_auth(app)`:
|
||||
|
||||
```python
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("LINKEDIN_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("LINKEDIN_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
app = Flask(__name__)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the app still boots under tests**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_app_integration.py -v`
|
||||
Expected: PASS (registering the provider is lazy — no network call at init).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/oauth.py src/auth/setup.py pyproject.toml .template.env tests/auth/conftest.py
|
||||
git commit -m "feat(auth): init Authlib + provider LinkedIn (config env)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Routes `/auth/linkedin` et `/auth/linkedin/callback`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/routes.py`
|
||||
- Test: `tests/auth/test_oauth_routes.py` (create)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `resolve_oauth_user` (Task 2), `oauth` (Task 3), `safe_next`, `login_user`, `session`.
|
||||
- Produces: routes `GET /auth/linkedin`, `GET /auth/linkedin/callback`. Redirections d'erreur vers `/connexion?error=oauth_cancelled|oauth_failed`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/auth/test_oauth_routes.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
from src.auth import db
|
||||
from src.auth import oauth as oauth_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_userinfo(monkeypatch):
|
||||
"""Patche authorize_access_token pour éviter tout appel réseau."""
|
||||
|
||||
state = {"userinfo": None, "raise": False}
|
||||
|
||||
def _authorize_access_token():
|
||||
if state["raise"]:
|
||||
raise RuntimeError("token exchange failed")
|
||||
return {"userinfo": state["userinfo"]}
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauth_module.oauth.linkedin,
|
||||
"authorize_access_token",
|
||||
_authorize_access_token,
|
||||
raising=False,
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def test_login_route_redirects_to_linkedin(client, monkeypatch):
|
||||
from flask import redirect
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauth_module.oauth.linkedin,
|
||||
"authorize_redirect",
|
||||
lambda redirect_uri, **kw: redirect("https://www.linkedin.com/oauth/authorize"),
|
||||
raising=False,
|
||||
)
|
||||
resp = client.get("/auth/linkedin")
|
||||
assert resp.status_code == 302
|
||||
assert "linkedin.com" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_callback_creates_user_and_logs_in(client, fake_userinfo, users_db_path):
|
||||
db.init_schema()
|
||||
fake_userinfo["userinfo"] = {
|
||||
"sub": "sub-xyz",
|
||||
"email": "newbie@example.com",
|
||||
"email_verified": True,
|
||||
}
|
||||
resp = client.get("/auth/linkedin/callback")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/compte/admin")
|
||||
assert db.get_user_by_email("newbie@example.com") is not None
|
||||
|
||||
|
||||
def test_callback_without_email_fails(client, fake_userinfo, users_db_path):
|
||||
db.init_schema()
|
||||
fake_userinfo["userinfo"] = {"sub": "sub-noemail", "email_verified": True}
|
||||
resp = client.get("/auth/linkedin/callback")
|
||||
assert resp.status_code == 302
|
||||
assert "error=oauth_failed" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_callback_token_error_redirects(client, fake_userinfo, users_db_path):
|
||||
db.init_schema()
|
||||
fake_userinfo["raise"] = True
|
||||
resp = client.get("/auth/linkedin/callback")
|
||||
assert resp.status_code == 302
|
||||
assert "error=oauth_failed" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_callback_user_cancelled_redirects(client, users_db_path):
|
||||
db.init_schema()
|
||||
resp = client.get("/auth/linkedin/callback?error=user_cancelled_login")
|
||||
assert resp.status_code == 302
|
||||
assert "error=oauth_cancelled" in resp.headers["Location"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -v`
|
||||
Expected: FAIL (routes 404 / not defined).
|
||||
|
||||
- [ ] **Step 3: Implement the routes in `src/auth/routes.py`**
|
||||
|
||||
Add `session` to the flask import line:
|
||||
|
||||
```python
|
||||
from flask import Blueprint, redirect, request, session
|
||||
```
|
||||
|
||||
Add the import of the oauth registry near the top imports:
|
||||
|
||||
```python
|
||||
from src.auth.oauth import oauth
|
||||
```
|
||||
|
||||
Append the two routes at the end of the file:
|
||||
|
||||
```python
|
||||
@auth_bp.route("/linkedin", methods=["GET"])
|
||||
def linkedin_login():
|
||||
session["oauth_next"] = safe_next(
|
||||
request.args.get("next"), fallback="/compte/admin"
|
||||
)
|
||||
redirect_uri = f"{os.getenv('APP_BASE_URL', '')}/auth/linkedin/callback"
|
||||
return oauth.linkedin.authorize_redirect(redirect_uri)
|
||||
|
||||
|
||||
@auth_bp.route("/linkedin/callback", methods=["GET"])
|
||||
def linkedin_callback():
|
||||
next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin")
|
||||
if request.args.get("error"):
|
||||
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
|
||||
return _redirect_with_error("/connexion", "oauth_cancelled")
|
||||
try:
|
||||
token = oauth.linkedin.authorize_access_token()
|
||||
except Exception:
|
||||
logger.exception("Échec de l'échange de token LinkedIn")
|
||||
return _redirect_with_error("/connexion", "oauth_failed")
|
||||
|
||||
userinfo = token.get("userinfo") or {}
|
||||
subject = userinfo.get("sub")
|
||||
email = (userinfo.get("email") or "").strip().lower()
|
||||
if not subject or not email:
|
||||
logger.error("Réponse LinkedIn sans sub/email : %s", userinfo)
|
||||
return _redirect_with_error("/connexion", "oauth_failed")
|
||||
|
||||
user = resolve_oauth_user(
|
||||
"linkedin", subject, email, bool(userinfo.get("email_verified"))
|
||||
)
|
||||
login_user(user, remember=True)
|
||||
return redirect(next_url)
|
||||
```
|
||||
|
||||
Add the `os` import at the top of the file if absent:
|
||||
|
||||
```python
|
||||
import os
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Run the full auth suite**
|
||||
|
||||
Run: `uv run pytest tests/auth -v`
|
||||
Expected: PASS (all auth tests green).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/routes.py tests/auth/test_oauth_routes.py
|
||||
git commit -m "feat(auth): routes /auth/linkedin et callback OIDC
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Bouton « Connexion avec LinkedIn » sur `/connexion` et `/inscription`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/connexion.py`
|
||||
- Modify: `src/pages/inscription.py`
|
||||
- Test: `tests/auth/test_oauth_routes.py` (extend — render assertions)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: route `/auth/linkedin` (Task 4).
|
||||
- Produces: lien `<a href="/auth/linkedin">` stylé, libellé « Connexion avec LinkedIn », + messages d'erreur oauth.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/auth/test_oauth_routes.py`:
|
||||
|
||||
```python
|
||||
def test_connexion_layout_has_linkedin_button():
|
||||
from src.pages.connexion import layout
|
||||
|
||||
html_str = str(layout())
|
||||
assert "Connexion avec LinkedIn" in html_str
|
||||
assert "/auth/linkedin" in html_str
|
||||
|
||||
|
||||
def test_connexion_layout_shows_oauth_error():
|
||||
from src.pages.connexion import layout
|
||||
|
||||
assert "LinkedIn" in str(layout(error="oauth_failed"))
|
||||
|
||||
|
||||
def test_inscription_layout_has_linkedin_button():
|
||||
from src.pages.inscription import layout
|
||||
|
||||
html_str = str(layout())
|
||||
assert "Connexion avec LinkedIn" in html_str
|
||||
assert "/auth/linkedin" in html_str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -k layout -v`
|
||||
Expected: FAIL (button/text absent).
|
||||
|
||||
- [ ] **Step 3: Add a shared button helper and use it**
|
||||
|
||||
In `src/pages/connexion.py`, add the error messages and the button. Extend `ERROR_MESSAGES`:
|
||||
|
||||
```python
|
||||
ERROR_MESSAGES = {
|
||||
"invalid_credentials": "Identifiants invalides.",
|
||||
"email_not_verified": "Vérifiez d'abord votre adresse email (consultez votre boîte de réception).",
|
||||
"oauth_cancelled": "Connexion LinkedIn annulée.",
|
||||
"oauth_failed": "Échec de la connexion via LinkedIn. Réessayez.",
|
||||
}
|
||||
```
|
||||
|
||||
Add this helper (above `layout`) in `connexion.py`:
|
||||
|
||||
```python
|
||||
def linkedin_button():
|
||||
return html.A(
|
||||
"Connexion avec LinkedIn",
|
||||
href="/auth/linkedin",
|
||||
className="btn w-100 mb-2",
|
||||
style={"backgroundColor": "rgb(10, 102, 194)", "color": "white"},
|
||||
)
|
||||
```
|
||||
|
||||
In `connexion.py` `layout`, insert a separator + button between the `html.Form(...)` and the `html.Hr()`:
|
||||
|
||||
```python
|
||||
html.Form(
|
||||
...
|
||||
),
|
||||
html.Div("ou", className="text-center text-muted my-2"),
|
||||
linkedin_button(),
|
||||
html.Hr(),
|
||||
```
|
||||
|
||||
In `src/pages/inscription.py`, import the helper and place it likewise. At the top, after the existing imports:
|
||||
|
||||
```python
|
||||
from src.pages.connexion import linkedin_button
|
||||
```
|
||||
|
||||
In `inscription.py` `layout`, insert between the `html.Form(...)` and `html.Hr()`:
|
||||
|
||||
```python
|
||||
html.Form(
|
||||
...
|
||||
),
|
||||
html.Div("ou", className="text-center text-muted my-2"),
|
||||
linkedin_button(),
|
||||
html.Hr(),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -k layout -v`
|
||||
Expected: PASS (3 layout tests).
|
||||
|
||||
- [ ] **Step 5: Manual smoke check (no network)**
|
||||
|
||||
Run: `uv run python -c "from src.pages.connexion import layout; from src.pages.inscription import layout as l2; assert 'Connexion avec LinkedIn' in str(layout()) and 'Connexion avec LinkedIn' in str(l2()); print('ok')"`
|
||||
Expected: prints `ok`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/connexion.py src/pages/inscription.py tests/auth/test_oauth_routes.py
|
||||
git commit -m "feat(auth): bouton Connexion avec LinkedIn sur connexion/inscription
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Vérification finale
|
||||
|
||||
- [ ] **Step 1: Run the whole test suite touched by this work**
|
||||
|
||||
Run: `uv run pytest tests/auth -v`
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 2: Confirm the docs/spec prerequisites are accurate**
|
||||
|
||||
Re-read `docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md` § Prérequis. Ensure the redirect URIs and env var names match what was implemented (`/auth/linkedin/callback`, `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
|
||||
|
||||
- [ ] **Step 3: No commit needed if nothing changed.** Otherwise commit doc fixes.
|
||||
|
||||
---
|
||||
|
||||
## Manual setup required before LinkedIn login works in production
|
||||
|
||||
(Not code — for the project admin.)
|
||||
|
||||
1. LinkedIn Developer Portal → create app → add product « Sign In with LinkedIn using OpenID Connect ».
|
||||
2. Authorized redirect URLs:
|
||||
- `http://localhost:8050/auth/linkedin/callback`
|
||||
- `https://test.decp.info/auth/linkedin/callback`
|
||||
- `https://decp.info/auth/linkedin/callback`
|
||||
3. Copy Client ID / Secret into each environment's `.env` (`LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
|
||||
4. Ensure `APP_BASE_URL` matches the deployment origin in each `.env`.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Accès gratuit pour tous (`TOUS_ABONNES`) — 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:** Un drapeau d'environnement `TOUS_ABONNES` qui ouvre gratuitement les fonctionnalités réservées aux abonnés à tout utilisateur connecté, affiche un bandeau d'info sur `/compte/abonnement` et y grise les boutons « S'abonner ».
|
||||
|
||||
**Architecture:** L'accès est déjà centralisé dans `src/pages/_compte_shell.py::current_user_has_subscription()` (pilote menu + `account_guard`). On y branche le drapeau. La page `/compte/abonnement` lit le même drapeau pour afficher un bandeau et désactiver les boutons. Le contrôle bas-niveau `db.has_active_subscription()` reste inchangé (vrai abonnement payant), donc la redirection post-login et l'anti-double-abonnement gardent leur comportement.
|
||||
|
||||
**Tech Stack:** Python, Dash 3.4, Dash Bootstrap Components, pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.utils`, `src.pages._compte_shell`).
|
||||
- Convention drapeau booléen : `os.getenv("NOM", "False").lower() == "true"` (cf. `DEVELOPMENT` dans `src/utils/__init__.py`).
|
||||
- Drapeau lu via `from src.utils import TOUS_ABONNES` **dans le corps** des fonctions (lecture paresseuse → testable par `monkeypatch.setattr`).
|
||||
- Quand `TOUS_ABONNES` est absent / `false`, comportement actuel strictement inchangé.
|
||||
- Texte exact du bandeau (verbatim) : « Les fonctionnalités normalement accessibles contre un abonnement de 20 € HT par mois sont accessibles à tous et toutes en attendant la validation de mon dossier pour recevoir des paiements. »
|
||||
- Tests lancés avec `uv run pytest`.
|
||||
- Messages de commit en français, terminés par la ligne `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Déblocage de l'accès via `TOUS_ABONNES`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/__init__.py` (ajouter la constante près de `DEVELOPMENT`, ligne ~33)
|
||||
- Modify: `src/pages/_compte_shell.py:41-46` (`current_user_has_subscription`)
|
||||
- Modify: `.template.env` (documenter la variable)
|
||||
- Test: `tests/test_compte_shell.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien (première tâche).
|
||||
- Produces:
|
||||
|
||||
- `src.utils.TOUS_ABONNES: bool` — constante module, défaut `False`.
|
||||
- `src.pages._compte_shell.current_user_has_subscription() -> bool` — renvoie `True` si l'utilisateur est authentifié **et** `TOUS_ABONNES`, sinon le comportement DB existant ; toujours `False` si non authentifié.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Ajouter à la fin de `tests/test_compte_shell.py` :
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _fake_user(authenticated: bool):
|
||||
user = type("U", (), {})()
|
||||
user.is_authenticated = authenticated
|
||||
user.id = 1
|
||||
return user
|
||||
|
||||
|
||||
def test_has_subscription_true_for_authenticated_when_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
with patch("src.pages._compte_shell.current_user", _fake_user(True)):
|
||||
assert shell.current_user_has_subscription() is True
|
||||
|
||||
|
||||
def test_has_subscription_false_for_anonymous_even_with_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
with patch("src.pages._compte_shell.current_user", _fake_user(False)):
|
||||
assert shell.current_user_has_subscription() is False
|
||||
|
||||
|
||||
def test_has_subscription_uses_db_when_flag_off(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
|
||||
with patch("src.pages._compte_shell.current_user", _fake_user(True)), patch(
|
||||
"src.subscriptions.db.has_active_subscription", return_value=False
|
||||
) as mocked:
|
||||
assert shell.current_user_has_subscription() is False
|
||||
mocked.assert_called_once_with(1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/test_compte_shell.py -v`
|
||||
Expected: les 3 nouveaux tests ÉCHOUENT (`test_has_subscription_true_...` échoue car `current_user_has_subscription` ne consulte pas encore `TOUS_ABONNES` ; `monkeypatch.setattr("src.utils.TOUS_ABONNES", True)` réussit seulement si l'attribut existe — sinon `AttributeError`, ce qui confirme aussi qu'il faut créer la constante).
|
||||
|
||||
- [ ] **Step 3: Ajouter la constante dans `src/utils/__init__.py`**
|
||||
|
||||
Juste après la ligne `DEVELOPMENT = os.getenv("DEVELOPMENT", "False").lower() == "true"` (ligne ~33) :
|
||||
|
||||
```python
|
||||
# Accès gratuit temporaire à toutes les fonctionnalités d'abonné, le temps que
|
||||
# la plateforme de paiement Frisbii valide la réception de paiements.
|
||||
TOUS_ABONNES = os.getenv("TOUS_ABONNES", "False").lower() == "true"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Brancher le drapeau dans `current_user_has_subscription`**
|
||||
|
||||
Remplacer la fonction (`src/pages/_compte_shell.py:41-46`) par :
|
||||
|
||||
```python
|
||||
def current_user_has_subscription() -> bool:
|
||||
from src.subscriptions import db
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
if TOUS_ABONNES:
|
||||
return True
|
||||
return db.has_active_subscription(current_user.id)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Documenter la variable dans `.template.env`**
|
||||
|
||||
Ajouter une ligne (par ex. à la suite du bloc abonnement/Frisbii) :
|
||||
|
||||
```
|
||||
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/test_compte_shell.py -v`
|
||||
Expected: les 8 tests PASSENT (5 existants + 3 nouveaux).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/__init__.py src/pages/_compte_shell.py .template.env tests/test_compte_shell.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Ajoute le drapeau TOUS_ABONNES pour l'accès gratuit
|
||||
|
||||
Ouvre les fonctionnalités d'abonné à tout utilisateur connecté tant que
|
||||
Frisbii n'a pas validé la réception de paiements.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Bandeau d'info et boutons « S'abonner » désactivés sur `/compte/abonnement`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte_abonnement.py` (`_plan_card` ~25-57, ajout `_tous_abonnes_banner`, `layout` ~198-221)
|
||||
- Test: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.utils.TOUS_ABONNES` (Task 1).
|
||||
- Produces:
|
||||
|
||||
- `compte_abonnement._tous_abonnes_banner()` — renvoie un `dbc.Alert` (color `info`) contenant le texte verbatim si `TOUS_ABONNES`, sinon `None`.
|
||||
- `_plan_card` rend un bouton désactivé (`className="btn btn-secondary disabled"`, `disabled=True`) quand `TOUS_ABONNES`, sinon le bouton primaire actuel.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Ajouter à `tests/subscriptions/test_compte_abonnement.py` :
|
||||
|
||||
```python
|
||||
def test_subscribe_buttons_disabled_when_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._plan_cards(trial_for=lambda key: 2))
|
||||
assert "btn-secondary disabled" in text
|
||||
assert "btn-primary" not in text
|
||||
|
||||
|
||||
def test_subscribe_buttons_active_when_flag_off(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._plan_cards(trial_for=lambda key: 2))
|
||||
assert "btn-primary" in text
|
||||
|
||||
|
||||
def test_banner_present_when_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._tous_abonnes_banner())
|
||||
assert "accessibles à tous et toutes" in text
|
||||
|
||||
|
||||
def test_banner_absent_when_flag_off(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
assert compte_abonnement._tous_abonnes_banner() is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: les 4 nouveaux tests ÉCHOUENT (`_tous_abonnes_banner` n'existe pas → `AttributeError` ; le bouton reste `btn-primary`).
|
||||
|
||||
- [ ] **Step 3: Désactiver le bouton dans `_plan_card`**
|
||||
|
||||
Dans `src/pages/compte_abonnement.py::_plan_card`, remplacer le bloc `html.Button("S'abonner", ...)` (lignes ~48-50) par une lecture du drapeau. Ajouter en tête de fonction puis le bouton conditionnel :
|
||||
|
||||
```python
|
||||
def _plan_card(meta: dict, trial: int | None, trial_used: bool):
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if trial_used:
|
||||
```
|
||||
|
||||
et remplacer le `html.Button(...)` par :
|
||||
|
||||
```python
|
||||
html.Button(
|
||||
"S'abonner",
|
||||
type="submit",
|
||||
className=(
|
||||
"btn btn-secondary disabled"
|
||||
if TOUS_ABONNES
|
||||
else "btn btn-primary"
|
||||
),
|
||||
disabled=TOUS_ABONNES,
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ajouter le helper `_tous_abonnes_banner`**
|
||||
|
||||
Dans `src/pages/compte_abonnement.py`, ajouter (par ex. juste avant `def layout`) :
|
||||
|
||||
```python
|
||||
def _tous_abonnes_banner():
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if not TOUS_ABONNES:
|
||||
return None
|
||||
return dbc.Alert(
|
||||
"Les fonctionnalités normalement accessibles contre un abonnement de "
|
||||
"20 € HT par mois sont accessibles à tous et toutes en attendant la "
|
||||
"validation de mon dossier pour recevoir des paiements.",
|
||||
color="info",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Insérer le bandeau dans `layout`**
|
||||
|
||||
Dans `src/pages/compte_abonnement.py::layout`, remplacer la construction de `body` (ligne ~214) par une version qui place le bandeau en tête quand présent :
|
||||
|
||||
```python
|
||||
body = [html.H2("Abonnement", className="mb-4")]
|
||||
banner = _tous_abonnes_banner()
|
||||
if banner is not None:
|
||||
body.append(banner)
|
||||
body.extend(_feedback(query))
|
||||
if has_access and row is not None:
|
||||
body.append(_active_view(row))
|
||||
else:
|
||||
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: tous les tests PASSENT (4 existants + 4 nouveaux).
|
||||
|
||||
- [ ] **Step 7: Lancer la suite des tests d'abonnement et du shell**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ tests/test_compte_shell.py -v`
|
||||
Expected: PASS (aucune régression).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte_abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Bandeau TOUS_ABONNES et boutons S'abonner désactivés
|
||||
|
||||
Affiche un bandeau d'info sur /compte/abonnement et grise les boutons
|
||||
S'abonner quand l'accès gratuit pour tous est activé.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
|
||||
- Drapeau d'env `TOUS_ABONNES` → Task 1, Steps 3 & 5.
|
||||
- Accès aux fonctionnalités d'abonné pour tout connecté → Task 1, Step 4 (`current_user_has_subscription`, qui pilote menu + `account_guard`).
|
||||
- Bandeau sur `/compte/abonnement` → Task 2, Steps 4-5.
|
||||
- Boutons « S'abonner » désactivés et gris → Task 2, Step 3.
|
||||
- `db.has_active_subscription` inchangé / redirection post-login inchangée → aucun changement (couvert par l'architecture ; non gating ailleurs vérifié dans la spec).
|
||||
- Comportement inchangé si drapeau off → tests `_flag_off` dans les deux tâches.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code et toutes les commandes sont explicites.
|
||||
|
||||
**Type consistency :** `current_user_has_subscription() -> bool`, `_tous_abonnes_banner() -> dbc.Alert | None`, `TOUS_ABONNES: bool` utilisés de façon cohérente entre tâches et tests.
|
||||
@@ -0,0 +1,990 @@
|
||||
# Sauvegarde des vues du Tableau — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre aux abonné·es d'enregistrer des vues nommées (filtres + tris + colonnes) sur `/tableau`, de les appliquer en un clic, et de les gérer sur `/compte/vues`.
|
||||
|
||||
**Architecture:** Une vue = un nom + la query string `filtres`/`tris`/`colonnes` que `/tableau` produit et restaure déjà. Stockage serveur dans `users.sqlite` (nouveau module `src/saved_views/`). La logique métier (construction de query, validation, builders d'UI) vit dans des fonctions pures testables ; les callbacks Dash ne font que les câbler. Réutilisation du `restore_view_from_url` existant pour appliquer une vue (navigation vers `/tableau?<query>`).
|
||||
|
||||
**Tech Stack:** Python, Dash 3.4, Dash Bootstrap Components, SQLite (`users.sqlite` via `src.auth.db.get_conn()`), Flask-Login (`current_user`), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.saved_views.db`), jamais `saved_views.db`.
|
||||
- Accès DB via `src.auth.db.get_conn()` (connexion thread-local sur `users.sqlite`).
|
||||
- Contrôle d'abonnement via `src.pages._compte_shell.current_user_has_subscription()` (respecte `TOUS_ABONNES`). Ne jamais appeler `db.has_active_subscription()` directement pour le gating UI.
|
||||
- Toute opération DB inclut `user_id` dans le `WHERE` (isolation entre comptes).
|
||||
- Périmètre strict : `/tableau` uniquement. La colonne `table_name` vaut toujours `'tableau'`.
|
||||
- Tests lancés avec `uv run pytest`.
|
||||
- Format/lint : `prettier` (markdown) et `ruff` tournent en pre-commit ; committer du code déjà formaté.
|
||||
|
||||
---
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
| Fichier | Responsabilité |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/saved_views/__init__.py` (créer) | Marqueur de package. |
|
||||
| `src/saved_views/db.py` (créer) | Schéma `saved_views` + CRUD (`init_schema`, `list_views`, `upsert`, `rename`, `delete`, `get`). |
|
||||
| `src/saved_views/ui.py` (créer) | Fonctions **pures** d'UI/validation : `bar_style`, `clean_view_name`, `prepare_view_to_save`, `saved_views_items`, `views_table`. Aucune dépendance à un app Dash (pas de `register_page`/`callback`). |
|
||||
| `src/utils/table.py` (modifier) | Ajouter `build_view_query()` ; refactorer `sync_url_and_reset_button` (dans `tableau.py`) pour l'utiliser. |
|
||||
| `src/app.py` (modifier) | Appeler `saved_views.db.init_schema()` au démarrage. |
|
||||
| `src/pages/tableau.py` (modifier) | Barre « vues » dans `table-menu` + 3 callbacks (visibilité, sauvegarde, remplissage du menu) qui câblent les helpers. |
|
||||
| `src/pages/_compte_shell.py` (modifier) | Ajouter la section `vues` à `SECTIONS`. |
|
||||
| `src/pages/compte_vues.py` (créer) | Page `/compte/vues` (liste/renommer/supprimer), gabarit `compte_admin.py`. |
|
||||
| `tests/saved_views/__init__.py` (créer) | Package de tests. |
|
||||
| `tests/saved_views/conftest.py` (créer) | Fixture `users_db_path` (copie de `tests/subscriptions/conftest.py`). |
|
||||
| `tests/saved_views/test_db.py` (créer) | Tests unitaires du CRUD. |
|
||||
| `tests/saved_views/test_ui.py` (créer) | Tests des fonctions pures d'UI/validation. |
|
||||
| `tests/saved_views/test_build_view_query.py` (créer) | Tests de `build_view_query`. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Module DB `src/saved_views/db.py`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/saved_views/__init__.py`
|
||||
- Create: `src/saved_views/db.py`
|
||||
- Create: `tests/saved_views/__init__.py`
|
||||
- Create: `tests/saved_views/conftest.py`
|
||||
- Create: `tests/saved_views/test_db.py`
|
||||
- Modify: `src/app.py` (après `init_subscriptions(app.server)`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.auth.db.get_conn()`, `src.auth.db.init_schema()`, `src.auth.db.create_user(email, password_hash) -> int`, `src.auth.db.delete_user(user_id)`.
|
||||
- Produces:
|
||||
|
||||
- `SCHEMA: str`
|
||||
- `init_schema() -> None`
|
||||
- `list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]`
|
||||
- `upsert(user_id: int, table_name: str, name: str, query: str) -> None`
|
||||
- `rename(view_id: int, user_id: int, new_name: str) -> None`
|
||||
- `delete(view_id: int, user_id: int) -> None`
|
||||
- `get(view_id: int, user_id: int) -> sqlite3.Row | None`
|
||||
|
||||
- [ ] **Step 1: Créer le package et le fichier de tests vide**
|
||||
|
||||
Créer `src/saved_views/__init__.py` (vide) et `tests/saved_views/__init__.py` (vide).
|
||||
|
||||
Créer `tests/saved_views/conftest.py` (copie de la fixture de `tests/subscriptions/conftest.py`) :
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def users_db_path(monkeypatch, tmp_path):
|
||||
from src.auth.db import reset_conn_for_tests
|
||||
|
||||
db_path = tmp_path / "users.test.sqlite"
|
||||
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||
reset_conn_for_tests()
|
||||
yield db_path
|
||||
reset_conn_for_tests()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Écrire les tests qui échouent**
|
||||
|
||||
Créer `tests/saved_views/test_db.py` :
|
||||
|
||||
```python
|
||||
from src.auth import db as auth_db
|
||||
from src.saved_views import db
|
||||
|
||||
|
||||
def _make_user(email="u@ex.fr"):
|
||||
auth_db.init_schema()
|
||||
return auth_db.create_user(email, "hash")
|
||||
|
||||
|
||||
def test_init_schema_creates_table(users_db_path):
|
||||
db.init_schema()
|
||||
conn = auth_db.get_conn()
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
assert "saved_views" in tables
|
||||
|
||||
|
||||
def test_upsert_creates_and_lists(users_db_path):
|
||||
db.init_schema()
|
||||
uid = _make_user()
|
||||
db.upsert(uid, "tableau", "Ma vue", "filtres=foo")
|
||||
views = db.list_views(uid, "tableau")
|
||||
assert len(views) == 1
|
||||
assert views[0]["name"] == "Ma vue"
|
||||
assert views[0]["query"] == "filtres=foo"
|
||||
|
||||
|
||||
def test_upsert_same_name_overwrites(users_db_path):
|
||||
db.init_schema()
|
||||
uid = _make_user()
|
||||
db.upsert(uid, "tableau", "Ma vue", "filtres=foo")
|
||||
db.upsert(uid, "tableau", "Ma vue", "filtres=bar")
|
||||
views = db.list_views(uid, "tableau")
|
||||
assert len(views) == 1
|
||||
assert views[0]["query"] == "filtres=bar"
|
||||
|
||||
|
||||
def test_list_views_is_isolated_per_user(users_db_path):
|
||||
db.init_schema()
|
||||
uid1 = _make_user("a@ex.fr")
|
||||
uid2 = _make_user("b@ex.fr")
|
||||
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
|
||||
assert db.list_views(uid2, "tableau") == []
|
||||
|
||||
|
||||
def test_rename_only_affects_owner(users_db_path):
|
||||
db.init_schema()
|
||||
uid1 = _make_user("a@ex.fr")
|
||||
uid2 = _make_user("b@ex.fr")
|
||||
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
|
||||
view_id = db.list_views(uid1, "tableau")[0]["id"]
|
||||
db.rename(view_id, uid2, "Pirate") # mauvais propriétaire → no-op
|
||||
assert db.get(view_id, uid1)["name"] == "Vue A"
|
||||
db.rename(view_id, uid1, "Vue B")
|
||||
assert db.get(view_id, uid1)["name"] == "Vue B"
|
||||
|
||||
|
||||
def test_delete_only_affects_owner(users_db_path):
|
||||
db.init_schema()
|
||||
uid1 = _make_user("a@ex.fr")
|
||||
uid2 = _make_user("b@ex.fr")
|
||||
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
|
||||
view_id = db.list_views(uid1, "tableau")[0]["id"]
|
||||
db.delete(view_id, uid2) # mauvais propriétaire → no-op
|
||||
assert db.get(view_id, uid1) is not None
|
||||
db.delete(view_id, uid1)
|
||||
assert db.get(view_id, uid1) is None
|
||||
|
||||
|
||||
def test_views_deleted_on_user_cascade(users_db_path):
|
||||
db.init_schema()
|
||||
uid = _make_user()
|
||||
db.upsert(uid, "tableau", "Vue A", "filtres=a")
|
||||
auth_db.delete_user(uid)
|
||||
assert db.list_views(uid, "tableau") == []
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_db.py -v`
|
||||
Expected: FAIL (ModuleNotFoundError: `src.saved_views.db`).
|
||||
|
||||
- [ ] **Step 4: Écrire `src/saved_views/db.py`**
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.auth.db import get_conn
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS saved_views (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
table_name TEXT NOT NULL DEFAULT 'tableau',
|
||||
name TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE (user_id, table_name, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_views_user
|
||||
ON saved_views(user_id, table_name);
|
||||
"""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def init_schema() -> None:
|
||||
get_conn().executescript(SCHEMA)
|
||||
|
||||
|
||||
def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM saved_views WHERE user_id = ? AND table_name = ? "
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
(user_id, table_name),
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
|
||||
|
||||
def get(view_id: int, user_id: int) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM saved_views WHERE id = ? AND user_id = ?",
|
||||
(view_id, user_id),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def upsert(user_id: int, table_name: str, name: str, query: str) -> None:
|
||||
now = _now()
|
||||
get_conn().execute(
|
||||
"INSERT INTO saved_views "
|
||||
"(user_id, table_name, name, query, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(user_id, table_name, name) DO UPDATE SET "
|
||||
"query = excluded.query, updated_at = excluded.updated_at",
|
||||
(user_id, table_name, name, query, now, now),
|
||||
)
|
||||
|
||||
|
||||
def rename(view_id: int, user_id: int, new_name: str) -> None:
|
||||
get_conn().execute(
|
||||
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?",
|
||||
(new_name, _now(), view_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def delete(view_id: int, user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"DELETE FROM saved_views WHERE id = ? AND user_id = ?",
|
||||
(view_id, user_id),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_db.py -v`
|
||||
Expected: PASS (7 tests).
|
||||
|
||||
- [ ] **Step 6: Câbler `init_schema()` au démarrage dans `src/app.py`**
|
||||
|
||||
Juste après la ligne `init_subscriptions(app.server)` (≈ ligne 106), ajouter :
|
||||
|
||||
```python
|
||||
from src.saved_views import db as saved_views_db # noqa: E402
|
||||
|
||||
saved_views_db.init_schema()
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vérifier l'import de l'app**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur (sortie vide ou logs normaux).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/saved_views/__init__.py src/saved_views/db.py src/app.py \
|
||||
tests/saved_views/__init__.py tests/saved_views/conftest.py \
|
||||
tests/saved_views/test_db.py
|
||||
git commit -m "feat: table saved_views et CRUD #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Helper `build_view_query` dans `src/utils/table.py`
|
||||
|
||||
Extrait la construction de query string aujourd'hui inline dans `sync_url_and_reset_button` (`tableau.py`), pour la réutiliser à la sauvegarde.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py` (ajouter la fonction + imports si absents)
|
||||
- Modify: `src/pages/tableau.py` (`sync_url_and_reset_button` utilise le helper)
|
||||
- Create: `tests/saved_views/test_build_view_query.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.utils.table.invert_columns(columns) -> list[str]`.
|
||||
- Produces: `build_view_query(filter_query: str | None, sort_by: list | None, hidden_columns: list | None) -> str` — renvoie une query string (`urlencode`) avec les clés `filtres`, `tris` (JSON), `colonnes` (CSV des colonnes **visibles**). Renvoie `""` si aucun paramètre.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Créer `tests/saved_views/test_build_view_query.py` :
|
||||
|
||||
```python
|
||||
import urllib.parse
|
||||
|
||||
from src.utils.table import build_view_query
|
||||
|
||||
|
||||
def test_empty_inputs_give_empty_string():
|
||||
assert build_view_query(None, None, None) == ""
|
||||
assert build_view_query("", [], []) == ""
|
||||
|
||||
|
||||
def test_filter_only():
|
||||
q = build_view_query("{objet} icontains route", None, None)
|
||||
params = urllib.parse.parse_qs(q)
|
||||
assert params["filtres"] == ["{objet} icontains route"]
|
||||
assert "tris" not in params
|
||||
assert "colonnes" not in params
|
||||
|
||||
|
||||
def test_sort_is_json_encoded():
|
||||
sort_by = [{"column_id": "montant", "direction": "desc"}]
|
||||
q = build_view_query(None, sort_by, None)
|
||||
params = urllib.parse.parse_qs(q)
|
||||
import json
|
||||
|
||||
assert json.loads(params["tris"][0]) == sort_by
|
||||
|
||||
|
||||
def test_hidden_columns_become_visible_csv():
|
||||
# build_view_query reçoit les colonnes MASQUÉES et stocke les VISIBLES
|
||||
q = build_view_query(None, None, ["objet"])
|
||||
params = urllib.parse.parse_qs(q)
|
||||
visible = params["colonnes"][0].split(",")
|
||||
assert "objet" not in visible
|
||||
assert len(visible) > 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_build_view_query.py -v`
|
||||
Expected: FAIL (ImportError: `build_view_query`).
|
||||
|
||||
- [ ] **Step 3: Ajouter `build_view_query` à `src/utils/table.py`**
|
||||
|
||||
Vérifier que le haut du fichier contient `import json` et `import urllib.parse` ; les ajouter sinon. Puis ajouter, à la fin du fichier (après `invert_columns`) :
|
||||
|
||||
```python
|
||||
def build_view_query(filter_query, sort_by, hidden_columns) -> str:
|
||||
"""
|
||||
Construit la query string d'une vue Tableau (filtres + tris + colonnes),
|
||||
identique à celle produite par le bouton « Partager la vue ».
|
||||
|
||||
hidden_columns : colonnes masquées ; on stocke les colonnes visibles.
|
||||
"""
|
||||
params = {}
|
||||
if filter_query:
|
||||
params["filtres"] = filter_query
|
||||
if sort_by:
|
||||
params["tris"] = json.dumps(sort_by)
|
||||
if hidden_columns:
|
||||
params["colonnes"] = ",".join(invert_columns(hidden_columns))
|
||||
return urllib.parse.urlencode(params)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_build_view_query.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Refactorer `sync_url_and_reset_button` dans `src/pages/tableau.py`**
|
||||
|
||||
Dans `src/pages/tableau.py`, importer le helper en haut (ajouter `build_view_query` à l'import existant depuis `src.utils.table`).
|
||||
|
||||
Remplacer le corps de construction de l'URL (lignes ≈ 427-440, du `params = {}` jusqu'au `full_url = ...`) par :
|
||||
|
||||
```python
|
||||
query_string = build_view_query(filter_query, sort_by, hidden_columns)
|
||||
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
||||
```
|
||||
|
||||
Supprimer l'import devenu inutile s'il n'est plus utilisé ailleurs (vérifier `json`/`urllib` restent utilisés par d'autres callbacks — ils le sont, ne pas les retirer).
|
||||
|
||||
- [ ] **Step 6: Vérifier la non-régression du partage**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur.
|
||||
Run: `uv run pytest tests/saved_views -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py src/pages/tableau.py tests/saved_views/test_build_view_query.py
|
||||
git commit -m "refactor: extrait build_view_query et le réutilise dans le partage #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fonctions pures d'UI/validation `src/saved_views/ui.py`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/saved_views/ui.py`
|
||||
- Create: `tests/saved_views/test_ui.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `dash_bootstrap_components as dbc`, `dash.html`.
|
||||
- Produces:
|
||||
|
||||
- `bar_style(has_subscription: bool) -> dict` — `{}` si abonné, `{"display": "none"}` sinon.
|
||||
- `clean_view_name(name: str | None) -> str` — `name.strip()`, `""` si vide/None.
|
||||
- `prepare_view_to_save(has_subscription: bool, name: str | None) -> tuple[str | None, str | None]` — renvoie `(clean_name, None)` si OK ; `(None, message)` si refus (non-abonné ou nom vide).
|
||||
- `saved_views_items(views) -> list` — liste de `dbc.DropdownMenuItem` liens `href="/tableau?<query>"` (un par vue).
|
||||
- `views_table(views) -> html.Div` — bloc de gestion pour `/compte/vues` (un `html.Div` par vue avec id pattern-matching pour Ouvrir/Renommer/Supprimer), ou message d'état vide.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Créer `tests/saved_views/test_ui.py` :
|
||||
|
||||
```python
|
||||
from src.saved_views import ui
|
||||
|
||||
|
||||
class _Row(dict):
|
||||
"""Imite un sqlite3.Row : accès par clé."""
|
||||
|
||||
|
||||
def _view(view_id, name, query):
|
||||
return _Row(id=view_id, name=name, query=query)
|
||||
|
||||
|
||||
def test_bar_style_hidden_for_non_subscriber():
|
||||
assert ui.bar_style(False) == {"display": "none"}
|
||||
assert ui.bar_style(True) == {}
|
||||
|
||||
|
||||
def test_clean_view_name_strips_and_empties():
|
||||
assert ui.clean_view_name(" Ma vue ") == "Ma vue"
|
||||
assert ui.clean_view_name(" ") == ""
|
||||
assert ui.clean_view_name(None) == ""
|
||||
|
||||
|
||||
def test_prepare_refuses_non_subscriber():
|
||||
name, err = ui.prepare_view_to_save(False, "Ma vue")
|
||||
assert name is None
|
||||
assert err
|
||||
|
||||
|
||||
def test_prepare_refuses_empty_name():
|
||||
name, err = ui.prepare_view_to_save(True, " ")
|
||||
assert name is None
|
||||
assert err
|
||||
|
||||
|
||||
def test_prepare_accepts_valid():
|
||||
name, err = ui.prepare_view_to_save(True, " Ma vue ")
|
||||
assert name == "Ma vue"
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_saved_views_items_build_links():
|
||||
items = ui.saved_views_items(
|
||||
[_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")]
|
||||
)
|
||||
assert len(items) == 2
|
||||
assert items[0].href == "/tableau?filtres=a"
|
||||
assert items[0].children == "Vue A"
|
||||
|
||||
|
||||
def test_views_table_empty_state():
|
||||
out = ui.views_table([])
|
||||
# un Div non vide (message d'état) sans item de suppression
|
||||
assert out is not None
|
||||
|
||||
|
||||
def test_views_table_lists_views():
|
||||
out = ui.views_table([_view(1, "Vue A", "filtres=a")])
|
||||
text = str(out)
|
||||
assert "Vue A" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_ui.py -v`
|
||||
Expected: FAIL (ModuleNotFoundError: `src.saved_views.ui`).
|
||||
|
||||
- [ ] **Step 3: Écrire `src/saved_views/ui.py`**
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import html
|
||||
|
||||
|
||||
def bar_style(has_subscription: bool) -> dict:
|
||||
return {} if has_subscription else {"display": "none"}
|
||||
|
||||
|
||||
def clean_view_name(name: str | None) -> str:
|
||||
return (name or "").strip()
|
||||
|
||||
|
||||
def prepare_view_to_save(
|
||||
has_subscription: bool, name: str | None
|
||||
) -> tuple[str | None, str | None]:
|
||||
if not has_subscription:
|
||||
return None, "Réservé aux abonné·es."
|
||||
clean = clean_view_name(name)
|
||||
if not clean:
|
||||
return None, "Veuillez saisir un nom pour la vue."
|
||||
return clean, None
|
||||
|
||||
|
||||
def saved_views_items(views) -> list:
|
||||
return [
|
||||
dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}")
|
||||
for view in views
|
||||
]
|
||||
|
||||
|
||||
def _view_row(view) -> html.Div:
|
||||
view_id = view["id"]
|
||||
return html.Div(
|
||||
className="saved-view-row d-flex align-items-center gap-2 mb-2",
|
||||
children=[
|
||||
html.Span(view["name"], className="flex-grow-1"),
|
||||
dbc.Button(
|
||||
"Ouvrir",
|
||||
href=f"/tableau?{view['query']}",
|
||||
color="link",
|
||||
size="sm",
|
||||
),
|
||||
dbc.Button(
|
||||
"Renommer",
|
||||
id={"type": "vue-rename-open", "index": view_id},
|
||||
color="secondary",
|
||||
outline=True,
|
||||
size="sm",
|
||||
),
|
||||
dbc.Button(
|
||||
"Supprimer",
|
||||
id={"type": "vue-delete", "index": view_id},
|
||||
color="danger",
|
||||
outline=True,
|
||||
size="sm",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def views_table(views) -> html.Div:
|
||||
if not views:
|
||||
return html.Div(
|
||||
html.P(
|
||||
"Vous n'avez pas encore de vue enregistrée. "
|
||||
"Créez-en une depuis le Tableau, bouton « Sauvegarder la vue »."
|
||||
)
|
||||
)
|
||||
return html.Div([_view_row(v) for v in views])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_ui.py -v`
|
||||
Expected: PASS (8 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/saved_views/ui.py tests/saved_views/test_ui.py
|
||||
git commit -m "feat: helpers UI/validation des vues sauvegardées #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Intégration sur `/tableau`
|
||||
|
||||
Câble les helpers : barre « vues » (masquée par défaut), callback de visibilité (gating), callback de sauvegarde (avec modale), callback de remplissage du menu déroulant.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/tableau.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.saved_views.db` (`upsert`, `list_views`), `src.saved_views.ui` (`bar_style`, `prepare_view_to_save`, `saved_views_items`), `src.utils.table.build_view_query`, `src.pages._compte_shell.current_user_has_subscription`, `flask_login.current_user`.
|
||||
- Produces: composants d'id `saved-views-bar`, `btn-save-view`, `save-view-modal`, `save-view-name`, `btn-save-view-confirm`, `save-view-feedback`, `saved-views-menu`, `saved-views-refresh` (Store).
|
||||
|
||||
- [ ] **Step 1: Ajouter les imports en haut de `src/pages/tableau.py`**
|
||||
|
||||
```python
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import current_user_has_subscription
|
||||
from src.saved_views import db as saved_views_db
|
||||
from src.saved_views import ui as saved_views_ui
|
||||
```
|
||||
|
||||
(`build_view_query` a déjà été ajouté à l'import `src.utils.table` en Task 2.)
|
||||
|
||||
- [ ] **Step 2: Ajouter la barre « vues » dans la `table-menu`**
|
||||
|
||||
Dans le `children` de la `html.Div(className="table-menu", ...)` (≈ lignes 154-265), juste après le bouton « Choisir les colonnes » (id `tableau_columns_open`), insérer :
|
||||
|
||||
```python
|
||||
html.Div(
|
||||
id="saved-views-bar",
|
||||
style={"display": "none"},
|
||||
className="d-inline-flex align-items-center gap-2",
|
||||
children=[
|
||||
dbc.Button(
|
||||
"Sauvegarder la vue",
|
||||
id="btn-save-view",
|
||||
title="Enregistrer les filtres, tris et colonnes actuels sous un nom",
|
||||
),
|
||||
dbc.DropdownMenu(
|
||||
id="saved-views-menu",
|
||||
label="Mes vues",
|
||||
children=[],
|
||||
className="d-inline-block",
|
||||
),
|
||||
],
|
||||
),
|
||||
dcc.Store(id="saved-views-refresh"),
|
||||
dbc.Modal(
|
||||
id="save-view-modal",
|
||||
is_open=False,
|
||||
children=[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Sauvegarder la vue")),
|
||||
dbc.ModalBody(
|
||||
[
|
||||
dbc.Label("Nom de la vue"),
|
||||
dcc.Input(
|
||||
id="save-view-name",
|
||||
type="text",
|
||||
className="form-control",
|
||||
),
|
||||
html.Div(id="save-view-feedback", className="mt-2"),
|
||||
]
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button(
|
||||
"Enregistrer",
|
||||
id="btn-save-view-confirm",
|
||||
color="primary",
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Ajouter le callback de visibilité (gating)**
|
||||
|
||||
À la fin de `src/pages/tableau.py`, ajouter :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("saved-views-bar", "style"),
|
||||
Input("tableau_url", "pathname"),
|
||||
)
|
||||
def toggle_saved_views_bar(_pathname):
|
||||
return saved_views_ui.bar_style(current_user_has_subscription())
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ajouter le callback d'ouverture de la modale**
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("save-view-modal", "is_open"),
|
||||
Input("btn-save-view", "n_clicks"),
|
||||
Input("btn-save-view-confirm", "n_clicks"),
|
||||
State("save-view-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def toggle_save_view_modal(_open, _confirm, is_open):
|
||||
return not is_open
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Ajouter le callback de sauvegarde (avec contrôle serveur)**
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("save-view-feedback", "children"),
|
||||
Output("saved-views-refresh", "data"),
|
||||
Input("btn-save-view-confirm", "n_clicks"),
|
||||
State("save-view-name", "value"),
|
||||
State("tableau_datatable", "filter_query"),
|
||||
State("tableau_datatable", "sort_by"),
|
||||
State("tableau_datatable", "hidden_columns"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def save_view(_n, name, filter_query, sort_by, hidden_columns):
|
||||
has_sub = current_user_has_subscription()
|
||||
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
|
||||
if error:
|
||||
return html.Span(error, style={"color": "red"}), no_update
|
||||
query = build_view_query(filter_query, sort_by, hidden_columns)
|
||||
saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
|
||||
return (
|
||||
html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}),
|
||||
clean_name,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Ajouter le callback de remplissage du menu déroulant**
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("saved-views-menu", "children"),
|
||||
Input("tableau_url", "pathname"),
|
||||
Input("saved-views-refresh", "data"),
|
||||
)
|
||||
def populate_saved_views_menu(_pathname, _refresh):
|
||||
if not current_user_has_subscription():
|
||||
return []
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.saved_views_items(views)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vérifier l'import et la non-régression**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur (pas d'erreur de callback dupliqué/composant manquant).
|
||||
Run: `uv run pytest tests/saved_views -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Vérification manuelle (smoke test)**
|
||||
|
||||
Démarrer `uv run python run.py`, se connecter avec un compte abonné (ou `TOUS_ABONNES=true` dans `.env`), aller sur `/tableau` :
|
||||
|
||||
- la barre « Sauvegarder la vue » + « Mes vues » est visible ;
|
||||
- appliquer un filtre, cliquer « Sauvegarder la vue », saisir un nom, Enregistrer → confirmation verte ;
|
||||
- ouvrir « Mes vues » → la vue apparaît ; cliquer dessus applique le filtre.
|
||||
- Se déconnecter → la barre disparaît.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/tableau.py
|
||||
git commit -m "feat: UI sauvegarde et application des vues sur /tableau #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Page de gestion `/compte/vues`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/_compte_shell.py` (ajout section `vues`)
|
||||
- Create: `src/pages/compte_vues.py`
|
||||
- Create: `tests/saved_views/test_compte_vues.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.pages._compte_shell` (`account_guard`, `account_shell`, `SECTIONS`), `src.saved_views.db` (`list_views`, `rename`, `delete`), `src.saved_views.ui.views_table`, `flask_login.current_user`.
|
||||
- Produces: page enregistrée sur `/compte/vues` ; section `vues` dans `SECTIONS`.
|
||||
|
||||
- [ ] **Step 1: Écrire le test de la section (échoue)**
|
||||
|
||||
Créer `tests/saved_views/test_compte_vues.py` :
|
||||
|
||||
```python
|
||||
from src.pages import _compte_shell as shell
|
||||
|
||||
|
||||
def test_vues_section_is_gated_subscription():
|
||||
section = next(s for s in shell.SECTIONS if s["key"] == "vues")
|
||||
assert section["href"] == "/compte/vues"
|
||||
assert section["require_subscription"] is True
|
||||
|
||||
|
||||
def test_vues_hidden_without_subscription():
|
||||
keys = {s["key"] for s in shell.visible_sections(has_subscription=False)}
|
||||
assert "vues" not in keys
|
||||
|
||||
|
||||
def test_vues_visible_with_subscription():
|
||||
keys = {s["key"] for s in shell.visible_sections(has_subscription=True)}
|
||||
assert "vues" in keys
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_compte_vues.py -v`
|
||||
Expected: FAIL (`StopIteration` : section `vues` absente).
|
||||
|
||||
- [ ] **Step 3: Ajouter la section dans `src/pages/_compte_shell.py`**
|
||||
|
||||
Dans la liste `SECTIONS`, après l'entrée `filtres`, ajouter :
|
||||
|
||||
```python
|
||||
{
|
||||
"key": "vues",
|
||||
"label": "Mes vues",
|
||||
"href": "/compte/vues",
|
||||
"require_subscription": True,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer le test, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_compte_vues.py -v`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Créer la page `src/pages/compte_vues.py`**
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import (
|
||||
ALL,
|
||||
Input,
|
||||
Output,
|
||||
State,
|
||||
callback,
|
||||
ctx,
|
||||
html,
|
||||
no_update,
|
||||
register_page,
|
||||
)
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import account_guard, account_shell
|
||||
from src.saved_views import db as saved_views_db
|
||||
from src.saved_views import ui as saved_views_ui
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte/vues",
|
||||
title="Mes vues | decp.info",
|
||||
name="Mes vues",
|
||||
description="Gérez vos vues enregistrées du tableau des marchés.",
|
||||
)
|
||||
|
||||
|
||||
def _content():
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return html.Div(
|
||||
[
|
||||
html.H2("Mes vues"),
|
||||
html.P(
|
||||
"Les vues que vous enregistrez depuis le Tableau apparaissent ici. "
|
||||
"Cliquez sur « Ouvrir » pour appliquer une vue."
|
||||
),
|
||||
html.Div(saved_views_ui.views_table(views), id="vues-list"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def layout(**_):
|
||||
guard = account_guard("/compte/vues", require_subscription=True)
|
||||
if guard is not None:
|
||||
return guard
|
||||
return account_shell("vues", _content())
|
||||
|
||||
|
||||
@callback(
|
||||
Output("vues-list", "children"),
|
||||
Input({"type": "vue-delete", "index": ALL}, "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def delete_view(n_clicks):
|
||||
if not ctx.triggered_id or not any(n_clicks):
|
||||
return no_update
|
||||
saved_views_db.delete(ctx.triggered_id["index"], current_user.id)
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.views_table(views)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Ajouter le renommage (modale partagée)**
|
||||
|
||||
Ajouter en bas de `src/pages/compte_vues.py` une modale de renommage et ses callbacks. Compléter `_content()` pour inclure la modale :
|
||||
|
||||
Dans `_content()`, ajouter à la liste des enfants (après `vues-list`) :
|
||||
|
||||
```python
|
||||
dbc.Modal(
|
||||
id="vue-rename-modal",
|
||||
is_open=False,
|
||||
children=[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")),
|
||||
dbc.ModalBody(
|
||||
dbc.Input(id="vue-rename-input", type="text"),
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button("Renommer", id="vue-rename-confirm", color="primary")
|
||||
),
|
||||
],
|
||||
),
|
||||
dbc.Input(id="vue-rename-id", type="hidden"),
|
||||
```
|
||||
|
||||
Puis les callbacks :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("vue-rename-modal", "is_open"),
|
||||
Output("vue-rename-id", "value"),
|
||||
Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"),
|
||||
Input("vue-rename-confirm", "n_clicks"),
|
||||
State("vue-rename-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def toggle_rename_modal(_open, _confirm, is_open):
|
||||
if isinstance(ctx.triggered_id, dict) and any(_open):
|
||||
return True, str(ctx.triggered_id["index"])
|
||||
return False, no_update
|
||||
|
||||
|
||||
@callback(
|
||||
Output("vues-list", "children", allow_duplicate=True),
|
||||
Input("vue-rename-confirm", "n_clicks"),
|
||||
State("vue-rename-id", "value"),
|
||||
State("vue-rename-input", "value"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def rename_view(_n, view_id, new_name):
|
||||
clean = saved_views_ui.clean_view_name(new_name)
|
||||
if not view_id or not clean:
|
||||
return no_update
|
||||
saved_views_db.rename(int(view_id), current_user.id, clean)
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.views_table(views)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vérifier l'import et les tests**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur.
|
||||
Run: `uv run pytest tests/saved_views -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Vérification manuelle (smoke test)**
|
||||
|
||||
Avec un compte abonné, aller sur `/compte/vues` : la liste des vues s'affiche ; « Supprimer » retire une vue ; « Renommer » ouvre la modale et met à jour le nom. Vérifier qu'un·e non-abonné·e est redirigé·e vers `/compte/abonnement`.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/_compte_shell.py src/pages/compte_vues.py tests/saved_views/test_compte_vues.py
|
||||
git commit -m "feat: page /compte/vues (liste, renommer, supprimer) #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Vérification finale
|
||||
|
||||
- [ ] **Step 1: Lancer toute la suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: PASS (les tests Selenium peuvent nécessiter Chrome ; au minimum `tests/saved_views`, `tests/test_compte_shell.py`, `tests/subscriptions` doivent passer).
|
||||
|
||||
- [ ] **Step 2: Vérifier le formatage**
|
||||
|
||||
Run: `uv run ruff format --check src/saved_views src/pages/compte_vues.py`
|
||||
Expected: déjà formaté (sinon lancer `uv run ruff format` et committer).
|
||||
|
||||
- [ ] **Step 3: Commit final si nécessaire**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: formatage vues sauvegardées #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Couverture de la spec :**
|
||||
|
||||
- Stockage `saved_views` dans `users.sqlite` → Task 1. ✓
|
||||
- Vue = nom + query (`filtres`/`tris`/`colonnes`), réutilise le mécanisme existant → Task 2 (`build_view_query`) + Task 4. ✓
|
||||
- Bouton « Sauvegarder la vue » + modale de nommage → Task 4. ✓
|
||||
- Menu déroulant des vues, application par navigation → Task 4 (`saved_views_items`, liens `/tableau?<query>` + `restore_view_from_url` existant). ✓
|
||||
- Masquage pour non-abonné·es + contrôle serveur → Task 4 (`toggle_saved_views_bar`, `save_view` re-vérifie l'abonnement). ✓
|
||||
- Gestion `/compte/vues` (liste, renommer, supprimer), section gatée → Task 5. ✓
|
||||
- Isolation par `user_id`, cascade → Task 1 (tests). ✓
|
||||
- Hors périmètre titulaire/acheteur, colonne `table_name` réservée → respecté (`table_name="tableau"` partout). ✓
|
||||
- Tests DB + gating → Tasks 1, 3, 5. ✓
|
||||
|
||||
**Scan placeholders :** aucun TBD/TODO ; tout le code est fourni.
|
||||
|
||||
**Cohérence des types/noms :** `build_view_query(filter_query, sort_by, hidden_columns)` cohérent Task 2↔4 ; `upsert(user_id, table_name, name, query)`, `list_views(user_id, table_name)`, `rename(view_id, user_id, new_name)`, `delete(view_id, user_id)` cohérents Task 1↔4↔5 ; `prepare_view_to_save(has_subscription, name) -> (clean_name, error)` cohérent Task 3↔4 ; ids des composants pattern-matching (`vue-delete`, `vue-rename-open`) cohérents `ui.py`↔`compte_vues.py`.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Charte graphique des boutons — 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:** Remplacer les couleurs de boutons dérivées du thème Simplex (danger mauve, secondary gris illisible) par une charte à trois rôles — `primary` terracotta, `secondary` gris ardoise, `danger` rouge — où la couleur encode la fonction et le remplissage l'emphase.
|
||||
|
||||
**Architecture:** Override CSS ciblant exclusivement les classes `.btn-*` dans `src/assets/css/style.css` (jamais les variables `--bs-*` racine, pour ne pas affecter alertes/badges). Le code applicatif continue d'utiliser `color="primary|secondary|danger"` + `outline=True|False` natifs de dash-bootstrap-components. Puis audit des boutons existants pour conformer leur usage à la charte.
|
||||
|
||||
**Tech Stack:** Dash 3.4, dash-bootstrap-components, Bootstrap 5 (thème Simplex), CSS, pytest + Selenium (DashComposite).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Override **uniquement** les sélecteurs `.btn-*` ; ne **jamais** modifier les variables racine `--bs-primary`, `--bs-danger`, `--bs-secondary` (alertes/badges doivent rester inchangés). Source : spec « Hors périmètre ».
|
||||
- Imports applicatifs toujours préfixés `src.` (ex. `src.pages.recherche`).
|
||||
- Palette exacte : terracotta `#b33821`, danger rouge `#c0392b`, secondary texte `#344054` / bord `#5a6570`, disabled bord `#ccc` / texte `#666`.
|
||||
- `border-radius: 3px` et typographie `Inter` poids 400 conservés sur les boutons.
|
||||
- Focus clavier visible (`:focus-visible`) sur tous les boutons restylés (accessibilité).
|
||||
- Respecter `prefers-reduced-motion` pour toute transition de survol.
|
||||
- Tests : `rtk pytest` (Selenium, nécessite Chrome/Chromium). `DEVELOPMENT=true` est positionné automatiquement.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Override CSS des trois rôles de boutons
|
||||
|
||||
Redéfinit l'apparence des classes `.btn-primary`, `.btn-outline-primary`, `.btn-secondary`, `.btn-outline-secondary`, `.btn-danger`, `.btn-outline-danger` dans la feuille de style applicative, en conservant l'API dbc native. Ajoute un test Selenium de non-régression vérifiant que la feuille est bien appliquée au bouton primaire de la page d'accueil (publique).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/assets/css/style.css` (bloc « Base Button Styles » autour des lignes 42-79)
|
||||
- Test: `tests/test_boutons.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien (première tâche).
|
||||
- Produces: classes CSS restylées `.btn-primary`, `.btn-outline-primary`, `.btn-secondary`, `.btn-outline-secondary`, `.btn-danger`, `.btn-outline-danger`. Aucun symbole Python.
|
||||
|
||||
- [ ] **Step 1: Écrire le test de non-régression (échoue d'abord car couleur cible non garantie)**
|
||||
|
||||
Créer `tests/test_boutons.py`. Le bouton « Rechercher » de la page d'accueil (`/`, public, `className="btn btn-primary"` dans `src/pages/recherche.py:56`) doit être rendu terracotta plein (le dégradé existant a `rgb(179, 56, 33)` en couleur médiane → on vérifie une composante rouge dominante et un fond non transparent).
|
||||
|
||||
```python
|
||||
from dash.testing.composite import DashComposite
|
||||
|
||||
|
||||
def _rgb_tuple(css_color: str) -> tuple[int, int, int]:
|
||||
"""Parse 'rgb(r, g, b)' ou 'rgba(r, g, b, a)' en (r, g, b)."""
|
||||
inner = css_color[css_color.index("(") + 1 : css_color.index(")")]
|
||||
parts = [p.strip() for p in inner.split(",")]
|
||||
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
|
||||
|
||||
def test_btn_primary_is_terracotta(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_element("a.btn.btn-primary, button.btn.btn-primary", timeout=10)
|
||||
btn = dash_duo.find_element("a.btn.btn-primary, button.btn.btn-primary")
|
||||
|
||||
# Le dégradé terracotta est posé via background-image ; la couleur de
|
||||
# repli background-color ne doit pas être le bleu Bootstrap par défaut.
|
||||
bg_image = btn.value_of_css_property("background-image")
|
||||
color = btn.value_of_css_property("color")
|
||||
|
||||
assert "gradient" in bg_image # dégradé terracotta appliqué
|
||||
r, g, b = _rgb_tuple(color)
|
||||
assert (r, g, b) == (255, 255, 255) # texte blanc
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test pour vérifier l'état initial**
|
||||
|
||||
Run: `rtk pytest tests/test_boutons.py::test_btn_primary_is_terracotta -v`
|
||||
Expected: PASS si le `.btn-primary` actuel s'applique déjà (dégradé + texte blanc déjà présents lignes 51-65). Ce test sert de **garde de non-régression** : il doit rester vert après le refactor CSS. S'il échoue ici, c'est que la feuille n'est pas chargée → investiguer avant de continuer.
|
||||
|
||||
- [ ] **Step 3: Réécrire le bloc boutons dans `style.css`**
|
||||
|
||||
Remplacer le bloc commenté/partiel « Base Button Styles » (lignes ~42-79 : le commentaire mort, `button.btn.btn-primary, button.show-hide { … }`, son `:hover`, et `button[disabled]`) par la charte complète ci-dessous. Conserver le dégradé terracotta existant pour `.btn-primary` et le `button.show-hide` (qui réutilise ce style).
|
||||
|
||||
```css
|
||||
/* ==========================================================================
|
||||
Boutons — charte à 3 rôles (couleur = fonction, remplissage = emphase)
|
||||
Voir docs/superpowers/specs/2026-06-30-charte-boutons-design.md
|
||||
Override scopé aux classes .btn-* uniquement (n'affecte pas les alertes).
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--btn-terracotta: #b33821;
|
||||
--btn-terracotta-text: #fff;
|
||||
--btn-secondary-text: #344054;
|
||||
--btn-secondary-border: #5a6570;
|
||||
--btn-secondary-hover-bg: #f1f3f5;
|
||||
--btn-danger: #c0392b;
|
||||
--btn-danger-text: #fff;
|
||||
}
|
||||
|
||||
/* Base commune : rayon + typo */
|
||||
.btn {
|
||||
border-radius: 3px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* --- PRIMARY plein : action principale validante (1 par contexte) --- */
|
||||
button.btn.btn-primary,
|
||||
a.btn.btn-primary,
|
||||
button.show-hide {
|
||||
display: block;
|
||||
outline: 0;
|
||||
color: var(--btn-terracotta-text);
|
||||
border: 0;
|
||||
background-image: linear-gradient(
|
||||
rgb(209, 96, 73),
|
||||
rgb(179, 56, 33) 26%,
|
||||
rgb(159, 36, 22)
|
||||
);
|
||||
}
|
||||
|
||||
button.btn.btn-primary:hover,
|
||||
a.btn.btn-primary:hover,
|
||||
button.show-hide:hover {
|
||||
color: var(--btn-terracotta-text);
|
||||
background-image: linear-gradient(
|
||||
rgb(239, 126, 103),
|
||||
rgb(209, 86, 63) 26%,
|
||||
rgb(189, 66, 52)
|
||||
);
|
||||
}
|
||||
|
||||
/* --- PRIMARY outline : action affirmative de moindre emphase --- */
|
||||
.btn.btn-outline-primary {
|
||||
color: var(--btn-terracotta);
|
||||
border: 1px solid var(--btn-terracotta);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-outline-primary:hover,
|
||||
.btn.btn-outline-primary:focus-visible {
|
||||
color: var(--btn-terracotta-text);
|
||||
background-color: var(--btn-terracotta);
|
||||
border-color: var(--btn-terracotta);
|
||||
}
|
||||
|
||||
/* --- SECONDARY : action neutre / alternative (gris ardoise) --- */
|
||||
.btn.btn-secondary,
|
||||
.btn.btn-outline-secondary {
|
||||
color: var(--btn-secondary-text);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-secondary:hover,
|
||||
.btn.btn-secondary:focus-visible,
|
||||
.btn.btn-outline-secondary:hover,
|
||||
.btn.btn-outline-secondary:focus-visible {
|
||||
color: var(--btn-secondary-text);
|
||||
background-color: var(--btn-secondary-hover-bg);
|
||||
border-color: var(--btn-secondary-border);
|
||||
}
|
||||
|
||||
/* --- DANGER outline : action destructive dans le flux courant --- */
|
||||
.btn.btn-outline-danger {
|
||||
color: var(--btn-danger);
|
||||
border: 1px solid var(--btn-danger);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-outline-danger:hover,
|
||||
.btn.btn-outline-danger:focus-visible {
|
||||
color: var(--btn-danger-text);
|
||||
background-color: var(--btn-danger);
|
||||
border-color: var(--btn-danger);
|
||||
}
|
||||
|
||||
/* --- DANGER plein : confirmation finale destructive (modale) --- */
|
||||
.btn.btn-danger {
|
||||
color: var(--btn-danger-text);
|
||||
border: 1px solid var(--btn-danger);
|
||||
background-color: var(--btn-danger);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-danger:hover,
|
||||
.btn.btn-danger:focus-visible {
|
||||
color: var(--btn-danger-text);
|
||||
background-color: #a93226;
|
||||
border-color: #a93226;
|
||||
}
|
||||
|
||||
/* --- État désactivé commun (conserve l'existant) --- */
|
||||
.btn[disabled],
|
||||
button[disabled] {
|
||||
border-color: #ccc;
|
||||
color: #666;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn {
|
||||
transition: background-color 0.15s ease, color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Relancer le test de non-régression**
|
||||
|
||||
Run: `rtk pytest tests/test_boutons.py::test_btn_primary_is_terracotta -v`
|
||||
Expected: PASS (le `.btn-primary` conserve son dégradé terracotta et son texte blanc).
|
||||
|
||||
- [ ] **Step 5: Vérifier l'absence de régression sur la suite complète**
|
||||
|
||||
Run: `rtk pytest`
|
||||
Expected: PASS (aucune régression introduite par le CSS).
|
||||
|
||||
- [ ] **Step 6: Vérification visuelle des trois rôles**
|
||||
|
||||
Lancer l'app (`python run.py`), se connecter, ouvrir `/compte/vues`. Vérifier :
|
||||
|
||||
- « Renommer » : gris ardoise lisible (texte `#344054`, bord `#5a6570`), plus de gris clair illisible ;
|
||||
- « Supprimer » : rouge `#c0392b` en outline, plus aucun mauve ; au survol, plein rouge texte blanc ;
|
||||
- focus clavier (Tab) : anneau/fond visible sur chaque bouton.
|
||||
Prendre une capture pour comparaison avec la capture initiale.
|
||||
|
||||
- [ ] **Step 7: Commit** (différé — voir note d'intégration en fin de plan : spec + plan + code committés ensemble)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Audit et conformation des boutons existants
|
||||
|
||||
Passe en revue les ~46 occurrences `color=…` et 8 `outline=True` pour s'assurer qu'elles respectent la charte : une seule action `primary` pleine par contexte, destructif outline par défaut sauf confirmation finale en modale (plein rouge), neutre en `secondary` outline.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Inspect: `src/saved_views/ui.py`, `src/pages/compte_vues.py`, `src/pages/compte_abonnement.py`, `src/pages/compte_abonnement_mes_infos.py`, `src/pages/mot_de_passe_oublie.py`, `src/pages/recherche.py`, `src/pages/tableau.py`, `src/app.py`
|
||||
- Modify: uniquement les fichiers dont un bouton enfreint la charte (à déterminer pendant l'audit)
|
||||
- Test: `tests/test_boutons.py` (réutilise la garde de non-régression de Task 1)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: classes CSS restylées de Task 1.
|
||||
- Produces: aucun symbole ; usages de boutons conformes à la charte.
|
||||
|
||||
- [ ] **Step 1: Recenser les boutons et leur rôle**
|
||||
|
||||
Run: `grep -rn 'dbc.Button\|className="btn' src/ --include=*.py`
|
||||
Pour chaque bouton, noter (page, libellé, `color`, `outline`) et confronter à la charte :
|
||||
|
||||
| Cas | Attendu |
|
||||
| ------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| Action principale d'un écran/formulaire (Rechercher, Enregistrer, Envoyer, S'abonner) | `color="primary"` plein |
|
||||
| Déclencheur destructif dans une liste (Supprimer, Se désabonner) | `color="danger", outline=True` |
|
||||
| Confirmation destructive finale en modale | `color="danger"` plein |
|
||||
| Action neutre (Renommer, Annuler, navigation) | `color="secondary", outline=True` |
|
||||
|
||||
Vérifier en particulier : aucun écran ne doit présenter **deux** boutons `primary` pleins en concurrence.
|
||||
|
||||
- [ ] **Step 2: Corriger les boutons non conformes**
|
||||
|
||||
Éditer uniquement les boutons divergents. Exemple de correction type (n'appliquer que si un cas réel est trouvé) :
|
||||
|
||||
```python
|
||||
# Avant — déclencheur de suppression en plein rouge dans une liste
|
||||
dbc.Button("Supprimer", id={"type": "vue-delete", "index": vid}, color="danger")
|
||||
# Après — outline par défaut hors confirmation finale
|
||||
dbc.Button("Supprimer", id={"type": "vue-delete", "index": vid}, color="danger", outline=True)
|
||||
```
|
||||
|
||||
Si l'audit ne révèle aucune divergence (les usages de `src/saved_views/ui.py` sont déjà conformes : Renommer = `secondary`+outline, Supprimer = `danger`+outline ; la confirmation modale `vue-rename-confirm` = `primary`), documenter « aucun changement nécessaire » dans le message de commit et passer au Step 3.
|
||||
|
||||
- [ ] **Step 3: Vérifier la non-régression**
|
||||
|
||||
Run: `rtk pytest`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Vérification visuelle multi-pages**
|
||||
|
||||
Lancer l'app, parcourir `/compte/vues`, `/compte/abonnement`, `/` et `/tableau`. Confirmer que chaque bouton respecte son rôle et qu'il n'y a pas deux `primary` pleins concurrents par écran.
|
||||
|
||||
- [ ] **Step 5: Commit** (différé — voir note d'intégration ci-dessous)
|
||||
|
||||
---
|
||||
|
||||
## Note d'intégration
|
||||
|
||||
Sur demande explicite de l'utilisateur, le **spec + le plan + le code** sont
|
||||
committés **ensemble** (pas de commit intermédiaire). Après validation des deux
|
||||
tâches, faire un unique commit :
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-06-30-charte-boutons-design.md \
|
||||
docs/superpowers/plans/2026-06-30-charte-boutons.md \
|
||||
src/assets/css/style.css tests/test_boutons.py
|
||||
# + tout fichier page modifié pendant l'audit (Task 2)
|
||||
git commit -m "feat: charte graphique des boutons (primary/secondary/danger) #<issue>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
Branche courante : `dev` (auto-déploie sur test.decp.info). Ne pas pousser sans
|
||||
demande explicite.
|
||||
@@ -0,0 +1,754 @@
|
||||
# Panneau admin — éditeur générique de tables 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:** Remplacer les pages admin dédiées (`/admin/user/<id>`, `/admin/journal` + formulaire de changement de statut) par une unique page `/admin` : un sélecteur de table SQLite + une `dash_table.DataTable` filtrable/triable/paginée en natif dont les cellules sont éditables directement.
|
||||
|
||||
**Architecture:** Un registre statique de tables autorisées (`src/admin/tables.py`) porte toute la logique de validation/écriture, testable sans Dash. La page (`src/pages/admin/liste.py`) ne fait que du câblage : un callback unique, déclenché soit par le changement de table (recharge les données), soit par une édition de cellule (diff `data`/`data_previous`, valide, écrit, logue).
|
||||
|
||||
**Tech Stack:** Dash 3.4 (`dash_table.DataTable`, `editable`, `dropdown`), dash-bootstrap-components, sqlite3 brut (pas d'ORM).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-03-admin-table-editor-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Tables éditables : `users` (jamais `password_hash` — colonne totalement exclue de `SELECT`/affichage), `subscriptions`, `subscriber_state`. `admin_actions` est consultable dans le même sélecteur mais en lecture seule (aucune colonne éditable).
|
||||
- Colonnes jamais éditables, quelle que soit la table : la clé primaire, `created_at`, `updated_at`. Colonnes explicitement exclues de l'édition même si elles ne sont ni PK ni timestamp : `frisbii_customer_handle`, `frisbii_subscription_handle`, `user_id` (FK).
|
||||
- Nom de table et de colonne **toujours** validés contre le registre `TABLES` avant toute requête SQL — jamais de nom interpolé directement depuis une valeur venant du client sans passer par ce registre.
|
||||
- Chaque colonne éditable a un type attendu (`int`, `float`, `str`) ; une valeur qui ne convertit pas proprement est rejetée avant écriture (rien n'est écrit, une alerte s'affiche).
|
||||
- `dash_table.DataTable` : `filter_action="native"`, `sort_action="native"`, `page_action="native"`, `page_size=20` partout dans cette page.
|
||||
- `is_admin()` (déjà en place, `src/admin/guard.py`) garde la page : non-admin → composant 404 (`not_admin()`), jamais de redirection.
|
||||
- Chaque édition de cellule réussie est loguée via `log_action()` (déjà en place, `src/admin/db.py`) avec `action=f"edit_{table}"`, `target_user_id` dérivé par table, `details=f"{column}: {old!r} → {new!r}"`.
|
||||
- Run tests with `uv run pytest` (venv activation via le Bash tool ne met pas PATH à jour de façon fiable ici).
|
||||
- `sqlite3` : connexions autocommit (`isolation_level=None`) — aucun `conn.commit()` dans les nouvelles fonctions DB, comme partout ailleurs dans le projet.
|
||||
- `tests/users.test.sqlite` est committé dans git et partagé pour toute la session de tests Selenium (`USERS_DB_PATH` fixé globalement dans `pyproject.toml`) — toute ligne créée par un test Selenium doit être supprimée en `finally`, avec vérification `git status --short tests/users.test.sqlite` vide après un run complet.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Registre des tables (`src/admin/tables.py`)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/tables.py`
|
||||
- Test: `tests/admin/test_tables.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `TableConfig` (dataclass), `TABLES: dict[str, TableConfig]`
|
||||
- Produces: `get_rows(table: str) -> list[dict]`
|
||||
- Produces: `set_cell(table: str, pk_value, column: str, value) -> None` (lève `ValueError` si table/colonne/valeur invalide)
|
||||
- Produces: `find_changed_cell(data: list[dict], data_previous: list[dict] | None) -> tuple[int, str, object, object] | None` — `(row_index, column, old_value, new_value)` de la première cellule modifiée, ou `None`.
|
||||
- Consumes: `get_conn()` de `src.auth.db` (déjà existant), `SUBSCRIPTION_STATUSES` de `src.subscriptions.db` (déjà existant, valeurs `("active", "trial", "cancelled", "expired", "pending")`), `PLANS` de `src.subscriptions.plans` (déjà existant, dict avec les clés `"simple"`, `"soutien"`).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/admin/test_tables.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
from src.admin import tables
|
||||
|
||||
|
||||
def test_get_rows_users_excludes_password_hash(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
|
||||
auth_db.init_schema()
|
||||
auth_db.create_user("a@ex.fr", "secret-hash")
|
||||
|
||||
rows = tables.get_rows("users")
|
||||
|
||||
assert rows[0]["email"] == "a@ex.fr"
|
||||
assert "password_hash" not in rows[0]
|
||||
|
||||
|
||||
def test_set_cell_rejects_unknown_table(users_db_path):
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("not_a_table", 1, "email", "x@ex.fr")
|
||||
|
||||
|
||||
def test_set_cell_rejects_non_editable_column(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
|
||||
auth_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("users", uid, "id", "999")
|
||||
|
||||
|
||||
def test_set_cell_rejects_invalid_dropdown_value(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
auth_db.init_schema()
|
||||
sub_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("subscriptions", sub_id, "status", "not_a_status")
|
||||
|
||||
|
||||
def test_set_cell_rejects_bad_type(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
auth_db.init_schema()
|
||||
sub_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("subscriptions", sub_id, "prix_ht", "not-a-number")
|
||||
|
||||
|
||||
def test_set_cell_writes_valid_value(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
|
||||
auth_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
|
||||
tables.set_cell("users", uid, "siret", "12345678900011")
|
||||
|
||||
rows = tables.get_rows("users")
|
||||
assert rows[0]["siret"] == "12345678900011"
|
||||
|
||||
|
||||
def test_set_cell_coerces_numeric_type(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
auth_db.init_schema()
|
||||
sub_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
|
||||
|
||||
tables.set_cell("subscriptions", sub_id, "prix_ht", "30")
|
||||
|
||||
rows = tables.get_rows("subscriptions")
|
||||
assert rows[0]["prix_ht"] == 30.0
|
||||
|
||||
|
||||
def test_find_changed_cell_detects_single_diff():
|
||||
data = [{"id": 1, "email": "new@ex.fr"}]
|
||||
data_previous = [{"id": 1, "email": "old@ex.fr"}]
|
||||
|
||||
result = tables.find_changed_cell(data, data_previous)
|
||||
|
||||
assert result == (0, "email", "old@ex.fr", "new@ex.fr")
|
||||
|
||||
|
||||
def test_find_changed_cell_returns_none_when_identical():
|
||||
data = [{"id": 1, "email": "a@ex.fr"}]
|
||||
data_previous = [{"id": 1, "email": "a@ex.fr"}]
|
||||
|
||||
assert tables.find_changed_cell(data, data_previous) is None
|
||||
|
||||
|
||||
def test_find_changed_cell_returns_none_when_previous_is_none():
|
||||
assert tables.find_changed_cell([{"id": 1}], None) is None
|
||||
|
||||
|
||||
def test_target_user_id_per_table():
|
||||
assert tables.TABLES["users"].target_user_id({"id": 7}) == 7
|
||||
assert tables.TABLES["subscriptions"].target_user_id({"user_id": 9}) == 9
|
||||
assert tables.TABLES["subscriber_state"].target_user_id({"user_id": 3}) == 3
|
||||
assert tables.TABLES["admin_actions"].target_user_id({"id": 1}) is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/admin/test_tables.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'src.admin.tables'`
|
||||
|
||||
- [ ] **Step 3: Implement `src/admin/tables.py`**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from src.auth.db import get_conn
|
||||
from src.subscriptions.db import SUBSCRIPTION_STATUSES
|
||||
from src.subscriptions.plans import PLANS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableConfig:
|
||||
columns: list[str]
|
||||
editable_columns: frozenset[str]
|
||||
pk: str
|
||||
column_types: dict[str, type]
|
||||
dropdowns: dict[str, list[str]]
|
||||
target_user_id: Callable[[dict], int | None]
|
||||
|
||||
|
||||
TABLES: dict[str, TableConfig] = {
|
||||
"users": TableConfig(
|
||||
columns=[
|
||||
"id",
|
||||
"email",
|
||||
"email_verified",
|
||||
"siret",
|
||||
"pending_email",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"email", "email_verified", "siret", "pending_email"}
|
||||
),
|
||||
pk="id",
|
||||
column_types={
|
||||
"email": str,
|
||||
"email_verified": int,
|
||||
"siret": str,
|
||||
"pending_email": str,
|
||||
},
|
||||
dropdowns={"email_verified": ["0", "1"]},
|
||||
target_user_id=lambda row: row["id"],
|
||||
),
|
||||
"subscriptions": TableConfig(
|
||||
columns=[
|
||||
"id",
|
||||
"user_id",
|
||||
"frisbii_customer_handle",
|
||||
"frisbii_subscription_handle",
|
||||
"plan",
|
||||
"prix_ht",
|
||||
"status",
|
||||
"current_period_end",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"plan", "prix_ht", "status", "current_period_end"}
|
||||
),
|
||||
pk="id",
|
||||
column_types={
|
||||
"plan": str,
|
||||
"prix_ht": float,
|
||||
"status": str,
|
||||
"current_period_end": str,
|
||||
},
|
||||
dropdowns={
|
||||
"status": list(SUBSCRIPTION_STATUSES),
|
||||
"plan": list(PLANS.keys()),
|
||||
},
|
||||
target_user_id=lambda row: row["user_id"],
|
||||
),
|
||||
"subscriber_state": TableConfig(
|
||||
columns=[
|
||||
"user_id",
|
||||
"trial_used",
|
||||
"votes_balance",
|
||||
"votes_last_credited_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"trial_used", "votes_balance", "votes_last_credited_at"}
|
||||
),
|
||||
pk="user_id",
|
||||
column_types={
|
||||
"trial_used": int,
|
||||
"votes_balance": int,
|
||||
"votes_last_credited_at": str,
|
||||
},
|
||||
dropdowns={"trial_used": ["0", "1"]},
|
||||
target_user_id=lambda row: row["user_id"],
|
||||
),
|
||||
"admin_actions": TableConfig(
|
||||
columns=["id", "admin_email", "action", "target_user_id", "details", "created_at"],
|
||||
editable_columns=frozenset(),
|
||||
pk="id",
|
||||
column_types={},
|
||||
dropdowns={},
|
||||
target_user_id=lambda row: None,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_rows(table: str) -> list[dict]:
|
||||
cfg = TABLES[table]
|
||||
cols_sql = ", ".join(cfg.columns)
|
||||
rows = get_conn().execute(f"SELECT {cols_sql} FROM {table}").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _coerce_value(table: str, column: str, value):
|
||||
cfg = TABLES[table]
|
||||
if column not in cfg.editable_columns:
|
||||
raise ValueError(f"Colonne non éditable : {column}")
|
||||
if column in cfg.dropdowns and str(value) not in cfg.dropdowns[column]:
|
||||
raise ValueError(f"Valeur non autorisée pour {column} : {value!r}")
|
||||
expected_type = cfg.column_types[column]
|
||||
try:
|
||||
if expected_type is int:
|
||||
return int(value)
|
||||
if expected_type is float:
|
||||
return float(value)
|
||||
return str(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Valeur invalide pour {column} : {value!r}") from exc
|
||||
|
||||
|
||||
def set_cell(table: str, pk_value, column: str, value) -> None:
|
||||
if table not in TABLES:
|
||||
raise ValueError(f"Table inconnue : {table}")
|
||||
cfg = TABLES[table]
|
||||
coerced = _coerce_value(table, column, value)
|
||||
get_conn().execute(
|
||||
f"UPDATE {table} SET {column} = ? WHERE {cfg.pk} = ?", (coerced, pk_value)
|
||||
)
|
||||
|
||||
|
||||
def find_changed_cell(
|
||||
data: list[dict], data_previous: list[dict] | None
|
||||
) -> tuple[int, str, object, object] | None:
|
||||
if data_previous is None or len(data) != len(data_previous):
|
||||
return None
|
||||
for i, (new_row, old_row) in enumerate(zip(data, data_previous)):
|
||||
for col, new_val in new_row.items():
|
||||
old_val = old_row.get(col)
|
||||
if new_val != old_val:
|
||||
return i, col, old_val, new_val
|
||||
return None
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/admin/test_tables.py -v`
|
||||
Expected: 10 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/tables.py tests/admin/test_tables.py
|
||||
git commit -m "feat(admin): add whitelisted table registry and cell validation/write logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Page éditeur générique + suppression du code obsolète
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/admin/liste.py` (réécrit entièrement)
|
||||
- Modify: `src/pages/admin/_shell.py` (retire `admin_nav`)
|
||||
- Modify: `src/auth/setup.py:47-53` (retire l'enregistrement du blueprint `admin_bp`)
|
||||
- Modify: `tests/admin/conftest.py` (retire les fixtures Flask-app devenues inutiles, garde `users_db_path`)
|
||||
- Delete: `src/pages/admin/detail.py`
|
||||
- Delete: `src/pages/admin/journal.py`
|
||||
- Delete: `src/admin/routes.py`
|
||||
- Delete: `tests/admin/test_routes.py`
|
||||
- Delete: `tests/admin/test_pages.py` (recréé dans la Task 3 avec le nouveau flux — le contenu actuel teste des routes qui n'existent plus)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes : `TABLES`, `get_rows`, `set_cell`, `find_changed_cell` (Task 1) ; `is_admin()` (`src/admin/guard.py`, inchangé) ; `log_action()` (`src/admin/db.py`, inchangé) ; `not_admin()` (`src/pages/admin/_shell.py`).
|
||||
|
||||
- [ ] **Step 1: Simplifier `src/pages/admin/_shell.py`**
|
||||
|
||||
Remplacer tout le contenu par :
|
||||
|
||||
```python
|
||||
from dash import html
|
||||
|
||||
|
||||
def not_admin():
|
||||
return html.Div(
|
||||
html.H2("404", id="admin-404-heading"), className="py-5 text-center"
|
||||
)
|
||||
```
|
||||
|
||||
(`admin_nav` disparaît : il n'y a plus qu'une seule page, plus de navigation entre sous-pages.)
|
||||
|
||||
- [ ] **Step 2: Réécrire `src/pages/admin/liste.py`**
|
||||
|
||||
Remplacer tout le contenu par :
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import Input, Output, State, callback, ctx, dash_table, html, no_update, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.admin.db import log_action
|
||||
from src.admin.guard import is_admin
|
||||
from src.admin.tables import TABLES, find_changed_cell, get_rows, set_cell
|
||||
from src.pages.admin._shell import not_admin
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/admin",
|
||||
title="Panneau admin | colibre",
|
||||
name="Admin",
|
||||
description="Panneau d'administration interne.",
|
||||
)
|
||||
|
||||
DEFAULT_TABLE = "users"
|
||||
|
||||
|
||||
def _columns_for(table: str):
|
||||
cfg = TABLES[table]
|
||||
return [
|
||||
{
|
||||
"name": col,
|
||||
"id": col,
|
||||
"editable": col in cfg.editable_columns,
|
||||
**({"presentation": "dropdown"} if col in cfg.dropdowns else {}),
|
||||
}
|
||||
for col in cfg.columns
|
||||
]
|
||||
|
||||
|
||||
def _dropdown_for(table: str):
|
||||
cfg = TABLES[table]
|
||||
return {
|
||||
col: {"options": [{"label": v, "value": v} for v in values]}
|
||||
for col, values in cfg.dropdowns.items()
|
||||
}
|
||||
|
||||
|
||||
def layout(**_):
|
||||
if not is_admin():
|
||||
return not_admin()
|
||||
return dbc.Container(
|
||||
[
|
||||
html.H2("Panneau admin"),
|
||||
html.Div(id="admin-alerts"),
|
||||
dbc.Select(
|
||||
id="admin-table-select",
|
||||
options=[{"label": name, "value": name} for name in TABLES],
|
||||
value=DEFAULT_TABLE,
|
||||
className="mb-3",
|
||||
style={"maxWidth": "300px"},
|
||||
),
|
||||
dash_table.DataTable(
|
||||
id="admin-table",
|
||||
columns=_columns_for(DEFAULT_TABLE),
|
||||
data=get_rows(DEFAULT_TABLE),
|
||||
dropdown=_dropdown_for(DEFAULT_TABLE),
|
||||
editable=True,
|
||||
filter_action="native",
|
||||
sort_action="native",
|
||||
page_action="native",
|
||||
page_size=20,
|
||||
),
|
||||
],
|
||||
fluid=True,
|
||||
className="py-4",
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("admin-table", "data"),
|
||||
Output("admin-table", "columns"),
|
||||
Output("admin-table", "dropdown"),
|
||||
Output("admin-alerts", "children"),
|
||||
Input("admin-table-select", "value"),
|
||||
Input("admin-table", "data"),
|
||||
State("admin-table", "data_previous"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _update_table(selected_table, data, data_previous):
|
||||
if ctx.triggered_id == "admin-table-select":
|
||||
return (
|
||||
get_rows(selected_table),
|
||||
_columns_for(selected_table),
|
||||
_dropdown_for(selected_table),
|
||||
None,
|
||||
)
|
||||
|
||||
change = find_changed_cell(data, data_previous)
|
||||
if change is None:
|
||||
return no_update, no_update, no_update, None
|
||||
|
||||
row_index, column, old_value, new_value = change
|
||||
pk_value = data[row_index][TABLES[selected_table].pk]
|
||||
try:
|
||||
set_cell(selected_table, pk_value, column, new_value)
|
||||
except ValueError as exc:
|
||||
return (
|
||||
no_update,
|
||||
no_update,
|
||||
no_update,
|
||||
dbc.Alert(str(exc), color="danger", dismissable=True),
|
||||
)
|
||||
|
||||
target_user_id = TABLES[selected_table].target_user_id(data[row_index])
|
||||
log_action(
|
||||
current_user.email,
|
||||
f"edit_{selected_table}",
|
||||
target_user_id,
|
||||
f"{column}: {old_value!r} → {new_value!r}",
|
||||
)
|
||||
return (
|
||||
no_update,
|
||||
no_update,
|
||||
no_update,
|
||||
dbc.Alert("Modification enregistrée.", color="success", dismissable=True),
|
||||
)
|
||||
```
|
||||
|
||||
Note d'implémentation : quand une écriture échoue (type invalide, colonne non éditable, valeur hors liste), le callback renvoie `no_update` pour `data` plutôt que de réécrire activement l'ancienne valeur — cela évite tout risque de boucle de déclenchement (le callback a `data` à la fois en `Input` et `Output`). La cellule affichée côté navigateur garde alors la valeur tapée (invalide) jusqu'à ce que l'admin resélectionne la table (ce qui recharge tout depuis la base) ; l'alerte rouge signale explicitement que rien n'a été écrit.
|
||||
|
||||
- [ ] **Step 3: Supprimer le code obsolète**
|
||||
|
||||
```bash
|
||||
git rm src/pages/admin/detail.py src/pages/admin/journal.py src/admin/routes.py tests/admin/test_routes.py tests/admin/test_pages.py
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Retirer l'enregistrement du blueprint dans `src/auth/setup.py`**
|
||||
|
||||
Supprimer ces lignes (actuellement `src/auth/setup.py:51-53`, juste après l'enregistrement de `auth_bp`) :
|
||||
|
||||
```python
|
||||
from src.admin.routes import admin_bp
|
||||
|
||||
app.register_blueprint(admin_bp)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Nettoyer `tests/admin/conftest.py`**
|
||||
|
||||
Remplacer tout le contenu par (seule la fixture encore utilisée — par `tests/admin/test_tables.py` et `tests/admin/test_guard.py` — est conservée) :
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def users_db_path(monkeypatch, tmp_path):
|
||||
from src.auth.db import reset_conn_for_tests
|
||||
|
||||
db_path = tmp_path / "users.test.sqlite"
|
||||
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||
reset_conn_for_tests()
|
||||
yield db_path
|
||||
reset_conn_for_tests()
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Vérifier que le reste de la suite passe toujours**
|
||||
|
||||
Run: `uv run pytest tests/admin/ -v`
|
||||
Expected: tous les tests de `test_tables.py` et `test_guard.py` passent (pas de `test_pages.py`/`test_routes.py` à ce stade, supprimés à l'étape 3 — recréés Task 3)
|
||||
|
||||
- [ ] **Step 7: Vérification manuelle du rendu (sans navigateur)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run python -c "
|
||||
import os
|
||||
os.environ.setdefault('USERS_DB_PATH', 'tests/users.test.sqlite')
|
||||
os.environ.setdefault('SECRET_KEY', 'x')
|
||||
os.environ['ADMIN_EMAIL'] = 'admin@ex.fr'
|
||||
import src.app
|
||||
from unittest.mock import patch
|
||||
with src.app.app.server.test_request_context():
|
||||
import src.pages.admin.liste as liste
|
||||
admin = type('U', (), {'is_authenticated': True, 'email': 'admin@ex.fr'})()
|
||||
with patch('src.admin.guard.current_user', admin):
|
||||
print(type(liste.layout()))
|
||||
non_admin = type('U', (), {'is_authenticated': True, 'email': 'autre@ex.fr'})()
|
||||
with patch('src.admin.guard.current_user', non_admin):
|
||||
print(type(liste.layout()))
|
||||
"
|
||||
git status --short tests/users.test.sqlite
|
||||
```
|
||||
|
||||
Expected : deux lignes `<class '...Container.Container'>` puis `<class '...Div'>`, aucune trace d'erreur ; `git status --short tests/users.test.sqlite` ne renvoie rien (fichier inchangé).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(admin): replace dedicated pages with a generic table editor at /admin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Couverture Selenium bout-en-bout
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/admin/test_pages.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes : `src.app.app`, `src.auth.db`, `src.subscriptions.db` (inchangés).
|
||||
|
||||
**Note sur le mécanisme d'édition testé :** la colonne `subscriptions.status` est une cellule "dropdown" (`presentation: "dropdown"`), dont l'interaction Selenium est plus fragile à automatiser de façon fiable qu'une cellule texte standard (widget de sélection non natif, rendu par dash_table). Ce test exerce donc l'édition sur `subscriptions.prix_ht` (cellule texte standard, éditable en cliquant/tapant/tabulant) pour prouver que tout le pipeline fonctionne (clic → édition → callback → écriture DB → audit). La logique de validation spécifique aux colonnes "dropdown" (`status`, `plan`, `email_verified`, `trial_used`) est déjà couverte sans navigateur par `test_set_cell_rejects_invalid_dropdown_value` (Task 1).
|
||||
|
||||
**Note sur les sélecteurs CSS de cellule :** `dash_table.DataTable` rend chaque cellule avec les attributs `data-dash-row` et `data-dash-column` (documentés, stables). Si le rendu réel diverge de ce qui est écrit ci-dessous (versions de Dash), inspecter le DOM réellement produit et ajuster les sélecteurs — l'important est : cliquer précisément dans la cellule ciblée, remplacer sa valeur, puis tabuler/cliquer ailleurs pour déclencher la mise à jour de la prop `data`.
|
||||
|
||||
- [ ] **Step 1: Écrire le fichier de test**
|
||||
|
||||
Create `tests/admin/test_pages.py`:
|
||||
|
||||
```python
|
||||
import uuid
|
||||
|
||||
from dash.testing.composite import DashComposite
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.ui import Select
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
PASSWORD = "s3cretpass!"
|
||||
|
||||
|
||||
def _unique_email(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}@ex.fr"
|
||||
|
||||
|
||||
def _make_verified_user(email: str) -> int:
|
||||
auth_db.init_schema()
|
||||
uid = auth_db.create_user(email, generate_password_hash(PASSWORD))
|
||||
auth_db.set_email_verified(uid)
|
||||
return uid
|
||||
|
||||
|
||||
def _cleanup_user(user_id: int) -> None:
|
||||
conn = auth_db.get_conn()
|
||||
conn.execute("DELETE FROM admin_actions WHERE target_user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM subscriptions WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM subscriber_state WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
# tests/users.test.sqlite est committé dans git et partagé pour toute la
|
||||
# session Selenium (USERS_DB_PATH fixé globalement dans pyproject.toml).
|
||||
# Les DELETE seuls laissent le fichier byte-diffé : sqlite_sequence
|
||||
# (compteur AUTOINCREMENT) n'est jamais remis à zéro par un DELETE.
|
||||
conn.execute(
|
||||
"UPDATE sqlite_sequence SET seq = 0 "
|
||||
"WHERE name IN ('users', 'subscriptions', 'admin_actions')"
|
||||
)
|
||||
|
||||
|
||||
def _login(dash_duo: DashComposite, email: str):
|
||||
dash_duo.driver.get(dash_duo.server_url + "/connexion")
|
||||
dash_duo.wait_for_element("input[name=email]", timeout=8).send_keys(email)
|
||||
dash_duo.driver.find_element("css selector", "input[name=password]").send_keys(
|
||||
PASSWORD
|
||||
)
|
||||
dash_duo.driver.find_element("css selector", "button[type=submit]").click()
|
||||
|
||||
|
||||
def test_admin_anonymous_gets_404(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
|
||||
|
||||
|
||||
def test_admin_non_admin_gets_404(dash_duo: DashComposite, monkeypatch):
|
||||
from src.app import app
|
||||
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "admin-only@ex.fr")
|
||||
email = _unique_email("regular")
|
||||
uid = _make_verified_user(email)
|
||||
try:
|
||||
dash_duo.start_server(app)
|
||||
_login(dash_duo, email)
|
||||
# Cet utilisateur n'a pas d'abonnement : une connexion réussie
|
||||
# redirige vers /compte/abonnement (voir _post_login_url dans
|
||||
# src/auth/routes.py). Ça confirme que le login a bien réussi avant
|
||||
# de vérifier /admin (sinon ce test serait indiscernable de
|
||||
# test_admin_anonymous_gets_404 en cas de régression du login).
|
||||
dash_duo.wait_for_text_to_equal("h2", "Abonnement", timeout=8)
|
||||
assert "/connexion" not in dash_duo.driver.current_url
|
||||
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
|
||||
finally:
|
||||
_cleanup_user(uid)
|
||||
|
||||
|
||||
def test_admin_full_flow(dash_duo: DashComposite, monkeypatch):
|
||||
from src.app import app
|
||||
|
||||
admin_email = _unique_email("admin")
|
||||
monkeypatch.setenv("ADMIN_EMAIL", admin_email)
|
||||
admin_uid = _make_verified_user(admin_email)
|
||||
|
||||
target_email = _unique_email("target")
|
||||
target_uid = _make_verified_user(target_email)
|
||||
sub_db.init_schema()
|
||||
_handle, sub_id = sub_db.create_pending(target_uid, "cust-e2e", "simple", 20.0)
|
||||
sub_db.set_status(sub_id, "active")
|
||||
|
||||
try:
|
||||
dash_duo.start_server(app)
|
||||
_login(dash_duo, admin_email)
|
||||
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
|
||||
assert target_email in dash_duo.driver.page_source # table users, par défaut
|
||||
|
||||
select = Select(dash_duo.wait_for_element("#admin-table-select", timeout=8))
|
||||
select.select_by_value("subscriptions")
|
||||
dash_duo.wait_for_element(
|
||||
"td[data-dash-column='prix_ht'][data-dash-row='0']", timeout=8
|
||||
)
|
||||
|
||||
cell = dash_duo.driver.find_element(
|
||||
"css selector", "td[data-dash-column='prix_ht'][data-dash-row='0']"
|
||||
)
|
||||
cell.click()
|
||||
active_input = dash_duo.driver.switch_to.active_element
|
||||
active_input.send_keys(Keys.CONTROL, "a")
|
||||
active_input.send_keys("30")
|
||||
active_input.send_keys(Keys.TAB)
|
||||
|
||||
dash_duo.wait_for_text_to_equal(
|
||||
"#admin-alerts .alert-success", "Modification enregistrée.", timeout=8
|
||||
)
|
||||
|
||||
row = sub_db.get_current(target_uid)
|
||||
assert row["prix_ht"] == 30.0
|
||||
|
||||
select = Select(
|
||||
dash_duo.driver.find_element("css selector", "#admin-table-select")
|
||||
)
|
||||
select.select_by_value("admin_actions")
|
||||
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
|
||||
assert "edit_subscriptions" in dash_duo.driver.page_source
|
||||
assert "prix_ht" in dash_duo.driver.page_source
|
||||
finally:
|
||||
_cleanup_user(target_uid)
|
||||
_cleanup_user(admin_uid)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests**
|
||||
|
||||
Run: `uv run pytest tests/admin/test_pages.py -v`
|
||||
Expected: 3 passed. Si le clic/édition de cellule ne déclenche pas la mise à jour attendue, inspecter le DOM réel (`dash_duo.driver.page_source` ou les outils de dev du navigateur en mode non-headless) et ajuster les sélecteurs de `test_admin_full_flow` en conséquence — la structure ci-dessus est le point de départ, pas une garantie absolue selon la version exacte de `dash_table`.
|
||||
|
||||
- [ ] **Step 3: Vérifier `tests/users.test.sqlite` inchangé**
|
||||
|
||||
Run: `git status --short tests/users.test.sqlite`
|
||||
Expected: aucune sortie.
|
||||
|
||||
- [ ] **Step 4: Lancer la suite admin complète + suite globale**
|
||||
|
||||
Run: `uv run pytest tests/admin/ -v`
|
||||
Expected: tous les tests passent (test_tables.py, test_guard.py, test_pages.py).
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: aucune régression sur le reste de la suite.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/admin/test_pages.py
|
||||
git commit -m "test(admin): add end-to-end Selenium coverage for the generic table editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage :** registre de tables + validation/coercition + audit → Task 1 ; page unique, callback de sélection/édition, suppression des pages/route obsolètes, nettoyage `setup.py`/`conftest.py` → Task 2 ; couverture Selenium (accès anonyme/non-admin, flux d'édition complet, audit consultable) → Task 3. `password_hash` jamais sélectionnée (Task 1, `get_rows`/`columns` du registre `users`). Colonnes jamais éditables (PK, timestamps, handles Frisbii, `user_id`) → absentes de `editable_columns` dans le registre (Task 1), revalidées côté serveur dans le callback (Task 2). Pas de tâche pour l'ajout/suppression de lignes ni pour d'autres tables — explicitement hors périmètre du spec.
|
||||
- **Cohérence des types :** `TableConfig`, `TABLES`, `get_rows`, `set_cell`, `find_changed_cell` (Task 1) sont importés tels quels dans `liste.py` (Task 2) sans renommage. `TableConfig.target_user_id` est un `Callable[[dict], int | None]` dans les deux tâches.
|
||||
- **Écart noté par rapport au libellé du spec** ("la cellule revient à son ancienne valeur" en cas d'échec) : le callback renvoie `no_update` plutôt que de réécrire activement l'ancienne valeur, pour éviter tout risque de boucle de déclenchement sur une prop qui est à la fois `Input` et `Output` du même callback. L'intégrité des données est préservée de façon identique (rien n'est écrit en base en cas d'échec) ; seul le retour visuel immédiat diffère (l'alerte rouge est explicite, la cellule garde la saisie invalide jusqu'au rechargement de la table). Documenté dans Task 2, Step 2.
|
||||
@@ -0,0 +1,935 @@
|
||||
# Cartes /acheteur et /titulaire : afficher la contrepartie 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:** Sur `/acheteurs/<id>` et `/titulaires/<id>`, remplacer la carte Plotly à point unique (basée sur l'annuaire) par une carte dash-leaflet clusterisée qui affiche l'organisme consulté **et** sa contrepartie (titulaires pour un acheteur, acheteurs pour un titulaire).
|
||||
|
||||
**Architecture:** Extraction d'une fonction partagée `build_org_markers` (déjà dupliquée en boucle dans `get_geographic_maps` d'`/observatoire`) puis nouvelle fonction `get_org_location_map` qui assemble deux couches `dl.GeoJSON` clusterisées (une par type d'organisme) avec cadrage automatique (`bounds`). Un nouveau callback par page, déclenché par l'URL et le filtre Année, appelle cette fonction ; l'ancien callback annuaire perd la responsabilité de la carte.
|
||||
|
||||
**Tech Stack:** Python, Polars, DuckDB, Dash, `dash-leaflet` (`dl`), `dash-leaflet.express` (`dlx`), `dash-extensions` (`Namespace`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Couleurs des marqueurs : acheteur `#E69F00` (orange), titulaire `#56B4E9` (bleu ciel) — identiques à `/observatoire`.
|
||||
- La carte suit le filtre **Année** de la page (`acheteur_year`/`titulaire_year`), comme les tableaux Top titulaires/acheteurs.
|
||||
- Cadrage automatique (`fitBounds`) sur l'organisme + toutes ses contreparties, avec padding léger (`boundsOptions={"padding": [30, 30], "maxZoom": 12}`).
|
||||
- Repli sur une vue France fixe (`center=[46.6, 2.2]`, `zoom=5`) si aucune coordonnée exploitable n'est disponible (dataset sans colonnes longitude/latitude, ou organisme sans marché géolocalisé) — jamais d'exception.
|
||||
- Pas de découpage par région/DOM-TOM ni de bascule chloroplèthe sur `/acheteur`/`/titulaire` (réservé à `/observatoire`).
|
||||
- Les contrôles de zoom +/- natifs de `dl.Map` (Leaflet) doivent rester actifs — ne jamais passer `zoomControl=False`.
|
||||
- Imports internes toujours via `src.` (ex. `from src.figures import ...`), jamais `figures.py` en import relatif nu.
|
||||
- Lancer les tests avec `uv run pytest` (l'activation du venv via l'outil Bash n'est pas fiable dans cet environnement).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify `src/figures.py`** : ajoute `ORG_COLORS`, `build_org_markers`, `get_org_location_map` ; met à jour `get_geographic_maps` et `make_clusters_map` pour réutiliser `build_org_markers`/`ORG_COLORS` ; supprime `point_on_map` (devient mort après migration des deux pages).
|
||||
- **Modify `src/pages/acheteur.py`** : nouveau callback `update_acheteur_map` ; `update_acheteur_infos` perd la construction de la carte.
|
||||
- **Modify `src/pages/titulaire.py`** : nouveau callback `update_titulaire_map` ; `update_titulaire_infos` perd la construction de la carte.
|
||||
- **Test `tests/test_figures.py`** : tests unitaires de `build_org_markers` et `get_org_location_map`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extraire `build_org_markers` et l'utiliser dans `get_geographic_maps`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/figures.py:410-551` (fonction `get_geographic_maps`), `src/figures.py:582-633` (fonction `make_clusters_map`)
|
||||
- Test: `tests/test_figures.py` (ajout en fin de fichier)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `ORG_COLORS: dict[str, str]` (clés `"acheteur"`/`"titulaire"`) ; `build_org_markers(lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]) -> list[dict]` où chaque `dict` a les clés `"lat"`, `"lon"`, `"tooltip"`, `"marker_color"`. Renvoie `[]` (sans exception) si les colonnes `{org_type}_longitude`/`{org_type}_latitude` sont absentes du `LazyFrame`.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests (qui vont échouer, `build_org_markers` n'existe pas encore)**
|
||||
|
||||
Ajouter à la fin de `tests/test_figures.py` :
|
||||
|
||||
```python
|
||||
def test_build_org_markers_groups_and_counts():
|
||||
from src.figures import build_org_markers
|
||||
|
||||
lff = pl.LazyFrame(
|
||||
[
|
||||
{
|
||||
"uid": "u1",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
},
|
||||
{
|
||||
"uid": "u2",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
},
|
||||
{
|
||||
"uid": "u3",
|
||||
"acheteur_longitude": -1.68,
|
||||
"acheteur_latitude": 48.11,
|
||||
"acheteur_nom": "ACHETEUR B",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
markers = build_org_markers(lff, "acheteur")
|
||||
|
||||
assert len(markers) == 2
|
||||
marker_a = next(m for m in markers if m["tooltip"].startswith("ACHETEUR A"))
|
||||
assert marker_a["tooltip"] == "ACHETEUR A (2 marchés)"
|
||||
assert marker_a["lat"] == 48.85
|
||||
assert marker_a["lon"] == 2.35
|
||||
assert marker_a["marker_color"] == "#E69F00"
|
||||
|
||||
|
||||
def test_build_org_markers_filters_null_coordinates():
|
||||
from src.figures import build_org_markers
|
||||
|
||||
lff = pl.LazyFrame(
|
||||
[
|
||||
{
|
||||
"uid": "u1",
|
||||
"acheteur_longitude": None,
|
||||
"acheteur_latitude": None,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
},
|
||||
{
|
||||
"uid": "u2",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR B",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
markers = build_org_markers(lff, "acheteur")
|
||||
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["tooltip"] == "ACHETEUR B (1 marchés)"
|
||||
|
||||
|
||||
def test_build_org_markers_missing_columns_returns_empty():
|
||||
from src.figures import build_org_markers
|
||||
|
||||
lff = pl.LazyFrame([{"uid": "u1", "acheteur_nom": "ACHETEUR A"}])
|
||||
|
||||
assert build_org_markers(lff, "acheteur") == []
|
||||
assert build_org_markers(lff, "titulaire") == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -k build_org_markers -v`
|
||||
Expected: FAIL avec `ImportError: cannot import name 'build_org_markers'`
|
||||
|
||||
- [ ] **Step 3: Ajouter `ORG_COLORS` et `build_org_markers`, refactoriser `get_geographic_maps` et `make_clusters_map`**
|
||||
|
||||
Dans `src/figures.py`, insérer juste avant `def get_geographic_maps(dff: pl.DataFrame) -> list[dbc.Col] | list:` (ligne 410) :
|
||||
|
||||
```python
|
||||
ORG_COLORS = {
|
||||
"acheteur": "#E69F00", # orange
|
||||
"titulaire": "#56B4E9", # bleu ciel
|
||||
}
|
||||
|
||||
|
||||
def build_org_markers(
|
||||
lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]
|
||||
) -> list[dict]:
|
||||
"""Regroupe les marchés par point géographique pour un type d'organisme.
|
||||
|
||||
Renvoie [] (sans exception) si les colonnes longitude/latitude de ce
|
||||
type sont absentes du LazyFrame (ex: tests/test.parquet).
|
||||
"""
|
||||
lon_col = f"{org_type}_longitude"
|
||||
lat_col = f"{org_type}_latitude"
|
||||
nom_col = f"{org_type}_nom"
|
||||
|
||||
available = set(lff.collect_schema().names())
|
||||
if lon_col not in available or lat_col not in available:
|
||||
return []
|
||||
|
||||
lff_org = (
|
||||
lff.select("uid", lon_col, lat_col, nom_col)
|
||||
.group_by(lon_col, lat_col, nom_col)
|
||||
.len("nb_marches")
|
||||
.filter(pl.col(lat_col).is_not_null() & pl.col(lon_col).is_not_null())
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"lat": row[lat_col],
|
||||
"lon": row[lon_col],
|
||||
"tooltip": f"{row[nom_col]} ({row['nb_marches']} marchés)",
|
||||
"marker_color": ORG_COLORS[org_type],
|
||||
}
|
||||
for row in lff_org.collect().to_dicts()
|
||||
]
|
||||
```
|
||||
|
||||
Dans le corps de `get_geographic_maps` (fonction interne `make_map_data`), remplacer le bloc actuel :
|
||||
|
||||
```python
|
||||
else:
|
||||
_map_type: str = "clusters"
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
lff_org = (
|
||||
lff.select(
|
||||
"uid",
|
||||
f"{org_type}_longitude",
|
||||
f"{org_type}_latitude",
|
||||
f"{org_type}_nom",
|
||||
)
|
||||
.group_by(
|
||||
f"{org_type}_longitude",
|
||||
f"{org_type}_latitude",
|
||||
f"{org_type}_nom",
|
||||
)
|
||||
.len("nb_marches")
|
||||
.filter(
|
||||
pl.col(f"{org_type}_latitude").is_not_null()
|
||||
& pl.col(f"{org_type}_longitude").is_not_null()
|
||||
)
|
||||
)
|
||||
|
||||
markers = []
|
||||
|
||||
# Couleurs accessibles (Okabe-Ito)
|
||||
colors = {
|
||||
"acheteur": "#E69F00", # orange
|
||||
"titulaire": "#56B4E9", # bleu ciel
|
||||
}
|
||||
|
||||
for row in lff_org.collect().to_dicts():
|
||||
markers.append(
|
||||
{
|
||||
"lat": row[f"{org_type}_latitude"],
|
||||
"lon": row[f"{org_type}_longitude"],
|
||||
"tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
|
||||
"marker_color": colors[org_type],
|
||||
}
|
||||
)
|
||||
dfs.append(markers)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
else:
|
||||
_map_type: str = "clusters"
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
dfs.append(build_org_markers(lff, org_type))
|
||||
```
|
||||
|
||||
Dans `make_clusters_map`, remplacer :
|
||||
|
||||
```python
|
||||
# Couleurs
|
||||
color_acheteur = region_acheteurs[0]["marker_color"]
|
||||
color_titulaire = region_titulaires[0]["marker_color"]
|
||||
```
|
||||
|
||||
par (évite un `IndexError` maintenant que `build_org_markers` peut renvoyer une liste vide) :
|
||||
|
||||
```python
|
||||
# Couleurs
|
||||
color_acheteur = ORG_COLORS["acheteur"]
|
||||
color_titulaire = ORG_COLORS["titulaire"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: PASS (tous les tests, y compris les 3 nouveaux)
|
||||
|
||||
- [ ] **Step 5: Lancer la suite complète pour vérifier l'absence de régression**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: tous les tests passent (identique à avant la modification)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/figures.py tests/test_figures.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(figures): extraire build_org_markers pour réutilisation
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Ajouter `get_org_location_map`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/figures.py` (nouvelle fonction, à la suite de `make_clusters_map`, ligne 633)
|
||||
- Test: `tests/test_figures.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ORG_COLORS`, `build_org_markers` (Task 1)
|
||||
- Produces: `get_org_location_map(dff: pl.DataFrame, home_type: Literal["acheteur", "titulaire"], map_id: str) -> dl.Map`
|
||||
|
||||
- [ ] **Step 1: Écrire les tests (qui vont échouer, la fonction n'existe pas encore)**
|
||||
|
||||
Ajouter à la fin de `tests/test_figures.py` :
|
||||
|
||||
```python
|
||||
def test_get_org_location_map_bounds_cover_home_and_counterpart():
|
||||
import dash_leaflet as dl
|
||||
|
||||
from src.figures import get_org_location_map
|
||||
|
||||
dff = pl.DataFrame(
|
||||
[
|
||||
{
|
||||
"uid": "u1",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
"titulaire_longitude": -1.68,
|
||||
"titulaire_latitude": 48.11,
|
||||
"titulaire_nom": "TITULAIRE A",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
leaflet_map = get_org_location_map(dff, "acheteur", "test-map")
|
||||
|
||||
assert isinstance(leaflet_map, dl.Map)
|
||||
assert leaflet_map.bounds == [[48.11, -1.68], [48.85, 2.35]]
|
||||
|
||||
geojson_layers = [c for c in leaflet_map.children if isinstance(c, dl.GeoJSON)]
|
||||
assert {layer.id for layer in geojson_layers} == {
|
||||
"test-map-acheteur",
|
||||
"test-map-titulaire",
|
||||
}
|
||||
|
||||
|
||||
def test_get_org_location_map_defaults_to_france_view_without_coordinates():
|
||||
from src.figures import get_org_location_map
|
||||
|
||||
dff = pl.DataFrame(
|
||||
[{"uid": "u1", "acheteur_nom": "ACHETEUR A", "titulaire_nom": "TITULAIRE A"}]
|
||||
)
|
||||
|
||||
leaflet_map = get_org_location_map(dff, "acheteur", "test-map")
|
||||
|
||||
assert leaflet_map.center == [46.6, 2.2]
|
||||
assert leaflet_map.zoom == 5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -k get_org_location_map -v`
|
||||
Expected: FAIL avec `ImportError: cannot import name 'get_org_location_map'`
|
||||
|
||||
- [ ] **Step 3: Implémenter `get_org_location_map`**
|
||||
|
||||
Dans `src/figures.py`, ajouter à la suite de `make_clusters_map` (après la ligne `return leaflet_map` qui la termine) :
|
||||
|
||||
```python
|
||||
def get_org_location_map(
|
||||
dff: pl.DataFrame,
|
||||
home_type: Literal["acheteur", "titulaire"],
|
||||
map_id: str,
|
||||
) -> dl.Map:
|
||||
"""Carte cluster (dash-leaflet) d'un organisme et de sa contrepartie.
|
||||
|
||||
Affiche les marchés de `home_type` (fiche /acheteur ou /titulaire
|
||||
consultée) ainsi que ceux de son type complémentaire, clusterisés et
|
||||
colorés comme sur /observatoire. Cadrage automatique (fitBounds) sur
|
||||
l'ensemble des points ; repli sur une vue France fixe si aucun point
|
||||
n'est disponible.
|
||||
"""
|
||||
counterpart_type: Literal["acheteur", "titulaire"] = (
|
||||
"titulaire" if home_type == "acheteur" else "acheteur"
|
||||
)
|
||||
|
||||
lff = dff.lazy()
|
||||
markers_by_type = {
|
||||
"acheteur": build_org_markers(lff, "acheteur"),
|
||||
"titulaire": build_org_markers(lff, "titulaire"),
|
||||
}
|
||||
|
||||
ns = Namespace("dash_clientside", "leaflet")
|
||||
point_to_layer = ns("pointToLayer")
|
||||
cluster_to_layer = ns("clusterToLayer")
|
||||
|
||||
layers: list = [dl.TileLayer()]
|
||||
all_points: list[tuple[float, float]] = []
|
||||
# Ordre fixe (titulaire puis acheteur) pour que l'organisme consulté
|
||||
# soit toujours peint au-dessus de sa contrepartie, comme sur /observatoire.
|
||||
for org_type in ("titulaire", "acheteur"):
|
||||
markers = markers_by_type[org_type]
|
||||
if not markers:
|
||||
continue
|
||||
all_points.extend((m["lat"], m["lon"]) for m in markers)
|
||||
layers.append(
|
||||
dl.GeoJSON(
|
||||
data=dlx.dicts_to_geojson(markers),
|
||||
cluster=True,
|
||||
zoomToBoundsOnClick=True,
|
||||
pointToLayer=point_to_layer,
|
||||
clusterToLayer=cluster_to_layer,
|
||||
id=f"{map_id}-{org_type}",
|
||||
options={"fillColor": ORG_COLORS[org_type]},
|
||||
)
|
||||
)
|
||||
|
||||
map_kwargs: dict = {}
|
||||
if all_points:
|
||||
lats = [lat for lat, _ in all_points]
|
||||
lons = [lon for _, lon in all_points]
|
||||
map_kwargs["bounds"] = [[min(lats), min(lons)], [max(lats), max(lons)]]
|
||||
map_kwargs["boundsOptions"] = {"padding": [30, 30], "maxZoom": 12}
|
||||
else:
|
||||
map_kwargs["center"] = [46.6, 2.2]
|
||||
map_kwargs["zoom"] = 5
|
||||
|
||||
return dl.Map(
|
||||
layers,
|
||||
style={"width": "100%", "height": "300px"},
|
||||
id=map_id,
|
||||
**map_kwargs,
|
||||
)
|
||||
```
|
||||
|
||||
Note : `home_type` et `counterpart_type` ne sont pas utilisés pour changer le contenu affiché (les deux types sont toujours construits et affichés) — `home_type` sert uniquement à documenter l'intention de l'appelant et pourrait être utilisé plus tard pour un style différencié. Garder le paramètre tel quel (il est requis par l'appelant pour construire les colonnes de la requête, cf. Task 3/4).
|
||||
|
||||
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: PASS (tous les tests, y compris les 2 nouveaux)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/figures.py tests/test_figures.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(figures): ajouter get_org_location_map (carte cluster organisme + contrepartie)
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Brancher la carte sur `/acheteurs/<id>`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/acheteur.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_org_location_map` (Task 2), `_acheteur_scope(pathname, ach_year) -> tuple[str, list]` (déjà existant, `src/pages/acheteur.py:50`), `schema.names()`, `query_marches` (déjà importés)
|
||||
|
||||
- [ ] **Step 1: Mettre à jour l'import de `src.figures`**
|
||||
|
||||
Dans `src/pages/acheteur.py`, remplacer :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_top_org_table,
|
||||
make_card,
|
||||
make_column_picker,
|
||||
point_on_map,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_org_location_map,
|
||||
get_top_org_table,
|
||||
make_card,
|
||||
make_column_picker,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Retirer la construction de la carte de `update_acheteur_infos`**
|
||||
|
||||
Remplacer le callback existant (celui avec les `Output` `acheteur_siret`, `acheteur_nom`, `acheteur_commune`, `acheteur_map`, `acheteur_departement`, `acheteur_region`, `acheteur_lien_annuaire`) :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="acheteur_siret", component_property="children"),
|
||||
Output(component_id="acheteur_nom", component_property="children"),
|
||||
Output(component_id="acheteur_commune", component_property="children"),
|
||||
Output(component_id="acheteur_map", component_property="children"),
|
||||
Output(component_id="acheteur_departement", component_property="children"),
|
||||
Output(component_id="acheteur_region", component_property="children"),
|
||||
Output(component_id="acheteur_lien_annuaire", component_property="href"),
|
||||
Input(component_id="acheteur_url", component_property="pathname"),
|
||||
)
|
||||
def update_acheteur_infos(url):
|
||||
acheteur_siret = url.split("/")[-1]
|
||||
# if len(acheteur_siret) != 14:
|
||||
# acheteur_siret = (
|
||||
# f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
|
||||
# )
|
||||
data = get_annuaire_data(acheteur_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
# Extraction du code département à partir du code postal
|
||||
code_postal = data_etablissement.get("code_postal", "")
|
||||
departement_code = code_postal[:2] if code_postal else None
|
||||
|
||||
# Création de la carte avec le code département pour un centrage approprié
|
||||
acheteur_map = point_on_map(
|
||||
data_etablissement["latitude"],
|
||||
data_etablissement["longitude"],
|
||||
departement_code,
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
acheteur_map = html.Div()
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = ""
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
acheteur_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
acheteur_map,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="acheteur_siret", component_property="children"),
|
||||
Output(component_id="acheteur_nom", component_property="children"),
|
||||
Output(component_id="acheteur_commune", component_property="children"),
|
||||
Output(component_id="acheteur_departement", component_property="children"),
|
||||
Output(component_id="acheteur_region", component_property="children"),
|
||||
Output(component_id="acheteur_lien_annuaire", component_property="href"),
|
||||
Input(component_id="acheteur_url", component_property="pathname"),
|
||||
)
|
||||
def update_acheteur_infos(url):
|
||||
acheteur_siret = url.split("/")[-1]
|
||||
data = get_annuaire_data(acheteur_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = ""
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
acheteur_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output(component_id="acheteur_map", component_property="children"),
|
||||
Input(component_id="acheteur_url", component_property="pathname"),
|
||||
Input(component_id="acheteur_year", component_property="value"),
|
||||
)
|
||||
def update_acheteur_map(pathname, ach_year):
|
||||
where_sql, params = _acheteur_scope(pathname, ach_year)
|
||||
geo_columns = [
|
||||
col
|
||||
for col in [
|
||||
"uid",
|
||||
"acheteur_longitude",
|
||||
"acheteur_latitude",
|
||||
"acheteur_nom",
|
||||
"titulaire_longitude",
|
||||
"titulaire_latitude",
|
||||
"titulaire_nom",
|
||||
]
|
||||
if col in schema.names()
|
||||
]
|
||||
dff = query_marches(where_sql, params, columns=geo_columns)
|
||||
return get_org_location_map(dff, "acheteur", "acheteur_map_leaflet")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Vérifier qu'il n'y a plus de référence à `point_on_map` dans le fichier**
|
||||
|
||||
Run: `grep -n "point_on_map" src/pages/acheteur.py`
|
||||
Expected: aucune sortie
|
||||
|
||||
- [ ] **Step 4: Lancer la suite de tests complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: tous les tests passent
|
||||
|
||||
- [ ] **Step 5: Vérification manuelle dans le navigateur**
|
||||
|
||||
Run: `python run.py` (dans un terminal séparé, avec le `.venv` activé et un `.env` pointant vers un jeu de données de production contenant des colonnes longitude/latitude)
|
||||
|
||||
Naviguer vers `/acheteurs/<un-siret-existant>` :
|
||||
|
||||
- La carte doit afficher des marqueurs orange (acheteur) et bleus (titulaires), clusterisés.
|
||||
- Les boutons de zoom +/- doivent être visibles sur la carte.
|
||||
- Changer le filtre Année doit mettre à jour la carte.
|
||||
|
||||
Arrêter le serveur (`Ctrl+C`) une fois la vérification faite.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/acheteur.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(acheteur): afficher les titulaires sur la carte de la fiche acheteur
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Brancher la carte sur `/titulaires/<id>` et supprimer `point_on_map`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/titulaire.py`
|
||||
- Modify: `src/figures.py:184-254` (suppression de `point_on_map`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_org_location_map` (Task 2), `_titulaire_scope(pathname, titulaire_year) -> tuple[str, list]` (déjà existant, `src/pages/titulaire.py:49`)
|
||||
|
||||
- [ ] **Step 1: Mettre à jour l'import de `src.figures`**
|
||||
|
||||
Dans `src/pages/titulaire.py`, remplacer :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_top_org_table,
|
||||
make_column_picker,
|
||||
point_on_map,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_org_location_map,
|
||||
get_top_org_table,
|
||||
make_column_picker,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Retirer la construction de la carte de `update_titulaire_infos`**
|
||||
|
||||
Remplacer le callback existant (celui avec les `Output` `titulaire_siret`, `titulaire_nom`, `titulaire_commune`, `titulaire_map`, `titulaire_departement`, `titulaire_region`, `titulaire_lien_annuaire`, `titulaire_activite_libelle`) :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="titulaire_siret", component_property="children"),
|
||||
Output(component_id="titulaire_nom", component_property="children"),
|
||||
Output(component_id="titulaire_commune", component_property="children"),
|
||||
Output(component_id="titulaire_map", component_property="children"),
|
||||
Output(component_id="titulaire_departement", component_property="children"),
|
||||
Output(component_id="titulaire_region", component_property="children"),
|
||||
Output(component_id="titulaire_lien_annuaire", component_property="href"),
|
||||
Output(component_id="titulaire_activite_libelle", component_property="children"),
|
||||
Input(component_id="titulaire_url", component_property="pathname"),
|
||||
)
|
||||
def update_titulaire_infos(url):
|
||||
titulaire_siret = url.split("/")[-1]
|
||||
if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
|
||||
activite_libelle_row = DF_TITULAIRES.filter(
|
||||
pl.col("titulaire_id") == titulaire_siret
|
||||
).select("titulaire_activite_libelle")
|
||||
activite_libelle = (
|
||||
activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
|
||||
)
|
||||
else:
|
||||
activite_libelle = ""
|
||||
data = get_annuaire_data(titulaire_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
# Extraction du code département à partir du code postal
|
||||
code_postal = data_etablissement.get("code_postal", "")
|
||||
departement_code = code_postal[:2] if code_postal else None
|
||||
|
||||
# Création de la carte avec le code département pour un centrage approprié
|
||||
titulaire_map = point_on_map(
|
||||
data_etablissement["latitude"],
|
||||
data_etablissement["longitude"],
|
||||
departement_code,
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
titulaire_map = html.Div()
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = html.Span(
|
||||
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
|
||||
)
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
titulaire_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
titulaire_map,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
activite_libelle,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="titulaire_siret", component_property="children"),
|
||||
Output(component_id="titulaire_nom", component_property="children"),
|
||||
Output(component_id="titulaire_commune", component_property="children"),
|
||||
Output(component_id="titulaire_departement", component_property="children"),
|
||||
Output(component_id="titulaire_region", component_property="children"),
|
||||
Output(component_id="titulaire_lien_annuaire", component_property="href"),
|
||||
Output(component_id="titulaire_activite_libelle", component_property="children"),
|
||||
Input(component_id="titulaire_url", component_property="pathname"),
|
||||
)
|
||||
def update_titulaire_infos(url):
|
||||
titulaire_siret = url.split("/")[-1]
|
||||
if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
|
||||
activite_libelle_row = DF_TITULAIRES.filter(
|
||||
pl.col("titulaire_id") == titulaire_siret
|
||||
).select("titulaire_activite_libelle")
|
||||
activite_libelle = (
|
||||
activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
|
||||
)
|
||||
else:
|
||||
activite_libelle = ""
|
||||
data = get_annuaire_data(titulaire_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = html.Span(
|
||||
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
|
||||
)
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
titulaire_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
activite_libelle,
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output(component_id="titulaire_map", component_property="children"),
|
||||
Input(component_id="titulaire_url", component_property="pathname"),
|
||||
Input(component_id="titulaire_year", component_property="value"),
|
||||
)
|
||||
def update_titulaire_map(pathname, titulaire_year):
|
||||
where_sql, params = _titulaire_scope(pathname, titulaire_year)
|
||||
geo_columns = [
|
||||
col
|
||||
for col in [
|
||||
"uid",
|
||||
"acheteur_longitude",
|
||||
"acheteur_latitude",
|
||||
"acheteur_nom",
|
||||
"titulaire_longitude",
|
||||
"titulaire_latitude",
|
||||
"titulaire_nom",
|
||||
]
|
||||
if col in schema.names()
|
||||
]
|
||||
dff = query_marches(where_sql, params, columns=geo_columns)
|
||||
return get_org_location_map(dff, "titulaire", "titulaire_map_leaflet")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Supprimer `point_on_map` (devenue inutilisée) de `src/figures.py`**
|
||||
|
||||
Vérifier d'abord qu'il n'y a plus aucun appelant :
|
||||
|
||||
Run: `grep -rn "point_on_map" src/`
|
||||
Expected: aucune sortie
|
||||
|
||||
Puis supprimer la fonction complète dans `src/figures.py` (actuellement lignes 184-254, entre `def point_on_map(lat, lon, departement_code=None):` et le `return html.Div(...)` qui la clôt, juste avant `class DataTable(dash_table.DataTable):`) :
|
||||
|
||||
```python
|
||||
def point_on_map(lat, lon, departement_code=None):
|
||||
"""Fonction améliorée utilisant les codes départementaux pour la détection de région.
|
||||
|
||||
Args:
|
||||
lat: Coordonnée de latitude
|
||||
lon: Coordonnée de longitude
|
||||
departement_code: Code du département (ex: '75', '971', etc.)
|
||||
|
||||
Returns:
|
||||
html.Div contenant la carte, ou div vide si invalide
|
||||
"""
|
||||
# Validation des coordonnées
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except (TypeError, ValueError):
|
||||
return html.Div() # Div vide pour les coordonnées invalides
|
||||
|
||||
# Vérification que les coordonnées sont valides
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return html.Div()
|
||||
|
||||
# Si aucun code département n'est fourni, retourner une div vide
|
||||
if not departement_code:
|
||||
return html.Div()
|
||||
|
||||
# Détermination de la région en utilisant le code département
|
||||
# Logique identique à get_geographic_maps
|
||||
if departement_code in ["971", "972", "973", "974", "976"]:
|
||||
region_key = departement_code # Département d'outre-mer
|
||||
elif len(departement_code) == 2: # Département métropolitain
|
||||
region_key = "Hexagone"
|
||||
else:
|
||||
return html.Div() # Format de code département invalide
|
||||
|
||||
# Paramètres de carte par région (réutilisés de get_geographic_maps)
|
||||
regions = {
|
||||
"Hexagone": {"center": [46.6, 2.2], "zoom": 5},
|
||||
"971": {"center": [16.23, -61.55], "zoom": 9}, # Guadeloupe
|
||||
"972": {"center": [14.64, -61.02], "zoom": 10}, # Martinique
|
||||
"973": {"center": [3.93, -53.12], "zoom": 7}, # Guyane
|
||||
"974": {"center": [-21.11, 55.53], "zoom": 9}, # La Réunion
|
||||
"976": {"center": [-12.82, 45.16], "zoom": 10}, # Mayotte
|
||||
}
|
||||
|
||||
settings = regions.get(region_key, regions["Hexagone"])
|
||||
|
||||
# Création de la carte
|
||||
fig = px.scatter_map(
|
||||
lat=[lat],
|
||||
lon=[lon],
|
||||
height=300,
|
||||
# width=400,
|
||||
color=[1],
|
||||
zoom=settings["zoom"],
|
||||
)
|
||||
|
||||
fig.update_traces(marker=dict(size=10))
|
||||
|
||||
# Configuration de la carte (interactive - zoomable)
|
||||
fig.update_layout(
|
||||
map_style="light", # Fond de carte clair
|
||||
margin={"r": 0, "t": 0, "l": 0, "b": 0},
|
||||
mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]},
|
||||
mapbox_zoom=settings["zoom"],
|
||||
coloraxis_showscale=False,
|
||||
)
|
||||
|
||||
return html.Div(
|
||||
dcc.Graph(figure=fig, config={"displayModeBar": False}),
|
||||
)
|
||||
```
|
||||
|
||||
Supprimer l'intégralité de ce bloc (la ligne vide qui le sépare de `class DataTable` peut rester telle quelle).
|
||||
|
||||
- [ ] **Step 4: Lancer la suite de tests complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: tous les tests passent
|
||||
|
||||
- [ ] **Step 5: Vérification manuelle dans le navigateur**
|
||||
|
||||
Run: `python run.py` (avec un jeu de données de production contenant des colonnes longitude/latitude)
|
||||
|
||||
Naviguer vers `/titulaires/<un-siret-existant>` :
|
||||
|
||||
- La carte doit afficher un marqueur bleu (titulaire) et des marqueurs orange (acheteurs), clusterisés.
|
||||
- Les boutons de zoom +/- doivent être visibles.
|
||||
- Changer le filtre Année doit mettre à jour la carte.
|
||||
|
||||
Arrêter le serveur (`Ctrl+C`) une fois la vérification faite.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/titulaire.py src/figures.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(titulaire): afficher les acheteurs sur la carte de la fiche titulaire
|
||||
|
||||
Supprime point_on_map, devenue inutilisée après migration des deux pages
|
||||
vers get_org_location_map.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
@@ -0,0 +1,849 @@
|
||||
# Refonte du tunnel d'abonnement — 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:** Rendre l'offre d'abonnement visible aux visiteurs non connectés et raccourcir le tunnel d'inscription/abonnement.
|
||||
|
||||
**Architecture:** Les composants « cards » d'abonnement quittent la page authentifiée `/compte/abonnement` pour la page publique `/a-propos/abonnement`, complétés d'un unique bouton « Je m'abonne » dont la cible dépend de l'état de connexion. Le choix du plan (simple/soutien) migre dans le formulaire `mes-infos`. La validation d'email connecte automatiquement l'utilisateur et le dépose dans `mes-infos`.
|
||||
|
||||
**Tech Stack:** Dash 3.4, Dash Bootstrap Components, Flask + Flask-Login, SQLite (users.sqlite), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Interface et libellés **en français**.
|
||||
- Imports du code applicatif toujours préfixés `src.` (ex. `src.pages.compte.abonnement`).
|
||||
- Lancer les tests avec `uv run pytest` (l'activation de venv via Bash n'est pas fiable ici).
|
||||
- **Soumission de formulaire native** : seul un `dcc.Input`/`dbc.Input` portant un
|
||||
attribut `name` est soumis dans le POST d'un `html.Form`. Une interaction Dash
|
||||
contrôlée (clic sur carte, `RadioItems`…) n'est **pas** soumise nativement — la
|
||||
relier à un `dcc.Input(type="hidden", name=...)` via callback. `html.Input` n'existe
|
||||
pas en Dash 3.4.
|
||||
- Boutons : classes Bootstrap existantes (`btn btn-primary`, `btn btn-outline-danger`…), cohérentes avec le reste du site.
|
||||
- Chaque commit doit passer `uv run pytest` (au minimum les tests touchés).
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : `linkedin_button` paramétrable + inscription vers le tunnel
|
||||
|
||||
Le bouton LinkedIn est réutilisé par `/connexion` et `/inscription`. On lui ajoute un
|
||||
paramètre `next_url` optionnel pour que l'inscription via LinkedIn finisse dans
|
||||
`mes-infos` (au lieu du fallback `/compte/admin`).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/connexion.py` (fonction `linkedin_button`, lignes 31-37)
|
||||
- Modify: `src/pages/inscription.py` (appel `linkedin_button()`, ligne 73)
|
||||
- Test: `tests/test_linkedin_button.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `linkedin_button(next_url: str | None = None) -> html.A`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/test_linkedin_button.py
|
||||
def test_linkedin_button_default_has_no_next():
|
||||
from src.pages.connexion import linkedin_button
|
||||
|
||||
html_str = str(linkedin_button())
|
||||
assert "href='/auth/linkedin'" in html_str
|
||||
assert "next=" not in html_str
|
||||
|
||||
|
||||
def test_linkedin_button_with_next_appends_query():
|
||||
from src.pages.connexion import linkedin_button
|
||||
|
||||
html_str = str(linkedin_button("/compte/abonnement/mes-infos"))
|
||||
assert "/auth/linkedin?next=/compte/abonnement/mes-infos" in html_str
|
||||
|
||||
|
||||
def test_inscription_linkedin_targets_mes_infos():
|
||||
from src.pages import inscription
|
||||
|
||||
assert "/auth/linkedin?next=/compte/abonnement/mes-infos" in str(
|
||||
inscription.layout()
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/test_linkedin_button.py -v`
|
||||
Expected: FAIL (`linkedin_button()` prend 0 argument / le `next` n'est pas présent)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/connexion.py`, remplacer la fonction `linkedin_button` :
|
||||
|
||||
```python
|
||||
def linkedin_button(next_url: str | None = None):
|
||||
href = "/auth/linkedin"
|
||||
if next_url:
|
||||
href += f"?next={next_url}"
|
||||
return html.A(
|
||||
"Connexion avec LinkedIn",
|
||||
href=href,
|
||||
className="btn w-100 mb-2",
|
||||
style={"backgroundColor": "rgb(10, 102, 194)", "color": "white"},
|
||||
)
|
||||
```
|
||||
|
||||
Dans `src/pages/inscription.py`, ligne 73, remplacer `linkedin_button(),` par :
|
||||
|
||||
```python
|
||||
linkedin_button("/compte/abonnement/mes-infos"),
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/test_linkedin_button.py -v`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/connexion.py src/pages/inscription.py tests/test_linkedin_button.py
|
||||
git commit -m "feat(abonnement): linkedin_button paramétrable par next, inscription vers mes-infos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Auto-login à la validation d'email
|
||||
|
||||
`verify_email` doit connecter automatiquement l'utilisateur et le rediriger vers
|
||||
`mes-infos` au lieu de `/connexion?verified=1`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/routes.py` (fonction `verify_email`, lignes 92-101)
|
||||
- Test: `tests/auth/test_verify_email.py` (modifier le test du token valide)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `db.get_user_by_id`, `db.set_email_verified`, `tokens.consume_verification_token`, `User`, `login_user` (déjà importés dans le module).
|
||||
|
||||
- [ ] **Step 1 : Adapter le test pour qu'il échoue**
|
||||
|
||||
Dans `tests/auth/test_verify_email.py`, remplacer `test_verify_email_valid_token_marks_user_verified` par :
|
||||
|
||||
```python
|
||||
def test_verify_email_valid_token_logs_in_and_redirects_to_mes_infos(
|
||||
client, users_db_path
|
||||
):
|
||||
db.init_schema()
|
||||
uid = db.create_user("a@b.c", "hash")
|
||||
token = tokens.create_verification_token(uid)
|
||||
|
||||
resp = client.get(f"/auth/verify-email?token={token}")
|
||||
assert resp.status_code == 302
|
||||
assert "/compte/abonnement/mes-infos" in resp.headers["Location"]
|
||||
assert db.get_user_by_id(uid)["email_verified"] == 1
|
||||
with client.session_transaction() as sess:
|
||||
assert sess.get("_user_id") == str(uid)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_verify_email.py -v`
|
||||
Expected: FAIL (Location vaut encore `/connexion?verified=1`, session vide)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/auth/routes.py`, dans `verify_email`, remplacer les deux dernières lignes
|
||||
(`db.set_email_verified(user_id)` + `return redirect("/connexion?verified=1")`) par :
|
||||
|
||||
```python
|
||||
db.set_email_verified(user_id)
|
||||
login_user(User(db.get_user_by_id(user_id)), remember=True)
|
||||
return redirect("/compte/abonnement/mes-infos")
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_verify_email.py -v`
|
||||
Expected: PASS (4 tests — les 3 autres restent inchangés)
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/routes.py tests/auth/test_verify_email.py
|
||||
git commit -m "feat(auth): auto-login à la validation d'email, redirection vers mes-infos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Page publique `/a-propos/abonnement` (cards + bouton « Je m'abonne »)
|
||||
|
||||
On installe dans la page publique : les cards informatives (sans bouton par card),
|
||||
l'explainer, et le bouton unique conditionnel. On retire la sous-section
|
||||
« Fonctionnalités incluses » des CGU (doublon avec l'explainer).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/a_propos/abonnement.py`
|
||||
- Test: `tests/test_abonnement_public.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
|
||||
- `_plan_card(meta: dict, trial: int | None) -> dbc.Card`
|
||||
- `_plan_cards(trial_for=plans.trial_days) -> dbc.Row`
|
||||
- `_explainer() -> dbc.Row`
|
||||
- `_subscribe_button(authenticated: bool, has_active_subscription: bool, tous_abonnes: bool) -> html.Div`
|
||||
- `abonnement_features` et `subscription_terms` restent exportés (déjà consommés par `compte/abonnement_mes_infos.py`).
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||
|
||||
```python
|
||||
# tests/test_abonnement_public.py
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plan_env(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
|
||||
|
||||
def test_plan_cards_informative_no_button():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._plan_cards(trial_for=lambda key: 2))
|
||||
assert "Abonnement" in text
|
||||
assert "Abonnement de soutien" in text
|
||||
assert "2 jours d'essai gratuit" in text
|
||||
assert "S'abonner" not in text
|
||||
|
||||
|
||||
def test_subscribe_button_visitor_goes_to_inscription():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(False, False, False))
|
||||
assert "Je m'abonne" in text
|
||||
assert "href='/inscription'" in text
|
||||
|
||||
|
||||
def test_subscribe_button_authenticated_no_sub_goes_to_mes_infos():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(True, False, False))
|
||||
assert "href='/compte/abonnement/mes-infos'" in text
|
||||
|
||||
|
||||
def test_subscribe_button_active_sub_manages():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(True, True, False))
|
||||
assert "Gérer mon abonnement" in text
|
||||
assert "href='/compte/abonnement'" in text
|
||||
|
||||
|
||||
def test_subscribe_button_disabled_when_tous_abonnes():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(False, False, True))
|
||||
assert "disabled" in text
|
||||
|
||||
|
||||
def test_cgu_terms_trimmed_of_features_section():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page.subscription_terms)
|
||||
assert "Fonctionnalités incluses" not in text
|
||||
assert "Résiliation" in text
|
||||
assert "Tarifs" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/test_abonnement_public.py -v`
|
||||
Expected: FAIL (`_plan_cards`/`_subscribe_button` inexistants ; « Fonctionnalités incluses » encore présent)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/a_propos/abonnement.py` :
|
||||
|
||||
3a. Ajouter les imports en tête :
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import dcc, html, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._apropos_shell import apropos_shell
|
||||
from src.subscriptions import db as sub_db
|
||||
from src.subscriptions import plans
|
||||
from src.utils import TOUS_ABONNES
|
||||
from src.utils.seo import META_CONTENT
|
||||
```
|
||||
|
||||
3b. Ajouter les composants cards (avant `subscription_terms`) :
|
||||
|
||||
```python
|
||||
def _plan_card(meta: dict, trial: int | None):
|
||||
badge = (
|
||||
html.Div(f"{trial} jours d'essai gratuit", className="mb-3") if trial else None
|
||||
)
|
||||
return dbc.Card(
|
||||
dbc.CardBody(
|
||||
[
|
||||
html.H4(meta["label"], className="mb-1"),
|
||||
html.P(
|
||||
f"{meta['prix_ht']} € HT / mois "
|
||||
f"({str(int(meta['prix_ht']) * 1.2).replace('.0', '')} € TTC)",
|
||||
className="text-muted mb-3",
|
||||
),
|
||||
html.P(meta["description"], className="mb-3"),
|
||||
badge,
|
||||
],
|
||||
className="p-4",
|
||||
),
|
||||
className="h-100",
|
||||
)
|
||||
|
||||
|
||||
def _plan_cards(trial_for=plans.trial_days):
|
||||
cards = []
|
||||
for key in ("simple", "soutien"):
|
||||
meta = plans.plan_meta(key)
|
||||
if meta:
|
||||
cards.append(_plan_card(meta, trial_for(key)))
|
||||
return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-4")
|
||||
|
||||
|
||||
def _explainer():
|
||||
col_left = dbc.Col(
|
||||
[html.H4("Fonctionnalités réservées aux abonné·es :"), abonnement_features],
|
||||
md=6,
|
||||
style={
|
||||
"borderRight": "1px solid var(--bs-border-color)",
|
||||
"paddingRight": "2rem",
|
||||
},
|
||||
)
|
||||
col_right = dbc.Col(
|
||||
[
|
||||
html.H4("Ce que les abonnements permettent"),
|
||||
html.Ul(
|
||||
[
|
||||
html.Li(
|
||||
"passer plus de temps à développer colibre et moins de temps "
|
||||
"à chercher des missions"
|
||||
),
|
||||
html.Li(
|
||||
"rédaction d'études à partir des données, par exemple sur les "
|
||||
"acheteurs dont les données sont introuvables et les raisons "
|
||||
"de cette non-publication."
|
||||
),
|
||||
html.Li(
|
||||
"fédération des bonnes volontés souhaitant militer pour une "
|
||||
"législation plus ambitieuse sur la transparence de la "
|
||||
"commande publique."
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
md=6,
|
||||
style={"paddingLeft": "2rem"},
|
||||
)
|
||||
return dbc.Row([col_left, col_right], className="align-items-start pt-2 mb-4")
|
||||
|
||||
|
||||
def _subscribe_button(
|
||||
authenticated: bool, has_active_subscription: bool, tous_abonnes: bool
|
||||
):
|
||||
if tous_abonnes:
|
||||
return html.Div(
|
||||
[
|
||||
dbc.Alert(
|
||||
"Les fonctionnalités normalement accessibles contre un abonnement "
|
||||
"de 20 € HT par mois sont accessibles à tous et toutes en attendant "
|
||||
"la validation de mon dossier pour recevoir des paiements.",
|
||||
color="info",
|
||||
),
|
||||
html.A(
|
||||
"Je m'abonne",
|
||||
href="#",
|
||||
className="btn btn-secondary disabled",
|
||||
),
|
||||
],
|
||||
className="text-center my-4",
|
||||
)
|
||||
if authenticated and has_active_subscription:
|
||||
label, href = "Gérer mon abonnement", "/compte/abonnement"
|
||||
elif authenticated:
|
||||
label, href = "Je m'abonne", "/compte/abonnement/mes-infos"
|
||||
else:
|
||||
label, href = "Je m'abonne", "/inscription"
|
||||
return html.Div(
|
||||
html.A(label, href=href, className="btn btn-primary btn-lg"),
|
||||
className="text-center my-4",
|
||||
)
|
||||
```
|
||||
|
||||
3c. Dans `subscription_terms`, **supprimer** le bloc « Fonctionnalités incluses »
|
||||
(le `html.H4("Fonctionnalités incluses")`, les deux `dcc.Markdown` qui l'entourent
|
||||
et l'insertion de `abonnement_features`). Garder `abonnement_features` **défini** au
|
||||
niveau module (il sert à `_explainer`). Le reste des CGU est inchangé.
|
||||
|
||||
3d. Remplacer `layout` :
|
||||
|
||||
```python
|
||||
def layout(**_):
|
||||
authenticated = current_user.is_authenticated
|
||||
has_active = authenticated and sub_db.has_active_subscription(current_user.id)
|
||||
body = html.Div(
|
||||
[
|
||||
_plan_cards(),
|
||||
_explainer(),
|
||||
_subscribe_button(authenticated, has_active, TOUS_ABONNES),
|
||||
subscription_terms,
|
||||
]
|
||||
)
|
||||
return apropos_shell("abonnement", body)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/test_abonnement_public.py -v`
|
||||
Expected: PASS (6 tests)
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/a_propos/abonnement.py tests/test_abonnement_public.py
|
||||
git commit -m "feat(abonnement): page publique avec cards et bouton Je m'abonne conditionnel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : `/compte/abonnement` non-abonné → bouton « M'abonner » / « Me réabonner »
|
||||
|
||||
La branche non-abonné n'affiche plus de cards mais un bouton dont le libellé dépend de
|
||||
`has_used_trial`. On supprime les composants cards devenus inutilisés ici et on migre
|
||||
les tests obsolètes.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement.py`
|
||||
- Modify: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `db.has_used_trial(user_id) -> bool` (existant).
|
||||
- Produces: `_reabo_button(has_used_trial: bool) -> html.Div`
|
||||
|
||||
- [ ] **Step 1 : Écrire / migrer les tests**
|
||||
|
||||
Dans `tests/subscriptions/test_compte_abonnement.py`, **supprimer** ces 4 tests
|
||||
(devenus obsolètes : les cards et leurs boutons ont quitté ce module) :
|
||||
`test_plan_cards_present_when_no_subscription`, `test_plan_cards_no_trial_when_trial_used`,
|
||||
`test_subscribe_buttons_disabled_when_tous_abonnes`, `test_subscribe_buttons_active_when_flag_off`.
|
||||
|
||||
**Ajouter** :
|
||||
|
||||
```python
|
||||
def test_reabo_button_never_subscribed():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._reabo_button(has_used_trial=False))
|
||||
assert "M'abonner" in text
|
||||
assert "Me réabonner" not in text
|
||||
assert "href='/a-propos/abonnement'" in text
|
||||
|
||||
|
||||
def test_reabo_button_former_subscriber():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._reabo_button(has_used_trial=True))
|
||||
assert "Me réabonner" in text
|
||||
assert "href='/a-propos/abonnement'" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: FAIL (`_reabo_button` inexistant)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/compte/abonnement.py` :
|
||||
|
||||
3a. Supprimer l'import `from src.pages.a_propos.abonnement import abonnement_features`.
|
||||
|
||||
3b. Supprimer les fonctions `_plan_card`, `_plan_cards`, `_explainer` (devenues
|
||||
inutilisées ici — elles vivent désormais dans `a_propos/abonnement.py`).
|
||||
|
||||
3c. Ajouter :
|
||||
|
||||
```python
|
||||
def _reabo_button(has_used_trial: bool):
|
||||
label = "Me réabonner" if has_used_trial else "M'abonner"
|
||||
return html.Div(
|
||||
[
|
||||
html.P(
|
||||
"Abonnez-vous pour accéder aux fonctionnalités réservées "
|
||||
"aux abonné·es.",
|
||||
className="mb-3",
|
||||
),
|
||||
html.A(
|
||||
label,
|
||||
href="/a-propos/abonnement",
|
||||
className="btn btn-primary",
|
||||
),
|
||||
],
|
||||
className="mb-4",
|
||||
)
|
||||
```
|
||||
|
||||
3d. Dans `layout`, la branche `else` (celle qui faisait
|
||||
`body.extend([_plan_cards(trial_used=trial_used), _explainer()])`) devient :
|
||||
|
||||
```python
|
||||
else:
|
||||
if row is not None and row["status"] == "expired":
|
||||
body.append(
|
||||
dbc.Alert(
|
||||
"Votre abonnement a expiré.", color="warning", className="mb-4"
|
||||
)
|
||||
)
|
||||
body.append(_reabo_button(db.has_used_trial(current_user.id)))
|
||||
```
|
||||
|
||||
La ligne `trial_used = db.has_used_trial(...)` en tête de `layout` n'est plus utilisée
|
||||
que… nulle part → **la supprimer** (lignes ~290-292 : la variable `trial_used`).
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: PASS (les tests `_active_view`, `_tous_abonnes_banner`, `_show_active_view` + les 2 nouveaux)
|
||||
|
||||
- [ ] **Step 5 : Vérifier l'absence de régression d'import**
|
||||
|
||||
Run: `uv run pytest tests/test_compte_pages.py tests/subscriptions/ -v`
|
||||
Expected: PASS (aucun module ne référence plus `compte.abonnement._plan_cards`)
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "feat(abonnement): /compte/abonnement non-abonné affiche M'abonner/Me réabonner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : CTA de `/connexion` vers l'offre d'abonnement
|
||||
|
||||
Le bouton du bas de `/connexion` pointe vers `/a-propos/abonnement` au lieu de
|
||||
`/inscription`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/connexion.py` (bloc final du `layout`, lignes 91-96)
|
||||
- Test: `tests/test_connexion_page.py` (créer)
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/test_connexion_page.py
|
||||
def test_connexion_cta_points_to_abonnement():
|
||||
from src.pages import connexion
|
||||
|
||||
text = str(connexion.layout())
|
||||
assert "/a-propos/abonnement" in text
|
||||
assert "Voir les abonnements" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/test_connexion_page.py -v`
|
||||
Expected: FAIL (le CTA pointe encore vers `/inscription`)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/connexion.py`, remplacer le dernier bloc du `layout` (le
|
||||
`html.A("Créer un compte avec mon adresse email", href="/inscription", ...)`) par :
|
||||
|
||||
```python
|
||||
html.A(
|
||||
"Pas encore de compte ? Voir les abonnements",
|
||||
href="/a-propos/abonnement",
|
||||
className="btn btn-primary w-100",
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/test_connexion_page.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/connexion.py tests/test_connexion_page.py
|
||||
git commit -m "feat(abonnement): CTA connexion vers /a-propos/abonnement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6 : Sélection de la formule par cartes cliquables dans `mes-infos`
|
||||
|
||||
Le formulaire `mes-infos` n'exige plus de paramètre `?plan=`. La formule se choisit en
|
||||
cliquant l'une des deux cartes (réutilisées de la page publique). Aucune sélection par
|
||||
défaut : un texte invite l'utilisateur ; la carte choisie prend un fond teinté. Un
|
||||
callback recopie la valeur dans un champ caché natif soumis dans le POST.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py`
|
||||
- Modify: `src/assets/css/style.css`
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `_plan_card` (Task 3, depuis `src.pages.a_propos.abonnement`),
|
||||
`plans.plan_meta`, `plans.trial_days`, `sub_db.has_used_trial`.
|
||||
- Produces:
|
||||
|
||||
- `_selectable_cards(trial_for) -> dbc.Row`
|
||||
- `_selection_state(selected: str) -> tuple[str, str, str, str]` (valeur cachée, classe carte simple, classe carte soutien, classe invite) — pur, testable
|
||||
- callback `_select_plan` (déclenché par `n_clicks` des cartes via `ctx.triggered_id`)
|
||||
- `_toggle_submit(retractation, cgu, plan) -> bool` (désactive tant qu'une case ou la formule manque)
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||
|
||||
```python
|
||||
# tests/subscriptions/test_mes_infos_plan.py
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plan_env(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
|
||||
|
||||
def test_selectable_cards_render_both_plans():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._selectable_cards(trial_for=lambda key: 2))
|
||||
assert "plan-card-simple" in text
|
||||
assert "plan-card-soutien" in text
|
||||
assert "plan-selectable" in text
|
||||
assert "Abonnement de soutien" in text
|
||||
|
||||
|
||||
def test_selection_state_simple():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
value, cls_simple, cls_soutien, cls_invite = m._selection_state("simple")
|
||||
assert value == "simple"
|
||||
assert "selected" in cls_simple
|
||||
assert "selected" not in cls_soutien
|
||||
assert cls_invite == "d-none"
|
||||
|
||||
|
||||
def test_selection_state_soutien():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
value, cls_simple, cls_soutien, _ = m._selection_state("soutien")
|
||||
assert value == "soutien"
|
||||
assert "selected" in cls_soutien
|
||||
assert "selected" not in cls_simple
|
||||
|
||||
|
||||
def test_submit_disabled_without_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
assert m._toggle_submit(["ok"], ["ok"], "") is True
|
||||
assert m._toggle_submit(["ok"], ["ok"], "simple") is False
|
||||
assert m._toggle_submit([], ["ok"], "simple") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: FAIL (`_selectable_cards` / `_selection_state` inexistants)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
3a. Dans `src/pages/compte/abonnement_mes_infos.py`, compléter les imports :
|
||||
ajouter `ctx` à l'import `from dash import ...`, ajouter `_plan_card` à l'import
|
||||
existant depuis `src.pages.a_propos.abonnement`, et ajouter :
|
||||
|
||||
```python
|
||||
from src.subscriptions import db as sub_db
|
||||
from src.subscriptions import plans
|
||||
```
|
||||
|
||||
3b. Ajouter les helpers + callback de sélection (par ex. juste après `_csrf_input`) :
|
||||
|
||||
```python
|
||||
def _trial_for(user_id):
|
||||
used = sub_db.has_used_trial(user_id)
|
||||
return lambda key: None if used else plans.trial_days(key)
|
||||
|
||||
|
||||
def _selectable_cards(trial_for):
|
||||
cols = []
|
||||
for key in ("simple", "soutien"):
|
||||
meta = plans.plan_meta(key)
|
||||
if not meta:
|
||||
continue
|
||||
cols.append(
|
||||
dbc.Col(
|
||||
html.Div(
|
||||
_plan_card(meta, trial_for(key)),
|
||||
id=f"plan-card-{key}",
|
||||
n_clicks=0,
|
||||
className="plan-selectable",
|
||||
),
|
||||
md=6,
|
||||
)
|
||||
)
|
||||
return dbc.Row(cols, className="g-4 mb-2")
|
||||
|
||||
|
||||
def _selection_state(selected):
|
||||
base = "plan-selectable"
|
||||
return (
|
||||
selected,
|
||||
f"{base} selected" if selected == "simple" else base,
|
||||
f"{base} selected" if selected == "soutien" else base,
|
||||
"d-none",
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("inf-plan-hidden", "value"),
|
||||
Output("plan-card-simple", "className"),
|
||||
Output("plan-card-soutien", "className"),
|
||||
Output("inf-plan-invite", "className"),
|
||||
Input("plan-card-simple", "n_clicks"),
|
||||
Input("plan-card-soutien", "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _select_plan(_n_simple, _n_soutien):
|
||||
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
|
||||
return _selection_state(selected)
|
||||
```
|
||||
|
||||
3c. Modifier le callback `_toggle_submit` existant pour exiger aussi une formule :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("inf-submit", "disabled"),
|
||||
Input("inf-cb-retractation", "value"),
|
||||
Input("inf-cb-cgu", "value"),
|
||||
Input("inf-plan-hidden", "value"),
|
||||
)
|
||||
def _toggle_submit(retractation, cgu, plan):
|
||||
return not (retractation and cgu and plan)
|
||||
```
|
||||
|
||||
3d. Dans `layout`, **supprimer** la garde `?plan=` (lignes 53-55) :
|
||||
|
||||
```python
|
||||
plan = query.get("plan", "")
|
||||
if not plan:
|
||||
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
|
||||
```
|
||||
|
||||
3e. Dans le `html.Form` (`children=`), **remplacer** la ligne
|
||||
`dcc.Input(type="hidden", name="plan", value=plan),` par l'invite, les cartes
|
||||
sélectionnables et le champ caché vide :
|
||||
|
||||
```python
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Div(
|
||||
"Choisissez votre formule :",
|
||||
id="inf-plan-invite",
|
||||
className="fw-bold mb-2",
|
||||
),
|
||||
_selectable_cards(_trial_for(current_user.id)),
|
||||
dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value=""),
|
||||
dbc.Row([col1, col2], className="g-4 mb-4"),
|
||||
checkboxes,
|
||||
html.Button(
|
||||
"Ajout d'une carte de paiement",
|
||||
id="inf-submit",
|
||||
type="submit",
|
||||
className="btn btn-primary",
|
||||
disabled=True,
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
3f. Ajouter à la fin de `src/assets/css/style.css` :
|
||||
|
||||
```css
|
||||
.plan-selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.plan-selectable .card {
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.plan-selectable.selected .card {
|
||||
background-color: var(--bs-primary-bg-subtle);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
- [ ] **Step 5 : Vérifier qu'aucun test existant ne dépendait de la garde `?plan=`**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ tests/test_page_loads.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte/abonnement_mes_infos.py src/assets/css/style.css tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): sélection de formule par cartes cliquables dans mes-infos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7 : Vérification de bout en bout
|
||||
|
||||
**Files:** aucun (validation).
|
||||
|
||||
- [ ] **Step 1 : Suite complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: PASS (aucune régression). Si des tests Selenium échouent faute de Chrome,
|
||||
les relever mais valider au moins les tests unitaires et de routes touchés.
|
||||
|
||||
- [ ] **Step 2 : Revue manuelle du parcours** (skill `verify` recommandé)
|
||||
|
||||
Dérouler : `/a-propos/abonnement` (déconnecté) → « Je m'abonne » → `/inscription` →
|
||||
signup → lien email → auto-login → `mes-infos` (radios présents, défaut « simple »)
|
||||
→ soumission → checkout. Puis, connecté sans abonnement : `/compte/abonnement`
|
||||
montre « M'abonner ».
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (auteur)
|
||||
|
||||
**Couverture spec :**
|
||||
|
||||
- §1 page publique cards/explainer/bouton/trim CGU → Task 3 ✓
|
||||
- §2 bouton conditionnel (4 états) → Task 3 (`_subscribe_button`) ✓
|
||||
- §3 `/compte/abonnement` M'abonner/Me réabonner via `has_used_trial` → Task 4 ✓
|
||||
- §4 mes-infos cartes cliquables + gating submit + suppression garde `?plan=` → Task 6 ✓
|
||||
- §5 `verify_email` auto-login → Task 2 ✓
|
||||
- §6 CTA connexion → Task 5 ✓
|
||||
- §7 `linkedin_button(next)` + inscription → Task 1 ✓
|
||||
|
||||
**Placeholders :** aucun ; tout le code est fourni.
|
||||
|
||||
**Cohérence des types :** `_subscribe_button(bool, bool, bool)`, `_reabo_button(bool)`,
|
||||
`_plan_cards(trial_for)` / `_plan_card(meta, trial)` (Task 3, réutilisé par Task 6),
|
||||
`_selectable_cards(trial_for) -> dbc.Row`, `_selection_state(str) -> tuple[str,str,str,str]`,
|
||||
`_toggle_submit(retractation, cgu, plan) -> bool` — noms et signatures cohérents entre
|
||||
tâches et tests.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Changer de méthode de paiement (issue #108) — 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:** Permettre à un·e abonné·e en essai ou actif de changer sa carte bancaire depuis `/compte/abonnement`, via la page hébergée Frisbii dédiée (`hosted_page_links.payment_info` de l'objet subscription).
|
||||
|
||||
**Architecture:** Une fonction client Frisbii récupère l'abonnement et construit l'URL de la page hébergée avec `accept_url`/`cancel_url`. Une route Flask (symétrique à `/subscriptions/add-payment` et `/subscriptions/cancel`) sert de point d'entrée POST et redirige (303) vers cette URL. Un bouton sur `/compte/abonnement` déclenche cette route ; les retours (`?carte=succes`/`?carte=annule`) affichent un message dans `_feedback()`.
|
||||
|
||||
**Tech Stack:** Python, Flask, Dash + Dash Bootstrap Components, httpx (déjà en place dans `src/subscriptions/client.py`), pytest + monkeypatch pour les tests.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Le bouton n'apparaît que pour `row["status"] in ("trial", "active")` — pas `pending` (garde son bouton "Ajouter une méthode de paiement" existant), pas `cancelled` (rien à facturer, chemin logique = reprendre un abonnement).
|
||||
- Réutiliser `client.get_subscription` déjà existant (`src/subscriptions/client.py:136`), ne pas dupliquer l'appel HTTP.
|
||||
- Suivre le pattern CSRF + `html.Form` POST déjà utilisé pour "Me désabonner" / "Ajouter une méthode de paiement" dans `src/pages/compte/abonnement.py`.
|
||||
- Aucun nouveau webhook/callback : Frisbii associe la nouvelle méthode de paiement à l'abonnement de son côté.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `client.get_payment_info_url`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/client.py`
|
||||
- Test: `tests/subscriptions/test_client.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_subscription(subscription_handle: str) -> dict` (déjà défini à `src/subscriptions/client.py:136`), qui retourne le JSON brut de `GET /v1/subscription/{handle}` — notamment `hosted_page_links: {"payment_info": "<url>"}`.
|
||||
- Produces: `get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str`, utilisé par la route du Task 2.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Ajouter à la fin de `tests/subscriptions/test_client.py` :
|
||||
|
||||
```python
|
||||
def test_get_payment_info_url_appends_query_params(fake_httpx):
|
||||
fake_httpx["queue"].append(
|
||||
fake_httpx["Response"](
|
||||
200,
|
||||
{
|
||||
"handle": "abo-1-1",
|
||||
"hosted_page_links": {
|
||||
"payment_info": "https://checkout.reepay.com/#/subscription/pay/en_GB/x/abo-1-1"
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
url = client.get_payment_info_url(
|
||||
"abo-1-1", "https://app/compte/abonnement?carte=succes",
|
||||
"https://app/compte/abonnement?carte=annule",
|
||||
)
|
||||
assert url.startswith(
|
||||
"https://checkout.reepay.com/#/subscription/pay/en_GB/x/abo-1-1"
|
||||
)
|
||||
assert "accept_url=https%3A%2F%2Fapp%2Fcompte%2Fabonnement%3Fcarte%3Dsucces" in url
|
||||
assert "cancel_url=https%3A%2F%2Fapp%2Fcompte%2Fabonnement%3Fcarte%3Dannule" in url
|
||||
|
||||
|
||||
def test_get_payment_info_url_merges_existing_query_string(fake_httpx):
|
||||
fake_httpx["queue"].append(
|
||||
fake_httpx["Response"](
|
||||
200,
|
||||
{
|
||||
"handle": "abo-1-2",
|
||||
"hosted_page_links": {
|
||||
"payment_info": "https://checkout.reepay.com/pay/abo-1-2?locale=fr"
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
url = client.get_payment_info_url("abo-1-2", "https://app/ok", "https://app/ko")
|
||||
assert "locale=fr" in url
|
||||
assert "accept_url=https%3A%2F%2Fapp%2Fok" in url
|
||||
assert "cancel_url=https%3A%2F%2Fapp%2Fko" in url
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -k payment_info_url -v`
|
||||
Expected: FAIL with `AttributeError: module 'src.subscriptions.client' has no attribute 'get_payment_info_url'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/subscriptions/client.py`, ajouter l'import en haut du fichier (avec les autres imports) :
|
||||
|
||||
```python
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
```
|
||||
|
||||
Puis, juste après `get_subscription` (après la ligne `return _call("GET", f"/v1/subscription/{subscription_handle}")`) :
|
||||
|
||||
```python
|
||||
def get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str:
|
||||
sub = get_subscription(sub_handle)
|
||||
url = sub["hosted_page_links"]["payment_info"]
|
||||
parts = urlsplit(url)
|
||||
query = dict(parse_qsl(parts.query))
|
||||
query["accept_url"] = accept_url
|
||||
query["cancel_url"] = cancel_url
|
||||
return urlunsplit(parts._replace(query=urlencode(query)))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -k payment_info_url -v`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: Run the full client test file to check no regression**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -v`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git commit -m "feat(abonnement): client.get_payment_info_url pour le changement de carte Frisbii"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route `POST /subscriptions/change-payment-method`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/routes.py`
|
||||
- Test: `tests/subscriptions/test_routes.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `client.get_payment_info_url(sub_handle, accept_url, cancel_url) -> str` (Task 1) ; `db.get_current(user_id) -> sqlite3.Row | None` (déjà existant, colonne `frisbii_subscription_handle`) ; `client.FrisbiiError` (déjà existant).
|
||||
- Produces: route Flask `POST /subscriptions/change-payment-method`, utilisée par le bouton du Task 3. Redirige (303) vers l'URL Frisbii en cas de succès, 400 si pas d'abonnement, redirect 302 vers `/compte/abonnement?error=frisbii` en cas d'erreur API.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter à la fin de `tests/subscriptions/test_routes.py` :
|
||||
|
||||
```python
|
||||
def test_change_payment_method_redirects_to_hosted_url(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"get_payment_info_url",
|
||||
lambda h, accept_url, cancel_url: f"https://pay.test/{h}?a={accept_url}&c={cancel_url}",
|
||||
)
|
||||
resp = client.post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["Location"].startswith(f"https://pay.test/{handle}")
|
||||
assert "carte%3Dsucces" in resp.headers["Location"] or "carte=succes" in resp.headers["Location"]
|
||||
assert "carte%3Dannule" in resp.headers["Location"] or "carte=annule" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_change_payment_method_without_subscription(logged_in_client):
|
||||
client, _ = logged_in_client
|
||||
resp = client.post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_change_payment_method_api_error_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
|
||||
def boom(h, accept_url, cancel_url):
|
||||
raise frisbii_client.FrisbiiError(500, "boom")
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "get_payment_info_url", boom)
|
||||
resp = client.post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code == 302
|
||||
assert "error=frisbii" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_change_payment_method_requires_login(sub_app):
|
||||
resp = sub_app.test_client().post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code in (302, 401)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -k change_payment_method -v`
|
||||
Expected: FAIL — `test_change_payment_method_without_subscription` gets a 404 (route doesn't exist yet) instead of 400, others fail similarly.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/subscriptions/routes.py`, ajouter la route juste après `add_payment_callback()` (avant `cancel()`) :
|
||||
|
||||
```python
|
||||
@subscriptions_bp.route("/subscriptions/change-payment-method", methods=["POST"])
|
||||
@login_required
|
||||
def change_payment_method():
|
||||
base = os.getenv("APP_BASE_URL", "")
|
||||
row = db.get_current(current_user.id)
|
||||
if row is None or not row["frisbii_subscription_handle"]:
|
||||
return "Aucun abonnement actif", 400
|
||||
try:
|
||||
url = client.get_payment_info_url(
|
||||
row["frisbii_subscription_handle"],
|
||||
f"{base}/compte/abonnement?carte=succes",
|
||||
f"{base}/compte/abonnement?carte=annule",
|
||||
)
|
||||
except client.FrisbiiError:
|
||||
logger.exception("Échec de récupération du lien de paiement Frisbii")
|
||||
return redirect("/compte/abonnement?error=frisbii")
|
||||
return redirect(url, code=303)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -k change_payment_method -v`
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
- [ ] **Step 5: Run the full routes test file to check no regression**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git commit -m "feat(abonnement): route /subscriptions/change-payment-method"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Bouton et messages de retour sur `/compte/abonnement`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement.py`
|
||||
- Test: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien de nouveau — utilise `_csrf_input()` déjà défini dans ce fichier (`src/pages/compte/abonnement.py:18`).
|
||||
- Produces: `_active_view(row)` inclut désormais le bouton pour `status in ("trial", "active")` ; `_feedback(query)` gère `query.get("carte")`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter à la fin de `tests/subscriptions/test_compte_abonnement.py` :
|
||||
|
||||
```python
|
||||
def test_active_view_shows_change_payment_method_for_active():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
text = str(compte_abonnement._active_view(row))
|
||||
assert "Changer de méthode de paiement" in text
|
||||
assert "/subscriptions/change-payment-method" in text
|
||||
|
||||
|
||||
def test_active_view_shows_change_payment_method_for_trial():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "trial",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "Changer de méthode de paiement" in str(
|
||||
compte_abonnement._active_view(row)
|
||||
)
|
||||
|
||||
|
||||
def test_active_view_hides_change_payment_method_for_cancelled():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "cancelled",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "Changer de méthode de paiement" not in str(
|
||||
compte_abonnement._active_view(row)
|
||||
)
|
||||
|
||||
|
||||
def test_active_view_hides_change_payment_method_for_pending():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {"plan": "simple", "status": "pending", "current_period_end": None}
|
||||
text = str(compte_abonnement._active_view(row))
|
||||
assert "Changer de méthode de paiement" not in text
|
||||
assert "Ajouter une méthode de paiement" in text
|
||||
|
||||
|
||||
def test_feedback_carte_succes():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._feedback({"carte": "succes"}))
|
||||
assert "Méthode de paiement mise à jour." in text
|
||||
|
||||
|
||||
def test_feedback_carte_annule():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._feedback({"carte": "annule"}))
|
||||
assert "Modification annulée." in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: FAIL — the 6 new tests fail (button/messages absent), the 8 pre-existing tests in this file still PASS.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/pages/compte/abonnement.py`, modifier `_active_view` : remplacer le bloc
|
||||
|
||||
```python
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.Button(
|
||||
"Me désabonner",
|
||||
id="resiliation-trigger",
|
||||
n_clicks=0,
|
||||
className="btn btn-outline-danger mt-3",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(blocks)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
if row["status"] in ("trial", "active"):
|
||||
blocks.append(
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/change-payment-method",
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Button(
|
||||
"Changer de méthode de paiement",
|
||||
type="submit",
|
||||
className="btn btn-outline-secondary mt-3 me-2",
|
||||
),
|
||||
],
|
||||
style={"display": "inline-block"},
|
||||
)
|
||||
)
|
||||
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.Button(
|
||||
"Me désabonner",
|
||||
id="resiliation-trigger",
|
||||
n_clicks=0,
|
||||
className="btn btn-outline-danger mt-3",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(blocks)
|
||||
```
|
||||
|
||||
Puis modifier `_feedback` : remplacer
|
||||
|
||||
```python
|
||||
if query.get("error") == "frisbii":
|
||||
out.append(
|
||||
dbc.Alert(
|
||||
"Une erreur est survenue avec le service de paiement.", color="primary"
|
||||
)
|
||||
)
|
||||
return out
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
if query.get("carte") == "succes":
|
||||
out.append(dbc.Alert("Méthode de paiement mise à jour.", color="success"))
|
||||
if query.get("carte") == "annule":
|
||||
out.append(dbc.Alert("Modification annulée.", color="secondary"))
|
||||
if query.get("error") == "frisbii":
|
||||
out.append(
|
||||
dbc.Alert(
|
||||
"Une erreur est survenue avec le service de paiement.", color="primary"
|
||||
)
|
||||
)
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: All 14 tests PASS (8 pre-existing + 6 new)
|
||||
|
||||
- [ ] **Step 5: Run the full subscriptions test suite to check no regression**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "feat(abonnement): bouton changer de méthode de paiement sur /compte/abonnement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Vérification manuelle en local
|
||||
|
||||
**Files:** aucun (vérification uniquement)
|
||||
|
||||
- [ ] **Step 1: Lancer l'app en local**
|
||||
|
||||
Run: `uv run run.py`
|
||||
|
||||
- [ ] **Step 2: Créer un abonnement actif de test et vérifier l'affichage du bouton**
|
||||
|
||||
Se connecter, créer un abonnement (trial ou active selon état de la base de test), aller sur `/compte/abonnement`, vérifier que le bouton "Changer de méthode de paiement" est visible, et absent une fois l'abonnement résilié (statut `cancelled`).
|
||||
|
||||
- [ ] **Step 3: Vérifier le clic (sans compte Frisbii réel si `FRISBII_API_KEY` n'est pas configurée en local)**
|
||||
|
||||
Si `FRISBII_API_KEY` n'est pas renseignée, le clic déclenchera une `FrisbiiError` (requête échouée) → vérifier la redirection vers `/compte/abonnement?error=frisbii` et l'affichage du message d'erreur existant. Si une clé de test Frisbii est disponible, vérifier la redirection réelle vers la page hébergée et le retour via `?carte=succes`/`?carte=annule`.
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: All PASS
|
||||
|
||||
Pas de commit pour cette tâche (vérification uniquement).
|
||||
@@ -0,0 +1,827 @@
|
||||
# Configurer son abonnement (#109) — 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:** Permettre à un·e abonné·e `active`, `trial` ou `pending` de changer de formule (simple ↔ soutien) et de mettre à jour ses infos de facturation depuis `/compte/abonnement`, et afficher le prix (HT + TTC) de la formule courante.
|
||||
|
||||
**Architecture:** On réutilise la page `/compte/abonnement/mes-infos` en la « flexibilisant » via un `mode` dérivé de l'état de l'abonnement (`configure` vs `subscribe`), plutôt que de créer une page dupliquée (impossible de partager les `id` de composants Dash entre deux pages). Un nouveau bouton « Configurer mon abonnement » sur `/compte/abonnement` y renvoie. Le changement de formule passe par une nouvelle route `/subscriptions/update` qui appelle `update_customer` puis, si la formule change, un nouvel endpoint client `change_subscription` (`PUT /v1/subscription/{handle}`), sans redirection vers un checkout.
|
||||
|
||||
**Tech Stack:** Dash 3.4, Dash Bootstrap Components, Flask (blueprint `subscriptions`), httpx (client Frisbii/Reepay), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.subscriptions.client`), jamais `subscriptions.client`.
|
||||
- `suppress_callback_exceptions=True` est déjà activé (`src/app.py:88`) : les composants rendus conditionnellement (cases à cocher absentes en mode `configure`) ne cassent pas les callbacks — un callback dont un `Input` n'existe pas ne se déclenche simplement pas.
|
||||
- TVA France = 20 % : prix TTC = `round(prix_ht * 1.2, 2)`, formaté avec `:g` pour éviter les artefacts flottants (ex. `28.8`, pas `28.799999999999997`).
|
||||
- Effet du changement de formule : `timing="renewal"` (prochaine échéance) pour `active`/`trial` ; `timing="immediate"` pour `pending` (pas d'échéance à laquelle rattacher un renouvellement).
|
||||
- Avant `git add`/`git commit`, exécuter `pre-commit` (ruff formate ; prettier reformate le markdown). Terminer chaque message de commit par `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
- Lancer les tests avec `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici). Chaque tâche ne lance QUE son fichier de test ; la suite complète (`uv run pytest`, sans chemin) uniquement à la toute fin.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Endpoint client `change_subscription`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/client.py` (ajouter la fonction en fin de fichier)
|
||||
- Test: `tests/subscriptions/test_client.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `client.change_subscription(sub_handle: str, plan_handle: str, timing: str = "renewal") -> dict` — appelle `PUT /v1/subscription/{sub_handle}` avec le corps `{"timing": timing, "plan": plan_handle}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_client.py` :
|
||||
|
||||
```python
|
||||
def test_change_subscription_sends_timing_and_plan(fake_httpx):
|
||||
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-1"}))
|
||||
client.change_subscription("abo-1-1", "plan_soutien", timing="renewal")
|
||||
call = fake_httpx["calls"][0]
|
||||
assert call["method"] == "PUT"
|
||||
assert call["url"] == "https://api.test/v1/subscription/abo-1-1"
|
||||
assert call["json"] == {"timing": "renewal", "plan": "plan_soutien"}
|
||||
|
||||
|
||||
def test_change_subscription_immediate_timing(fake_httpx):
|
||||
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-2"}))
|
||||
client.change_subscription("abo-1-2", "plan_simple", timing="immediate")
|
||||
assert fake_httpx["calls"][0]["json"]["timing"] == "immediate"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py::test_change_subscription_sends_timing_and_plan -v`
|
||||
Expected: FAIL with `AttributeError: module 'src.subscriptions.client' has no attribute 'change_subscription'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Ajouter en fin de `src/subscriptions/client.py` :
|
||||
|
||||
```python
|
||||
def change_subscription(
|
||||
sub_handle: str, plan_handle: str, timing: str = "renewal"
|
||||
) -> dict:
|
||||
return _call(
|
||||
"PUT",
|
||||
f"/v1/subscription/{sub_handle}",
|
||||
json={"timing": timing, "plan": plan_handle},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -v`
|
||||
Expected: PASS (tous, dont les deux nouveaux)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git add src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git commit -m "feat(abonnement): client.change_subscription (PUT /v1/subscription) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route `/subscriptions/update`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/routes.py` (ajouter la route après `change_payment_method`, avant `cancel`)
|
||||
- Test: `tests/subscriptions/test_routes.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `client.change_subscription(...)` (Task 1), `client.update_customer`, `db.get_current`, `plans.resolve_handle`, `auth_db.set_siret`.
|
||||
- Produces: route `POST /subscriptions/update` — met à jour le customer Frisbii et, si la formule diffère, appelle `change_subscription` ; redirige `303` vers `/compte/abonnement?maj=succes`.
|
||||
|
||||
Comportement : valide d'abord le plan (400 si inconnu), puis vérifie l'abonnement (400 si aucun), met à jour les infos de facturation, change la formule seulement si le handle diffère. `timing="immediate"` si `status == "pending"`, sinon `"renewal"`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_routes.py` :
|
||||
|
||||
```python
|
||||
def test_update_changes_plan_and_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
captured = {}
|
||||
|
||||
def fake_change(sub_handle, plan_handle, timing="renewal"):
|
||||
captured.update(sub_handle=sub_handle, plan_handle=plan_handle, timing=timing)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "change_subscription", fake_change)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 303
|
||||
assert "maj=succes" in resp.headers["Location"]
|
||||
assert captured["sub_handle"] == handle
|
||||
assert captured["plan_handle"] == "plan_soutien"
|
||||
assert captured["timing"] == "renewal"
|
||||
|
||||
|
||||
def test_update_pending_uses_immediate_timing(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"change_subscription",
|
||||
lambda sub_handle, plan_handle, timing="renewal": captured.update(timing=timing),
|
||||
)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 303
|
||||
assert captured["timing"] == "immediate"
|
||||
|
||||
|
||||
def test_update_same_plan_skips_change_subscription(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
called = []
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"change_subscription",
|
||||
lambda *a, **k: called.append(a),
|
||||
)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code == 303
|
||||
assert "maj=succes" in resp.headers["Location"]
|
||||
assert called == [], "formule inchangée : change_subscription ne doit pas être appelé"
|
||||
|
||||
|
||||
def test_update_without_subscription_returns_400(logged_in_client):
|
||||
client, _ = logged_in_client
|
||||
resp = client.post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_update_unknown_plan_returns_400(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
resp = client.post("/subscriptions/update", data={"plan": "bidon"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_update_api_error_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
|
||||
def boom(h, d):
|
||||
raise frisbii_client.FrisbiiError(500, "boom")
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", boom)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 302
|
||||
assert "error=frisbii" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_update_requires_login(sub_app):
|
||||
resp = sub_app.test_client().post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code in (302, 401)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py::test_update_changes_plan_and_redirects -v`
|
||||
Expected: FAIL avec `404` (route inexistante)
|
||||
|
||||
- [ ] **Step 3: Write the route**
|
||||
|
||||
Dans `src/subscriptions/routes.py`, insérer après la fonction `change_payment_method` (avant `cancel`) :
|
||||
|
||||
```python
|
||||
@subscriptions_bp.route("/subscriptions/update", methods=["POST"])
|
||||
@login_required
|
||||
def update():
|
||||
new_plan_key = request.form.get("plan") or ""
|
||||
new_handle = plans.resolve_handle(new_plan_key)
|
||||
if new_handle is None:
|
||||
return "Plan inconnu", 400
|
||||
|
||||
row = db.get_current(current_user.id)
|
||||
if row is None or not row["frisbii_subscription_handle"]:
|
||||
return "Aucun abonnement à mettre à jour", 400
|
||||
|
||||
cust = _customer_handle(current_user.id)
|
||||
siret = (request.form.get("siret") or "").strip()
|
||||
billing: dict = {
|
||||
"email": current_user.email,
|
||||
"first_name": request.form.get("first_name", ""),
|
||||
"last_name": request.form.get("last_name", ""),
|
||||
"address": request.form.get("address", ""),
|
||||
"city": request.form.get("city", ""),
|
||||
"postal_code": request.form.get("postal_code", ""),
|
||||
"country": request.form.get("country", "FR"),
|
||||
}
|
||||
if request.form.get("address2"):
|
||||
billing["address2"] = request.form["address2"]
|
||||
if request.form.get("company"):
|
||||
billing["company"] = request.form["company"]
|
||||
|
||||
try:
|
||||
if siret:
|
||||
auth_db.set_siret(current_user.id, siret)
|
||||
client.update_customer(cust, billing)
|
||||
if new_handle != plans.resolve_handle(row["plan"]):
|
||||
timing = "immediate" if row["status"] == "pending" else "renewal"
|
||||
client.change_subscription(
|
||||
row["frisbii_subscription_handle"], new_handle, timing
|
||||
)
|
||||
except client.FrisbiiError:
|
||||
logger.exception("Échec de mise à jour de l'abonnement Frisbii")
|
||||
return redirect("/compte/abonnement?error=frisbii")
|
||||
return redirect("/compte/abonnement?maj=succes", code=303)
|
||||
```
|
||||
|
||||
(Aucun import à ajouter : `client`, `db`, `plans`, `auth_db`, `logger`, `current_user`, `request`, `redirect` sont déjà importés dans ce fichier.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
|
||||
Expected: PASS (tous)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git add src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git commit -m "feat(abonnement): route /subscriptions/update (maj formule + facturation) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Page `/compte/abonnement` — prix, bouton « Configurer », message de succès
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement.py` (`_active_view`, `_feedback`, + nouveau helper `_price_text`)
|
||||
- Test: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `_price_text(meta: dict) -> str | None` ; `_active_view` affiche le prix sous le libellé et un lien « Configurer mon abonnement » (statuts `pending`/`trial`/`active`) ; `_feedback` gère `maj=succes`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_compte_abonnement.py` :
|
||||
|
||||
```python
|
||||
def test_price_text_rounds_ttc():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
assert compte_abonnement._price_text({"prix_ht": 20}) == "20 € HT / mois (24 € TTC)"
|
||||
assert compte_abonnement._price_text({"prix_ht": 50}) == "50 € HT / mois (60 € TTC)"
|
||||
assert compte_abonnement._price_text({"label": "x"}) is None
|
||||
|
||||
|
||||
def test_active_view_shows_price():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "24 € TTC" in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_active_view_shows_configure_button_for_active():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
text = str(compte_abonnement._active_view(row))
|
||||
assert "Configurer mon abonnement" in text
|
||||
assert "href='/compte/abonnement/mes-infos'" in text
|
||||
|
||||
|
||||
def test_active_view_shows_configure_button_for_pending():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {"plan": "simple", "status": "pending", "current_period_end": None}
|
||||
assert "Configurer mon abonnement" in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_active_view_hides_configure_button_for_cancelled():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "cancelled",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "Configurer mon abonnement" not in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_feedback_maj_succes():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._feedback({"maj": "succes"}))
|
||||
assert "Votre abonnement a été mis à jour." in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py::test_price_text_rounds_ttc -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_price_text'`
|
||||
|
||||
- [ ] **Step 3a: Add `_price_text` helper**
|
||||
|
||||
Dans `src/pages/compte/abonnement.py`, ajouter avant `_active_view` :
|
||||
|
||||
```python
|
||||
def _price_text(meta: dict) -> str | None:
|
||||
ht = meta.get("prix_ht")
|
||||
if ht is None:
|
||||
return None
|
||||
return f"{ht} € HT / mois ({round(ht * 1.2, 2):g} € TTC)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Show price in `_active_view`**
|
||||
|
||||
Dans `_active_view`, remplacer :
|
||||
|
||||
```python
|
||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||
end = format_date_french(row["current_period_end"])
|
||||
blocks = [html.H3(meta["label"], className="mb-3")]
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||
end = format_date_french(row["current_period_end"])
|
||||
blocks = [html.H3(meta["label"], className="mb-1")]
|
||||
price = _price_text(meta)
|
||||
if price:
|
||||
blocks.append(html.P(price, className="text-muted mb-3"))
|
||||
```
|
||||
|
||||
- [ ] **Step 3c: Add « Configurer mon abonnement » button**
|
||||
|
||||
Toujours dans `_active_view`, juste avant le bloc `if row["status"] in ("trial", "active"):` (celui du formulaire « Changer de méthode de paiement »), insérer :
|
||||
|
||||
```python
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.A(
|
||||
"Configurer mon abonnement",
|
||||
href="/compte/abonnement/mes-infos",
|
||||
className="btn btn-outline-primary mt-3 me-2",
|
||||
)
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 3d: Handle `maj=succes` in `_feedback`**
|
||||
|
||||
Dans `_feedback`, ajouter avant `return out` :
|
||||
|
||||
```python
|
||||
if query.get("maj") == "succes":
|
||||
out.append(dbc.Alert("Votre abonnement a été mis à jour.", color="success"))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: PASS (tous, y compris les tests existants qui doivent rester verts)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "feat(abonnement): prix + bouton Configurer sur /compte/abonnement (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `mes-infos` — mode configure/subscribe
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py`
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
|
||||
- `_mode_for(row) -> str` — `"configure"` si `row` et `row["status"]` ∈ {active, trial, pending}, sinon `"subscribe"`.
|
||||
- `_submit_button(mode: str) -> html.Button` — `id="inf-submit"`, libellé + `disabled` selon le mode.
|
||||
- `_selectable_cards(trial_for, selected=None)` — la card dont la clé == `selected` reçoit la classe `selected`.
|
||||
- `_consent_checklists() -> list` et `_legal_note()` — extraits de l'ancien bloc `checkboxes`.
|
||||
- `layout()` rend un `dcc.Store(id="inf-sub-info")` et un `html.Div(id="inf-change-hint")` dans les deux modes (Task 5 s'en sert).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
|
||||
|
||||
```python
|
||||
def test_mode_for_derives_from_status():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
for status in ("active", "trial", "pending"):
|
||||
assert m._mode_for({"status": status}) == "configure"
|
||||
assert m._mode_for({"status": "cancelled"}) == "subscribe"
|
||||
assert m._mode_for({"status": "expired"}) == "subscribe"
|
||||
assert m._mode_for(None) == "subscribe"
|
||||
|
||||
|
||||
def test_submit_button_subscribe_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._submit_button("subscribe"))
|
||||
assert "Ajouter une carte de paiement" in text
|
||||
assert "disabled" in text
|
||||
|
||||
|
||||
def test_submit_button_configure_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
btn = m._submit_button("configure")
|
||||
text = str(btn)
|
||||
assert "Mettre à jour mon abonnement" in text
|
||||
assert btn.disabled is False
|
||||
|
||||
|
||||
def test_selectable_cards_preselects_current_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._selectable_cards(trial_for=lambda key: None, selected="soutien"))
|
||||
# la card soutien est marquée sélectionnée, pas la card simple
|
||||
assert "plan-selectable selected" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_mode_for_derives_from_status -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_mode_for'`
|
||||
|
||||
- [ ] **Step 3a: Add import + helpers**
|
||||
|
||||
En haut de `src/pages/compte/abonnement_mes_infos.py`, ajouter l'import :
|
||||
|
||||
```python
|
||||
from src.utils.frontend import format_date_french
|
||||
```
|
||||
|
||||
Ajouter ces helpers (par ex. après `_trial_for`) :
|
||||
|
||||
```python
|
||||
def _mode_for(row) -> str:
|
||||
if row is not None and row["status"] in ("active", "trial", "pending"):
|
||||
return "configure"
|
||||
return "subscribe"
|
||||
|
||||
|
||||
def _submit_button(mode: str):
|
||||
label = (
|
||||
"Mettre à jour mon abonnement"
|
||||
if mode == "configure"
|
||||
else "Ajouter une carte de paiement"
|
||||
)
|
||||
return html.Button(
|
||||
label,
|
||||
id="inf-submit",
|
||||
type="submit",
|
||||
className="btn btn-primary",
|
||||
disabled=(mode == "subscribe"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Add `selected` param to `_selectable_cards`**
|
||||
|
||||
Remplacer la signature et le corps de `_selectable_cards` :
|
||||
|
||||
```python
|
||||
def _selectable_cards(trial_for, selected=None):
|
||||
cols = []
|
||||
for key in ("simple", "soutien"):
|
||||
meta = plans.plan_meta(key)
|
||||
if not meta:
|
||||
continue
|
||||
base = "plan-selectable selected" if key == selected else "plan-selectable"
|
||||
cols.append(
|
||||
dbc.Col(
|
||||
html.Div(
|
||||
_plan_card(meta, trial_for(key)),
|
||||
id=f"plan-card-{key}",
|
||||
n_clicks=0,
|
||||
className=base,
|
||||
),
|
||||
md=6,
|
||||
)
|
||||
)
|
||||
return dbc.Row(cols, className="g-4 mb-2")
|
||||
```
|
||||
|
||||
- [ ] **Step 3c: Extract legal note + consent checklists**
|
||||
|
||||
Remplacer le bloc `checkboxes = html.Div([...])` (dans `layout`) par deux helpers au niveau module :
|
||||
|
||||
```python
|
||||
def _legal_note():
|
||||
return dcc.Markdown(
|
||||
"""\\* Champ obligatoire
|
||||
|
||||
Si vous préférez régler par virement bancaire et une facturation annuelle plutôt qu'un réglement mensuel automatique par carte bancaire, [contactez-moi](/a-propos/contact)."""
|
||||
)
|
||||
|
||||
|
||||
def _consent_checklists():
|
||||
return [
|
||||
dcc.Checklist(
|
||||
id="inf-cb-retractation",
|
||||
options=[
|
||||
{
|
||||
"label": "Je renonce à mon droit de rétractation légal de 14 jours.",
|
||||
"value": "ok",
|
||||
}
|
||||
],
|
||||
value=[],
|
||||
className="mb-2",
|
||||
),
|
||||
dcc.Checklist(
|
||||
id="inf-cb-cgu",
|
||||
options=[
|
||||
{
|
||||
"label": [
|
||||
"J'ai lu et accepte les ",
|
||||
html.A(
|
||||
"conditions générales d'utilisation du service",
|
||||
href="#",
|
||||
id="inf-cgu-link",
|
||||
style={"cursor": "pointer"},
|
||||
),
|
||||
".",
|
||||
],
|
||||
"value": "ok",
|
||||
}
|
||||
],
|
||||
value=[],
|
||||
className="mb-4",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3d: Rewire `layout()`**
|
||||
|
||||
Au début de `layout`, après le `guard`, calculer le mode et les valeurs dérivées :
|
||||
|
||||
```python
|
||||
row = sub_db.get_current(current_user.id)
|
||||
mode = _mode_for(row)
|
||||
selected = row["plan"] if mode == "configure" else None
|
||||
echeance = (
|
||||
format_date_french(row["current_period_end"])
|
||||
if mode == "configure" and row["current_period_end"]
|
||||
else None
|
||||
)
|
||||
sub_info = (
|
||||
{"current_plan": selected, "status": row["status"], "echeance": echeance}
|
||||
if mode == "configure"
|
||||
else {}
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le `html.Form(...)` existant par :
|
||||
|
||||
```python
|
||||
form = html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/subscribe"
|
||||
if mode == "subscribe"
|
||||
else "/subscriptions/update",
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Div(
|
||||
"Choisissez votre formule :"
|
||||
if mode == "subscribe"
|
||||
else "Votre formule :",
|
||||
id="inf-plan-invite",
|
||||
className="fw-bold mb-2",
|
||||
),
|
||||
_selectable_cards(_trial_for(current_user.id), selected=selected),
|
||||
html.Div(id="inf-change-hint", className="d-none"),
|
||||
dcc.Store(id="inf-sub-info", data=sub_info),
|
||||
dcc.Input(
|
||||
type="hidden", id="inf-plan-hidden", name="plan", value=selected or ""
|
||||
),
|
||||
dbc.Row([col1, col2], className="g-4 mb-4"),
|
||||
_legal_note(),
|
||||
*(_consent_checklists() if mode == "subscribe" else []),
|
||||
_submit_button(mode),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Enfin, ne rendre la modale CGU qu'en mode `subscribe`. Remplacer le `return account_shell(...)` final par :
|
||||
|
||||
```python
|
||||
return account_shell(
|
||||
"abonnement",
|
||||
html.Div(
|
||||
[
|
||||
html.H2("Mes informations de facturation", className="mb-4"),
|
||||
dbc.Alert(
|
||||
"Informations récupérées depuis le prestataire de paiement, vous pouvez les modifier si besoin.",
|
||||
color="info",
|
||||
className="mb-4",
|
||||
)
|
||||
if prefill
|
||||
else None,
|
||||
form,
|
||||
_cgu_modal() if mode == "subscribe" else None,
|
||||
]
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (nouveaux + existants `test_selectable_cards_render_both_plans`, `test_selection_state_*`, `test_submit_disabled_without_plan`)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): mode configure sur mes-infos (formule préactivée, sans cases) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `mes-infos` — hint « prochaine échéance »
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py` (`_change_hint` + callback `_select_plan`)
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `dcc.Store(id="inf-sub-info")` et `html.Div(id="inf-change-hint")` rendus en Task 4.
|
||||
- Produces: `_change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]` — `(className, children)` du hint ; le callback `_select_plan` renvoie désormais 6 sorties (les 4 existantes + className/children du hint).
|
||||
|
||||
Règles : affiché uniquement si `sub_info` a un `current_plan`, `status` ∈ {active, trial}, et `selected != current_plan`. Sinon masqué (`d-none`, texte vide). `pending` → toujours masqué.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
|
||||
|
||||
```python
|
||||
def test_change_hint_shown_when_plan_differs():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, txt = m._change_hint(
|
||||
"soutien",
|
||||
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
|
||||
)
|
||||
assert cls != "d-none"
|
||||
assert "prochaine échéance : 1er janvier" in txt
|
||||
|
||||
|
||||
def test_change_hint_hidden_when_same_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, txt = m._change_hint(
|
||||
"simple",
|
||||
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
|
||||
)
|
||||
assert cls == "d-none"
|
||||
assert txt == ""
|
||||
|
||||
|
||||
def test_change_hint_hidden_for_pending():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, _ = m._change_hint(
|
||||
"soutien",
|
||||
{"current_plan": "simple", "status": "pending", "echeance": None},
|
||||
)
|
||||
assert cls == "d-none"
|
||||
|
||||
|
||||
def test_change_hint_hidden_in_subscribe_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, _ = m._change_hint("soutien", {})
|
||||
assert cls == "d-none"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_change_hint_shown_when_plan_differs -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_change_hint'`
|
||||
|
||||
- [ ] **Step 3a: Add `_change_hint` helper**
|
||||
|
||||
Dans `src/pages/compte/abonnement_mes_infos.py`, ajouter :
|
||||
|
||||
```python
|
||||
def _change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]:
|
||||
sub_info = sub_info or {}
|
||||
current = sub_info.get("current_plan")
|
||||
if (
|
||||
not current
|
||||
or sub_info.get("status") not in ("active", "trial")
|
||||
or selected == current
|
||||
):
|
||||
return "d-none", ""
|
||||
echeance = sub_info.get("echeance")
|
||||
return (
|
||||
"text-muted mt-2",
|
||||
f"Le changement d'abonnement sera appliqué à la prochaine échéance : {echeance}.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Extend the `_select_plan` callback**
|
||||
|
||||
Remplacer le callback `_select_plan` et son décorateur par :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("inf-plan-hidden", "value"),
|
||||
Output("plan-card-simple", "className"),
|
||||
Output("plan-card-soutien", "className"),
|
||||
Output("inf-plan-invite", "className"),
|
||||
Output("inf-change-hint", "className"),
|
||||
Output("inf-change-hint", "children"),
|
||||
Input("plan-card-simple", "n_clicks"),
|
||||
Input("plan-card-soutien", "n_clicks"),
|
||||
State("inf-sub-info", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _select_plan(_n_simple, _n_soutien, sub_info):
|
||||
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
|
||||
value, cls_simple, cls_soutien, cls_invite = _selection_state(selected)
|
||||
hint_cls, hint_txt = _change_hint(selected, sub_info)
|
||||
return value, cls_simple, cls_soutien, cls_invite, hint_cls, hint_txt
|
||||
```
|
||||
|
||||
(`State` est déjà importé depuis `dash` en tête de fichier.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (tous)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): message 'changement à la prochaine échéance' sous les cards (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Vérification finale
|
||||
|
||||
- [ ] **Step 1: Run the whole subscriptions test suite**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ -v`
|
||||
Expected: PASS (aucune régression)
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: PASS (les tests Selenium nécessitent Chrome/Chromium ; si l'environnement n'en a pas, noter les échecs Selenium comme non liés et confirmer que `tests/subscriptions/` est vert).
|
||||
|
||||
- [ ] **Step 3: Sanity-check manuel (optionnel mais recommandé)**
|
||||
|
||||
Lancer l'app (`uv run run.py`), se connecter avec un compte ayant un abonnement `active`, aller sur `/compte/abonnement` : vérifier le prix affiché et le bouton « Configurer mon abonnement ». Cliquer → `/compte/abonnement/mes-infos` : formule courante présélectionnée, pas de cases à cocher, bouton « Mettre à jour mon abonnement ». Cliquer sur l'autre formule → le message « … prochaine échéance : {date} » apparaît.
|
||||
|
||||
---
|
||||
|
||||
## Notes d'implémentation
|
||||
|
||||
- **Pourquoi `inf-change-hint` et `inf-sub-info` sont rendus dans les deux modes** : le callback `_select_plan` est enregistré une seule fois et référence ces `id` en sortie/état. Les rendre toujours (vides en mode `subscribe`) évite tout souci de composant manquant et garde le callback identique quel que soit le mode.
|
||||
- **Pourquoi `_toggle_submit` n'est pas modifié** : ses `Input` (`inf-cb-retractation`, `inf-cb-cgu`) n'existent pas en mode `configure`, donc le callback ne se déclenche pas et le bouton conserve son `disabled=False` défini par `_submit_button("configure")`. En mode `subscribe`, comportement inchangé.
|
||||
- **Timing facturation** : `update_customer` prend effet immédiatement (adresse/SIRET), tandis que le changement de formule est différé à l'échéance (`renewal`) — d'où le message de succès générique « Votre abonnement a été mis à jour. ».
|
||||
@@ -0,0 +1,266 @@
|
||||
# Migration Dash 3.4 → 4.4 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:** Faire passer l'application de `dash==3.4.0` à Dash 4.4.x sans changement fonctionnel, en acceptant le nouveau style des composants DCC.
|
||||
|
||||
**Architecture:** Bump groupé des dépendances de l'écosystème Dash dans un worktree isolé basé sur `dev`, puis on laisse la suite de tests et le smoke test manuel révéler les cassures, qu'on corrige au fil de l'eau. Une seule PR vers `dev`.
|
||||
|
||||
**Tech Stack:** Dash 4.4, `uv` (gestion des dépendances), pytest + Selenium (`dash[testing]`), `dash-leaflet` + `dash-extensions` (cartes), `dash-bootstrap-components`.
|
||||
|
||||
**Spec de référence:** `docs/superpowers/specs/2026-07-09-migration-dash-4-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Base worktree :** créer le worktree depuis `dev` (PAS `origin/main`, qui est en retard sur `dev`).
|
||||
- **Version cible :** `dash` épinglé en **exact** sur la dernière 4.4.x (style d'épinglage existant `dash==3.4.0`).
|
||||
- **DataTable reste :** ne PAS toucher aux `dash_table.DataTable` ni à `src/figures.py`/`src/utils/table_sql.py` (relève de #41).
|
||||
- **Régressions visuelles :** accepter le look Dash 4 ; ne corriger que les cassures fonctionnelles ; **retirer** le CSS custom en conflit plutôt que le patcher.
|
||||
- **Bump libs tierces :** seulement si la compat Dash 4 l'exige.
|
||||
- **Tests :** toujours via `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici).
|
||||
- **Avant chaque commit :** exécuter `pre-commit` (ruff + prettier) — cf. CLAUDE.md.
|
||||
- **Imports :** modules de l'app toujours préfixés `src.` (ne pas régresser vers `utils.`/`pages.`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bump Dash et résoudre les dépendances (portail de compat)
|
||||
|
||||
C'est le portail de risque : la résolution `uv` et le démarrage de l'app révèlent immédiatement toute incompatibilité de `dash-leaflet` / `dash-extensions` / `dbc` avec Dash 4.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `pyproject.toml` (bloc `dependencies` et groupe `dev`)
|
||||
- Modify: `uv.lock` (régénéré par `uv`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: environnement résolu sur Dash 4.4.x avec l'app qui démarre. Les tâches suivantes s'appuient sur cet état.
|
||||
|
||||
- [ ] **Step 1: Déterminer la dernière version 4.4.x**
|
||||
|
||||
Run: `uv run python -c "import urllib.request, json; d=json.load(urllib.request.urlopen('https://pypi.org/pypi/dash/json')); print([v for v in d['releases'] if v.startswith('4.4.')][-1])"`
|
||||
Expected: une version type `4.4.x`. Noter cette valeur (ci-après `<DASH_VERSION>`).
|
||||
|
||||
- [ ] **Step 2: Consolider la déclaration `dash` dans `pyproject.toml`**
|
||||
|
||||
Dans le bloc `dependencies`, remplacer les DEUX lignes actuelles :
|
||||
|
||||
```toml
|
||||
"dash==3.4.0",
|
||||
"dash[compress]",
|
||||
```
|
||||
|
||||
par une seule ligne épinglée :
|
||||
|
||||
```toml
|
||||
"dash[compress]==<DASH_VERSION>",
|
||||
```
|
||||
|
||||
Dans le groupe `[dependency-groups].dev`, remplacer :
|
||||
|
||||
```toml
|
||||
"dash[testing]",
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```toml
|
||||
"dash[testing]==<DASH_VERSION>",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Résoudre l'environnement**
|
||||
|
||||
Run: `uv sync`
|
||||
Expected: résolution réussie. **Si** échec de résolution mentionnant `dash-leaflet`, `dash-extensions` ou `dash-bootstrap-components` (peer-deps React/Dash) : bumper la lib en cause vers sa dernière version (`uv add 'dash-leaflet@latest'` et/ou `uv add 'dash-extensions@latest'`) puis relancer `uv sync`.
|
||||
|
||||
- [ ] **Step 4: Vérifier l'import et la version**
|
||||
|
||||
Run: `uv run python -c "import dash, dash_leaflet, dash_extensions, dash_bootstrap_components as dbc; print(dash.__version__, dash_leaflet.__version__, dash_extensions.__version__, dbc.__version__)"`
|
||||
Expected: `dash.__version__` commence par `4.4`, aucun ImportError.
|
||||
|
||||
- [ ] **Step 5: Vérifier le démarrage de l'app**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` puis attendre le log de démarrage et arrêter (Ctrl-C).
|
||||
Expected: l'app démarre sans traceback (le serveur écoute). Un warning de dépréciation sur `DataTable` est attendu et normal.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files pyproject.toml || true
|
||||
git add pyproject.toml uv.lock
|
||||
git commit -m "build: bump dash 3.4 -> 4.4 et résolution des dépendances (#101)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rendre la suite de tests verte sur Dash 4
|
||||
|
||||
Le restyling DCC de Dash 4 peut casser des sélecteurs Selenium (classes CSS changées) ou des assertions. On fait passer toute la suite.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: fichiers de `tests/` dont les assertions/sélecteurs cassent (inconnus a priori)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: environnement Dash 4.4 de la Task 1.
|
||||
- Produces: `uv run pytest` intégralement vert.
|
||||
|
||||
- [ ] **Step 1: Lancer la suite complète pour cartographier les cassures**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: soit tout vert (idéal → aller directement au Step 4), soit des échecs. Noter chaque test en échec et sa cause (sélecteur introuvable, timeout, assertion).
|
||||
|
||||
- [ ] **Step 2: Corriger chaque test en échec**
|
||||
|
||||
Pour chaque échec, appliquer la correction minimale :
|
||||
|
||||
- **Sélecteur Selenium cassé** (une classe/structure DCC a changé en Dash 4) : mettre à jour le sélecteur pour cibler la nouvelle structure rendue. Inspecter le DOM réel via `DEVELOPMENT=true uv run run.py` si besoin.
|
||||
- **Assertion sur du texte/markup restylé** : ajuster l'attendu à la sortie Dash 4.
|
||||
- **Ne PAS** contourner un échec qui révèle une vraie régression fonctionnelle : le noter pour la Task 3/4.
|
||||
|
||||
- [ ] **Step 3: Relancer les tests corrigés**
|
||||
|
||||
Run: `uv run pytest <chemins des tests précédemment en échec>`
|
||||
Expected: PASS pour les tests corrigés.
|
||||
|
||||
- [ ] **Step 4: Confirmer la suite complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: intégralement vert.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files <fichiers de test modifiés> || true
|
||||
git add tests/
|
||||
git commit -m "test: adapter la suite au rendu Dash 4 (#101)"
|
||||
```
|
||||
|
||||
Si aucun test n'a nécessité de modification, ne pas créer de commit vide — passer directement à la Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Vérifier le comportement des Dropdown et nettoyer le CSS en conflit
|
||||
|
||||
Dash 4 change deux défauts de `dcc.Dropdown` (`optionHeight='auto'`, `closeOnSelect=False` en multi-select) et restyle les DCC. On vérifie les 11 Dropdown et on retire les overrides CSS devenus inutiles/cassants.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify (si nécessaire) : `src/pages/recherche.py`, `src/pages/tableau.py`, et autres pages contenant des `dcc.Dropdown`
|
||||
- Modify: `src/assets/css/style.css` (et autres fichiers de `src/assets/css/`) — retrait des overrides en conflit
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: environnement Dash 4.4, suite verte (Tasks 1-2).
|
||||
- Produces: Dropdown fonctionnels, CSS sans override cassant.
|
||||
|
||||
- [ ] **Step 1: Lister les Dropdown et repérer ceux en multi-select**
|
||||
|
||||
Run: `grep -rn "dcc.Dropdown" src/ | grep -v __pycache__`
|
||||
Puis pour le contexte multi : `grep -rn "multi=True" src/ | grep -v __pycache__`
|
||||
Expected: la liste des 11 emplacements ; identifier ceux en `multi=True` (impactés par le changement `closeOnSelect`).
|
||||
|
||||
- [ ] **Step 2: Vérifier visuellement les Dropdown impactés**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` et ouvrir les pages concernées (notamment `/` recherche et `/tableau` filtres/sélecteur de colonnes).
|
||||
Expected: les Dropdown s'ouvrent, filtrent, et se ferment correctement. Un multi-select qui reste ouvert après sélection est le nouveau défaut Dash 4 — **acceptable** (ne pas corriger sauf si réellement cassé). Ne corriger (ex. forcer `optionHeight` numérique) que si l'affichage est rompu.
|
||||
|
||||
- [ ] **Step 3: Repérer les overrides CSS en conflit avec le restyling DCC**
|
||||
|
||||
Run: `grep -rn "Select\|dropdown\|Checklist\|RadioItems\|dash-loading\|input" src/assets/css/`
|
||||
Expected: liste des règles ciblant les DCC. Pour chacune, juger via le rendu (Step 2) si elle entre en conflit avec le nouveau style Dash 4.
|
||||
|
||||
- [ ] **Step 4: Retirer les overrides devenus inutiles ou cassants**
|
||||
|
||||
Supprimer (pas commenter) les règles CSS qui cassent ou déforment un composant DCC restylé. Conserver celles qui restent utiles et sans conflit. Ne PAS toucher au bloc CSS lié à `dash_table`/DataTable (hors périmètre, cf. `style.css:512`).
|
||||
|
||||
- [ ] **Step 5: Re-vérifier le rendu après nettoyage**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` et re-parcourir les pages modifiées.
|
||||
Expected: aucun composant cassé ; le rendu adopte le style Dash 4.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files <fichiers modifiés> || true
|
||||
git add src/
|
||||
git commit -m "style: accepter le rendu DCC Dash 4 et retirer le CSS en conflit (#101)"
|
||||
```
|
||||
|
||||
Si aucune modification n'a été nécessaire, ne pas créer de commit vide.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Smoke test complet et ouverture de la PR
|
||||
|
||||
Vérification finale (definition of done) : suite verte + parcours manuel des 6 pages + cartes Leaflet + exports.
|
||||
|
||||
**Files:** aucun a priori (corrections de dernière minute possibles selon findings).
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: état des Tasks 1-3.
|
||||
- Produces: PR vers `dev`.
|
||||
|
||||
- [ ] **Step 1: Suite de tests finale**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: intégralement vert.
|
||||
|
||||
- [ ] **Step 2: Smoke test manuel des 6 pages**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` et parcourir : `/` (recherche), `/acheteur`, `/titulaire`, `/tableau`, `/marche`, `/observatoire`.
|
||||
Expected: chaque page se charge sans erreur console/serveur ; les tableaux DataTable s'affichent et filtrent ; les graphiques Plotly s'affichent.
|
||||
|
||||
- [ ] **Step 3: Vérifier spécifiquement les cartes Leaflet et le clustering**
|
||||
|
||||
Sur `/acheteur` et `/titulaire` (pages avec cartes), vérifier que la carte Leaflet s'affiche et que le clustering de marqueurs fonctionne (zoom/dézoom regroupe/éclate les marqueurs).
|
||||
Expected: cartes rendues et interactives. **Si cassé** : bumper `dash-leaflet`/`dash-extensions` (cf. Task 1 Step 3), re-`uv sync`, re-tester, et amender le commit de la Task 1.
|
||||
|
||||
- [ ] **Step 4: Vérifier les exports**
|
||||
|
||||
Depuis `/tableau`, déclencher un export xlsx et un export csv.
|
||||
Expected: fichiers téléchargés et ouvrables, contenu cohérent.
|
||||
|
||||
- [ ] **Step 5: Corriger les cassures résiduelles éventuelles**
|
||||
|
||||
Pour toute cassure trouvée aux Steps 2-4, appliquer la correction minimale et commiter séparément :
|
||||
|
||||
```bash
|
||||
pre-commit run --files <fichiers> || true
|
||||
git add <fichiers>
|
||||
git commit -m "fix: <cassure corrigée> sur Dash 4 (#101)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Ouvrir la PR vers `dev`**
|
||||
|
||||
```bash
|
||||
git push -u origin <branche-worktree>
|
||||
gh pr create --base dev --title "Migration Dash 3.4 → 4.4 (#101)" --body "$(cat <<'EOF'
|
||||
Migration de `dash==3.4.0` vers Dash 4.4.x (pure montée de version, cf. spec `docs/superpowers/specs/2026-07-09-migration-dash-4-design.md`).
|
||||
|
||||
## Périmètre
|
||||
- Bump `dash` + libs écosystème si nécessaire
|
||||
- Acceptation du look DCC Dash 4, retrait du CSS en conflit
|
||||
- DataTable inchangées (→ #41), MCP hors périmètre (→ #111)
|
||||
|
||||
## Vérification
|
||||
- `uv run pytest` vert
|
||||
- Smoke manuel : 6 pages + cartes Leaflet + exports xlsx/csv
|
||||
|
||||
Closes #101
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: PR créée vers `dev`. Le merge déclenchera l'auto-deploy vers test.colibre.fr.
|
||||
|
||||
---
|
||||
|
||||
## Notes d'exécution
|
||||
|
||||
- La migration est **réactive** : les Tasks 2-4 corrigent ce que le bump casse. Si le bump ne casse rien (plausible vu que les ruptures Dash 3.0 sont déjà absorbées), plusieurs tasks se réduisent à leur étape de vérification, sans commit.
|
||||
- Le point de bascule risqué est concentré dans la Task 1 (résolution/boot) et la Task 4 Step 3 (cartes). En cas de blocage dur d'une lib tierce sans release Dash 4 : arrêter et réévaluer avec le mainteneur du plan.
|
||||
@@ -0,0 +1,341 @@
|
||||
# Comptes utilisateurs — Design
|
||||
|
||||
**Date** : 2026-04-20
|
||||
**Issue** : #73
|
||||
**Branche** : `feature/73_compte_utilisateur`
|
||||
**Spec initiale** : `comptes_utilisateurs.md`
|
||||
|
||||
## Objectif
|
||||
|
||||
Poser les fondations d'un système de comptes utilisateurs pour decp.info : inscription avec vérification d'email, connexion, réinitialisation de mot de passe, page compte permettant de changer son mot de passe. Ces fondations doivent permettre d'ajouter plus tard d'autres fonctionnalités (alertes email, préférences, etc.) sans avoir à retoucher l'authentification.
|
||||
|
||||
## Décisions de conception
|
||||
|
||||
| Sujet | Choix |
|
||||
| ---------------------------------- | --------------------------------------------------------------- |
|
||||
| Sessions | Flask-Login |
|
||||
| Reset password | Token stocké en base (usage unique) |
|
||||
| Vérification email à l'inscription | Obligatoire avant connexion |
|
||||
| Règles mot de passe | Longueur ≥ 8 caractères, rien d'autre |
|
||||
| Hashage mot de passe | `werkzeug.security` (scrypt par défaut) |
|
||||
| Lien navbar | Un seul lien « Connexion » (dropdown avec email quand connecté) |
|
||||
| Pages | 5 pages Dash séparées avec URLs explicites |
|
||||
| Base de données utilisateurs | SQLite, chemin configurable via `USERS_DB_PATH` |
|
||||
| Migrations | `CREATE TABLE IF NOT EXISTS` au démarrage |
|
||||
| Accès SQLite | `sqlite3` stdlib, SQL brut avec requêtes paramétrées |
|
||||
| Envoi emails | Flask-Mail |
|
||||
| Rate limiting | Aucun pour l'instant |
|
||||
| Tests | Unitaires + intégration Flask (pas de Selenium) |
|
||||
| Format emails | HTML + texte brut (multipart) |
|
||||
| CSRF | Flask-WTF (CSRFProtect global) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Nouvelles dépendances (`pyproject.toml`)
|
||||
|
||||
- `flask-login` — gestion des sessions utilisateur
|
||||
- `flask-mail` — envoi SMTP
|
||||
- `flask-wtf` — protection CSRF (uniquement CSRFProtect, pas d'utilisation des formulaires WTForms)
|
||||
- `email-validator` — validation du format email à l'inscription
|
||||
|
||||
`itsdangerous` est déjà une dépendance transitive de Flask.
|
||||
|
||||
### Nouveau package `src/auth/`
|
||||
|
||||
| Fichier | Rôle |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `src/auth/__init__.py` | Exports publics (`current_user`, `login_required`, `init_auth`) |
|
||||
| `src/auth/db.py` | Connexion SQLite, création schéma, requêtes CRUD |
|
||||
| `src/auth/models.py` | Classe `User` compatible Flask-Login |
|
||||
| `src/auth/mailer.py` | Init Flask-Mail, helpers `send_verification_email`, `send_reset_email` |
|
||||
| `src/auth/tokens.py` | Génération/validation tokens (vérif email, reset password) |
|
||||
| `src/auth/routes.py` | Routes Flask POST (login, signup, logout, request reset, perform reset, verify email, change password) |
|
||||
| `src/auth/setup.py` | `init_auth(app)` appelé depuis `src/app.py` |
|
||||
|
||||
### Pourquoi des routes Flask natives plutôt que des callbacks Dash
|
||||
|
||||
Les actions d'authentification nécessitent de définir/effacer des cookies de session, rediriger entre pages, et manipuler `flask.session` — c'est le territoire de Flask, pas de Dash. Les callbacks Dash renvoient des composants React, pas des redirections HTTP avec cookies. Les pages Dash s'occupent du **rendu** (formulaires HTML), les routes Flask s'occupent des **actions** (POST).
|
||||
|
||||
### Nouvelles pages Dash (`src/pages/`)
|
||||
|
||||
| Fichier | URL | Description |
|
||||
| ------------------------------- | ----------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `connexion.py` | `/connexion` | Formulaire email + mot de passe, lien vers inscription, lien mot de passe oublié |
|
||||
| `inscription.py` | `/inscription` | Formulaire email + mot de passe + confirmation |
|
||||
| `compte.py` | `/compte` | Protégée. Affiche l'email, formulaire changement mot de passe, bouton déconnexion |
|
||||
| `mot_de_passe_oublie.py` | `/mot-de-passe-oublie` | Formulaire de demande (email seul) |
|
||||
| `reinitialiser_mot_de_passe.py` | `/reinitialiser-mot-de-passe` | Formulaire nouveau mot de passe (token en query string) |
|
||||
| `verification_email.py` | `/verification-email` | Page de statut après clic sur lien de vérification |
|
||||
|
||||
### Modifications de fichiers existants
|
||||
|
||||
- **`src/app.py`** : ajout de `init_auth(app)` après init du cache. Modification de la navbar pour afficher le lien « Connexion » (déconnecté) ou un `dbc.DropdownMenu` (connecté).
|
||||
- **`pyproject.toml`** : ajout des dépendances listées ci-dessus et des variables d'env de tests dans `[tool.pytest.ini_options].env`.
|
||||
- **`.template.env`** : ajout des nouvelles variables d'environnement.
|
||||
|
||||
### Nouvelles variables d'environnement
|
||||
|
||||
| Variable | Exemple | Description |
|
||||
| --------------- | ------------------- | ----------------------------------------------------------------------- |
|
||||
| `USERS_DB_PATH` | `users.sqlite` | Chemin du fichier SQLite utilisateurs |
|
||||
| `SECRET_KEY` | (32 octets random) | Clé Flask pour sessions + signatures. Fail fast au démarrage si absente |
|
||||
| `SMTP_HOST` | `smtp.example.com` | Serveur SMTP |
|
||||
| `SMTP_PORT` | `587` | Port SMTP |
|
||||
| `SMTP_USERNAME` | `…` | Identifiant SMTP |
|
||||
| `SMTP_PASSWORD` | `…` | Mot de passe SMTP |
|
||||
| `SMTP_USE_TLS` | `True` | STARTTLS |
|
||||
| `MAIL_FROM` | `noreply@decp.info` | Expéditeur |
|
||||
| `APP_BASE_URL` | `https://decp.info` | Base URL pour construire les liens absolus dans les emails |
|
||||
|
||||
Si `SECRET_KEY` est absente au démarrage → `raise RuntimeError` (fail fast). Si les variables SMTP sont absentes → warning au démarrage, l'app démarre mais toute route déclenchant un envoi d'email retourne une erreur lisible.
|
||||
|
||||
## Schéma SQLite
|
||||
|
||||
Trois tables créées au démarrage via `CREATE TABLE IF NOT EXISTS`. Connexion initialisée avec :
|
||||
|
||||
```python
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
```
|
||||
|
||||
### Table `users`
|
||||
|
||||
| Colonne | Type | Contraintes |
|
||||
| ---------------- | ------- | ------------------------------------- |
|
||||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||
| `email` | TEXT | NOT NULL UNIQUE (stocké en lowercase) |
|
||||
| `password_hash` | TEXT | NOT NULL |
|
||||
| `email_verified` | INTEGER | NOT NULL DEFAULT 0 (0 ou 1) |
|
||||
| `created_at` | TEXT | NOT NULL (ISO 8601 UTC) |
|
||||
| `updated_at` | TEXT | NOT NULL (ISO 8601 UTC) |
|
||||
|
||||
Index : `CREATE UNIQUE INDEX idx_users_email ON users(email)`.
|
||||
|
||||
### Table `email_verification_tokens`
|
||||
|
||||
| Colonne | Type | Contraintes |
|
||||
| ------------ | ------- | --------------------------------------------------- |
|
||||
| `token_hash` | TEXT | PRIMARY KEY (SHA-256 hex du token) |
|
||||
| `user_id` | INTEGER | NOT NULL, FOREIGN KEY → users(id) ON DELETE CASCADE |
|
||||
| `expires_at` | TEXT | NOT NULL (ISO 8601 UTC, typiquement +24h) |
|
||||
| `created_at` | TEXT | NOT NULL |
|
||||
|
||||
### Table `password_reset_tokens`
|
||||
|
||||
| Colonne | Type | Contraintes |
|
||||
| ------------ | ------- | --------------------------------------------------- |
|
||||
| `token_hash` | TEXT | PRIMARY KEY (SHA-256 hex du token) |
|
||||
| `user_id` | INTEGER | NOT NULL, FOREIGN KEY → users(id) ON DELETE CASCADE |
|
||||
| `expires_at` | TEXT | NOT NULL (ISO 8601 UTC, typiquement +1h) |
|
||||
| `created_at` | TEXT | NOT NULL |
|
||||
|
||||
### Principes sur les tokens
|
||||
|
||||
- **Stockage hashé** : on stocke `sha256(token)`, jamais le token en clair. Si la DB fuite, les tokens actifs restent inutilisables.
|
||||
- **Génération** : `secrets.token_urlsafe(32)` → ~43 caractères URL-safe.
|
||||
- **Usage unique** : à la validation réussie d'un token, on fait `DELETE FROM <table> WHERE user_id = ?` pour invalider tous les tokens actifs de l'utilisateur (pas uniquement celui utilisé).
|
||||
- **Nouvelle demande** : avant de créer un nouveau token de reset, on supprime les anciens du même utilisateur.
|
||||
- **Nettoyage périodique** : au démarrage de l'app, `DELETE FROM <table> WHERE expires_at < now()` sur les deux tables. Pas de cron nécessaire.
|
||||
|
||||
## Flux utilisateurs
|
||||
|
||||
### Flux A — Inscription
|
||||
|
||||
1. GET `/inscription` → formulaire Dash (email, mot de passe, confirmation).
|
||||
2. Soumission POST `/auth/signup` avec token CSRF.
|
||||
3. Serveur valide :
|
||||
- Format email (`email-validator`).
|
||||
- Longueur mot de passe ≥ 8.
|
||||
- `password == password_confirm`.
|
||||
- Email non déjà pris (lookup lowercase).
|
||||
4. Création : `INSERT INTO users` avec `email_verified=0`, hash via `werkzeug.security.generate_password_hash`.
|
||||
5. Génération token 32 octets, `INSERT INTO email_verification_tokens` (hash, expiration +24h).
|
||||
6. Envoi email HTML+texte contenant `{APP_BASE_URL}/verification-email?token=…`.
|
||||
7. **Si envoi KO** : rollback (`DELETE` user + token), erreur « Erreur technique, réessayez plus tard ».
|
||||
8. **Si envoi OK** : redirection `/connexion?pending_verification=1` avec message « Compte créé, vérifie ton email ».
|
||||
|
||||
### Flux B — Vérification d'email
|
||||
|
||||
1. L'utilisateur clique sur le lien dans l'email → GET `/verification-email?token=…`.
|
||||
2. La page Dash déclenche côté serveur la vérification via GET `/auth/verify-email?token=…`.
|
||||
3. Serveur :
|
||||
- Hash le token reçu, cherche dans `email_verification_tokens` non expiré.
|
||||
- Si trouvé : `UPDATE users SET email_verified=1, updated_at=now()`, `DELETE FROM email_verification_tokens WHERE user_id = ?`, redirect `/connexion?verified=1`.
|
||||
- Sinon : redirect vers page d'erreur avec bouton « Renvoyer l'email de vérification ».
|
||||
|
||||
### Flux C — Connexion
|
||||
|
||||
1. GET `/connexion` → formulaire Dash.
|
||||
2. POST `/auth/login`.
|
||||
3. Serveur :
|
||||
- Lookup user par email lowercase.
|
||||
- **Toujours** appeler `check_password_hash()` (même si user inexistant, avec un hash bidon pré-calculé) pour uniformiser le temps de réponse.
|
||||
- Si user existe **et** password OK **et** `email_verified=1` → `login_user(user)` → redirect vers `next` validé ou `/compte`.
|
||||
- Si user existe mais `email_verified=0` → message « Vérifie d'abord ton adresse email » + bouton « Renvoyer email ».
|
||||
- Sinon → message générique « Identifiants invalides ».
|
||||
|
||||
### Flux D — Mot de passe oublié
|
||||
|
||||
1. GET `/mot-de-passe-oublie` → formulaire (email seul).
|
||||
2. POST `/auth/request-password-reset`.
|
||||
3. Serveur :
|
||||
- Message de retour **toujours identique** : « Si un compte existe avec cet email, un lien de réinitialisation a été envoyé. »
|
||||
- Si user trouvé : `DELETE` des anciens tokens reset, création d'un nouveau (expiration +1h), envoi email avec `{APP_BASE_URL}/reinitialiser-mot-de-passe?token=…`.
|
||||
- Si envoi SMTP KO : log serveur + message d'erreur technique (dérogation à la règle de non-énumération, pour éviter de masquer une panne SMTP).
|
||||
|
||||
### Flux E — Réinitialisation du mot de passe
|
||||
|
||||
1. GET `/reinitialiser-mot-de-passe?token=…`.
|
||||
2. Page Dash : vérifie le token (sans le consommer) via un callback au rendu. Si invalide → message « Lien invalide ou expiré ». Si valide → affiche formulaire (nouveau mot de passe + confirmation).
|
||||
3. POST `/auth/reset-password` avec token en champ caché + CSRF.
|
||||
4. Serveur :
|
||||
- Revalide le token (hash, expiration).
|
||||
- Valide le mot de passe (longueur, confirmation).
|
||||
- `UPDATE users SET password_hash=…, updated_at=now()`.
|
||||
- `DELETE FROM password_reset_tokens WHERE user_id = ?`.
|
||||
- Redirect `/connexion?password_changed=1`.
|
||||
|
||||
### Flux F — Page compte
|
||||
|
||||
1. GET `/compte` protégée par `@login_required`. Déconnecté → redirect `/connexion?next=/compte`.
|
||||
2. Page affiche l'email + formulaire « changer mot de passe » (mot de passe actuel + nouveau + confirmation) + bouton « Déconnexion ».
|
||||
3. POST `/auth/change-password` : vérifie mot de passe actuel, valide le nouveau, update.
|
||||
4. POST `/auth/logout` : `logout_user()`, redirect `/`.
|
||||
|
||||
### Navbar (`src/app.py`)
|
||||
|
||||
- **Déconnecté** : lien « Connexion » pointant vers `/connexion`, placé à droite (après « À propos »).
|
||||
- **Connecté** : `dbc.DropdownMenu` affichant l'email tronqué (30 caractères max) avec :
|
||||
- Item « Mon compte » → `/compte`
|
||||
- Item « Déconnexion » → soumission POST vers `/auth/logout` (form avec CSRF)
|
||||
|
||||
## Gestion des erreurs et sécurité
|
||||
|
||||
### Messages d'erreur utilisateur
|
||||
|
||||
- Transit des messages via **query string** (ex : `?error=invalid_credentials`, `?verified=1`) avec mapping code → message côté page Dash. Plus simple à tester et stateless que `flash()`.
|
||||
- Rendu via `dbc.Alert` en haut du formulaire (couleurs danger/success/info).
|
||||
|
||||
### Sécurité des cookies et sessions
|
||||
|
||||
- `SESSION_COOKIE_HTTPONLY = True`
|
||||
- `SESSION_COOKIE_SAMESITE = 'Lax'`
|
||||
- `SESSION_COOKIE_SECURE = True` en production (conditionnel sur `DEVELOPMENT=False`)
|
||||
- Durée de session : 30 jours (« remember me » implicite, permanent session)
|
||||
|
||||
### Protection CSRF
|
||||
|
||||
- `CSRFProtect(app.server)` installé globalement depuis `init_auth`.
|
||||
- Chaque formulaire Dash inclut un champ caché `csrf_token` rempli via un callback au rendu (`generate_csrf()` depuis `flask_wtf.csrf`).
|
||||
- Toutes les routes POST sont protégées automatiquement.
|
||||
|
||||
### Protection contre l'énumération de comptes
|
||||
|
||||
- Login : même message pour email inexistant et mot de passe faux.
|
||||
- Request password reset : même message pour email existant et inexistant (sauf en cas de panne SMTP, où l'erreur technique prime).
|
||||
- Timing : toujours appeler `check_password_hash` même si user inexistant.
|
||||
|
||||
### Validation des redirections `next`
|
||||
|
||||
```python
|
||||
def safe_next(url: str, fallback: str = "/") -> str:
|
||||
if not url or not url.startswith("/") or url.startswith("//"):
|
||||
return fallback
|
||||
return url
|
||||
```
|
||||
|
||||
### Tokens
|
||||
|
||||
- Entropie : 32 octets via `secrets.token_urlsafe(32)`.
|
||||
- Stockage : SHA-256 hex.
|
||||
- Usage unique (voir schéma SQLite).
|
||||
- Expiration : 24h (vérif email), 1h (reset password).
|
||||
|
||||
### Mots de passe dans les logs
|
||||
|
||||
Audit rapide du code au moment de la revue : aucun `print`/`logger.debug` ne doit logger mot de passe ou token en clair. Formulaires d'inscription/login/reset loggent uniquement l'email (et encore, seulement en cas d'erreur).
|
||||
|
||||
### Pannes SMTP
|
||||
|
||||
- **Inscription** : envoi synchrone, échec → rollback de l'inscription, erreur technique affichée.
|
||||
- **Reset password** : envoi synchrone, échec → erreur technique (dérogation au message générique).
|
||||
- **Changement de mot de passe** : pas d'email → pas de dépendance SMTP pour ce flux.
|
||||
- **Vérification email (renvoyer)** : échec → erreur technique.
|
||||
|
||||
### Configuration manquante au démarrage
|
||||
|
||||
- `SECRET_KEY` absente → `raise RuntimeError` (fail fast).
|
||||
- Variables SMTP absentes → warning loggué, app démarre ; routes nécessitant SMTP retournent erreur lisible.
|
||||
- `USERS_DB_PATH` absente → fallback sur `users.sqlite` à la racine.
|
||||
- `APP_BASE_URL` absente → fallback sur `http://localhost:8050`.
|
||||
|
||||
## Emails (HTML + texte)
|
||||
|
||||
Templates simples dans `src/auth/templates/emails/` :
|
||||
|
||||
- `verify_email.html` et `verify_email.txt`
|
||||
- `reset_password.html` et `reset_password.txt`
|
||||
|
||||
Chaque email contient :
|
||||
|
||||
- Salutation neutre (« Bonjour, »)
|
||||
- Une phrase expliquant le contexte
|
||||
- Le lien en clair (cliquable en HTML)
|
||||
- Une phrase sur l'expiration (« Ce lien est valide 24h / 1h »)
|
||||
- Un mot final (« Si vous n'êtes pas à l'origine de cette demande, ignorez cet email »)
|
||||
- Pas de logo complexe ; un en-tête texte `decp.info` en gras suffit
|
||||
|
||||
## Tests
|
||||
|
||||
### Structure `tests/auth/`
|
||||
|
||||
- `conftest.py` — fixtures : base SQLite temporaire, capture emails (monkeypatch `mail.send`), `client` (Flask test client), `authed_client`, `SECRET_KEY` fixe.
|
||||
- `test_db.py` — CRUD users, unicité email case-insensitive, cascade suppression tokens.
|
||||
- `test_tokens.py` — génération, hash stable, validation, rejet expiré/invalide, invalidation à l'usage.
|
||||
- `test_signup.py` — GET formulaire, POST validé, doublon rejeté, password trop court rejeté, email invalide rejeté, login avant vérif refusé.
|
||||
- `test_verify_email.py` — token valide marque vérifié et supprime token, token expiré rejeté, token déjà utilisé rejeté.
|
||||
- `test_login.py` — succès (cookie posé), mot de passe faux rejeté (message générique), email inexistant rejeté avec même message, user non vérifié rejeté, redirection `next` validée (rejet URLs absolues).
|
||||
- `test_password_reset.py` — demande envoie email, email inexistant → même message sans envoi, token valide permet changement, token expiré rejeté, login avec nouveau mot de passe OK, ancien KO.
|
||||
- `test_account.py` — `/compte` redirige si déconnecté, change password OK avec mot de passe actuel correct, KO sinon, logout efface session.
|
||||
- `test_csrf.py` — POST sans token rejeté (403), POST avec token valide accepté.
|
||||
|
||||
### Fixture clé
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def mail_outbox(monkeypatch):
|
||||
outbox = []
|
||||
monkeypatch.setattr(
|
||||
"src.auth.mailer.mail.send",
|
||||
lambda msg: outbox.append(msg),
|
||||
)
|
||||
return outbox
|
||||
```
|
||||
|
||||
### Configuration pytest (`pyproject.toml`)
|
||||
|
||||
Ajout au bloc `[tool.pytest.ini_options].env` :
|
||||
|
||||
```
|
||||
USERS_DB_PATH=tests/users.test.sqlite
|
||||
SECRET_KEY=test-secret-do-not-use-in-prod
|
||||
MAIL_FROM=test@decp.info
|
||||
APP_BASE_URL=http://localhost:8050
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=25
|
||||
```
|
||||
|
||||
### Non couvert (explicitement)
|
||||
|
||||
- Tests Selenium sur les formulaires (ajoutables plus tard si parcours critiques).
|
||||
- Tests de charge SMTP.
|
||||
- Tests cross-browser.
|
||||
- Vérification que les tests existants (`tests/test_main.py`) ne cassent pas : l'auth est ajoutée sans modifier les comportements existants (navbar garde un lien « Connexion » par défaut, pas de redirection ajoutée sur les pages publiques).
|
||||
|
||||
## Risques et contraintes
|
||||
|
||||
- **SMTP indisponible en dev** : tout développeur doit pouvoir tester sans SMTP réel. Prévoir un mode dev où les emails sont loggés dans la console au lieu d'être envoyés (ex : `MAIL_SUPPRESS_SEND=True` de Flask-Mail quand `DEVELOPMENT=True`, avec impression du lien dans le log).
|
||||
- **Clé de signature (SECRET_KEY)** : la rotation invalidera toutes les sessions actives. À documenter dans le README de deploy.
|
||||
- **Base SQLite + WAL** : la base est accessible en lecture/écriture par le process de l'app. En cas de plusieurs workers gunicorn, WAL gère correctement la concurrence pour un nombre modéste d'écritures (inscriptions, resets, login updates) — acceptable pour le volume attendu.
|
||||
- **Concurrence sur `users` depuis plusieurs workers gunicorn** : une seule connexion SQLite par process avec WAL est OK ; pas de connexion partagée entre workers.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Design : amélioration du style des exports Excel (#83)
|
||||
|
||||
**Date :** 2026-06-23
|
||||
**Issue :** #83
|
||||
|
||||
## Contexte
|
||||
|
||||
Les 6 fonctions d'export Excel du projet produisent des fichiers basiques : toutes les colonnes ont la même largeur par défaut et les en-têtes ne sont pas mis en valeur. L'objectif est d'améliorer la lisibilité en imitant les largeurs de colonnes du `DataTable` commun et en stylisant les en-têtes.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Toutes les fonctions d'export Excel :
|
||||
|
||||
| Fichier | Fonction | Page |
|
||||
| --------------------------- | ---------------------------------- | --------------- |
|
||||
| `src/pages/tableau.py` | `download_data` | `/tableau` |
|
||||
| `src/pages/acheteur.py` | `download_acheteur_data` | `/acheteur` |
|
||||
| `src/pages/acheteur.py` | `download_filtered_acheteur_data` | `/acheteur` |
|
||||
| `src/pages/titulaire.py` | `download_titulaire_data` | `/titulaire` |
|
||||
| `src/pages/titulaire.py` | `download_filtered_titulaire_data` | `/titulaire` |
|
||||
| `src/pages/observatoire.py` | `download_observatoire` | `/observatoire` |
|
||||
|
||||
## Solution retenue : wrapper `write_styled_excel` (approche C)
|
||||
|
||||
Le besoin de `text_wrap` impose de créer le workbook manuellement (`xlsxwriter.Workbook` avec `default_format_properties`). Répéter ce boilerplate 6 fois est peu maintenable, donc on centralise dans une fonction utilitaire dans `src/utils/table.py`.
|
||||
|
||||
## Détail du design
|
||||
|
||||
### Constantes et wrapper — `src/utils/table.py`
|
||||
|
||||
```python
|
||||
import xlsxwriter
|
||||
|
||||
_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI
|
||||
_EXCEL_HEADER_FORMAT = {
|
||||
"bold": True,
|
||||
"bg_color": "#b33821", # couleur primaire de l'app
|
||||
"font_color": "white",
|
||||
}
|
||||
_EXCEL_COLUMN_WIDTHS = { # tirés des minWidth du DataTable commun (src/figures.py:269)
|
||||
"objet": 350,
|
||||
"acheteur_nom": 250,
|
||||
"titulaire_nom": 250,
|
||||
"acheteur_id": 160,
|
||||
}
|
||||
|
||||
def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None:
|
||||
col_widths = {
|
||||
col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0))
|
||||
for col in df.columns
|
||||
}
|
||||
wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}})
|
||||
ws = wb.add_worksheet(worksheet)
|
||||
df.write_excel(
|
||||
workbook=wb,
|
||||
worksheet=ws,
|
||||
header_format=_EXCEL_HEADER_FORMAT,
|
||||
column_widths=col_widths,
|
||||
)
|
||||
wb.close()
|
||||
```
|
||||
|
||||
**Comportement :**
|
||||
|
||||
- `text_wrap=True` via `default_format_properties` s'applique à toutes les cellules de données.
|
||||
- Chaque colonne reçoit au minimum 132 px (≈ 3.5 cm) ; les colonnes avec largeur explicite utilisent leur valeur si elle est supérieure.
|
||||
- En-têtes : fond rouge (`#b33821`), texte blanc, gras.
|
||||
- Le wrapper accepte un `pl.DataFrame` ; les callbacks qui travaillent avec une `LazyFrame` appellent `.collect()` (éventuellement `engine="streaming"`) avant d'appeler le wrapper.
|
||||
|
||||
### Mise à jour des 6 callbacks
|
||||
|
||||
Chaque bloc `def to_bytes(buffer):` est remplacé par un appel au wrapper.
|
||||
|
||||
**Exemples :**
|
||||
|
||||
```python
|
||||
# tableau.py — download_data
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
|
||||
# acheteur.py — download_acheteur_data (worksheet dynamique selon l'année)
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(
|
||||
df_to_download, buffer,
|
||||
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
|
||||
)
|
||||
|
||||
# acheteur.py — download_filtered_acheteur_data
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
```
|
||||
|
||||
Titulaire et observatoire : même pattern.
|
||||
|
||||
## Décisions clés
|
||||
|
||||
- **`autofit=False`** (pas d'autofit Polars) : les largeurs sont entièrement contrôlées par `col_widths`.
|
||||
- **`text_wrap` via workbook** : seule façon d'appliquer le wrapping à toutes les cellules via `write_excel` (les paramètres `column_formats`/`dtype_formats` de Polars n'exposent pas les propriétés xlsxwriter de format cellule).
|
||||
- **Constantes privées** (`_EXCEL_*`) : non exportées, consommées uniquement par `write_styled_excel`.
|
||||
- **Worksheet dynamique** : `download_acheteur_data` et `download_titulaire_data` utilisent l'année comme nom de feuille quand elle est définie — conservé via le paramètre `worksheet`.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Défilement horizontal ergonomique des tableaux (#82)
|
||||
|
||||
## Problème
|
||||
|
||||
Les tableaux de données (`DataTable` Dash) sont souvent plus larges que l'écran et
|
||||
**débordent vers la droite**. Aujourd'hui aucun `overflowX` n'est défini sur leur
|
||||
conteneur : le tableau étire la page entière, et le seul moyen de faire défiler
|
||||
horizontalement est la **barre de défilement de la fenêtre du navigateur**, tout en
|
||||
bas du viewport. Cette barre :
|
||||
|
||||
- est discrète et fait défiler **toute la page** (pas seulement le tableau) ;
|
||||
- n'est pas comprise comme « le moyen de voir le reste du tableau ».
|
||||
|
||||
De plus, les tableaux dépassent souvent du bas de l'écran : une barre placée en bas
|
||||
du tableau serait invisible sans scroller.
|
||||
|
||||
## Objectif
|
||||
|
||||
Rendre le défilement horizontal **évident et toujours accessible**, et garder les
|
||||
**en-têtes de colonnes visibles** pendant le défilement vertical, sans introduire de
|
||||
zone scrollable imbriquée gênante.
|
||||
|
||||
## Approche retenue (option B — sticky au niveau page)
|
||||
|
||||
Le tableau **reste dans le flux de la page** (pas de conteneur à hauteur fixe, pas de
|
||||
scroll imbriqué). On combine deux mécanismes :
|
||||
|
||||
1. **En-têtes collants** — `position: sticky; top: 0` sur la ligne d'en-tête du
|
||||
tableau. Quand l'utilisateur descend dans la page, les en-têtes se figent en haut
|
||||
de la fenêtre au lieu d'être « avalés ».
|
||||
|
||||
2. **Barre de défilement horizontale miroir en haut** — un petit élément placé
|
||||
juste au-dessus du tableau, lui aussi `sticky` en haut, dont le défilement
|
||||
horizontal est **synchronisé** avec celui du tableau. Elle est donc toujours
|
||||
visible dès qu'on voit le haut du tableau, et pilote le défilement horizontal sans
|
||||
devoir descendre en bas du tableau.
|
||||
|
||||
La barre miroir et les en-têtes collants se figent ensemble en haut de la fenêtre :
|
||||
l'utilisateur garde en permanence le repère des colonnes **et** le contrôle du
|
||||
défilement horizontal.
|
||||
|
||||
### Pourquoi pas l'option A (tableau « fenêtré » à hauteur fixe)
|
||||
|
||||
Écartée volontairement : un conteneur à hauteur fixe avec scroll interne crée un
|
||||
**scroll imbriqué** (la molette agit d'abord sur le tableau, pas sur la page), source
|
||||
de confusion. L'option B garde un comportement de défilement vertical unique (celui
|
||||
de la page) ; seul le défilement horizontal est « custom ».
|
||||
|
||||
## Contrainte technique CSS à gérer
|
||||
|
||||
Un conteneur en `overflow-x: auto` devient automatiquement un conteneur de
|
||||
défilement **vertical** (règle CSS : `overflow-y: visible` recalculé en `auto` dès
|
||||
que l'autre axe n'est pas `visible`), ce qui **casse** le `position: sticky; top: 0`
|
||||
des en-têtes par rapport à la page.
|
||||
|
||||
Conséquences pour l'implémentation :
|
||||
|
||||
- Le **défilement horizontal réel** doit se faire dans un conteneur dédié en
|
||||
`overflow-x: auto` ; mais ce conteneur ne peut pas, en même temps, héberger des
|
||||
en-têtes sticky « page ». La barre miroir du haut résout ce conflit : c'est **elle**
|
||||
qui porte le `overflow-x: auto`, séparée du tableau, et synchronisée par JS.
|
||||
- Il faudra **vérifier et neutraliser au besoin l'`overflow` interne** que Dash
|
||||
DataTable applique à ses propres conteneurs (`.dash-spreadsheet-container`,
|
||||
`.dash-spreadsheet-inner`) pour que le sticky des en-têtes fonctionne.
|
||||
- Le rendu réel de Dash DataTable doit être inspecté avant de figer le CSS : la
|
||||
structure DOM exacte (où poser `sticky`, quel élément porte la largeur totale)
|
||||
conditionne la solution. **À valider en testant dans le navigateur.**
|
||||
|
||||
## Composants
|
||||
|
||||
| Élément | Rôle | Emplacement probable |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| CSS en-têtes sticky | `position: sticky; top: 0` sur la ligne d'en-tête, z-index, fond opaque | `src/assets/css/style.css` (cible `.marches_table`) |
|
||||
| Barre miroir (markup) | `<div>` scrollable au-dessus du tableau, avec un enfant à la largeur du tableau | composant partagé `DataTable` / wrapper dans `src/figures.py` |
|
||||
| Synchronisation JS | lier scrollLeft barre ↔ tableau ; recopier la largeur du tableau dans la barre ; recalcul au resize / changement de données | nouveau fichier `src/assets/js/*.js` (assets Dash, chargé automatiquement) |
|
||||
| CSS barre miroir | hauteur, sticky `top: 0`, masquage si pas de débordement | `src/assets/css/style.css` |
|
||||
|
||||
## Portée
|
||||
|
||||
Les **quatre pages** utilisant `className="marches_table"` :
|
||||
`/tableau`, `/acheteur`, `/titulaire`, `/observatoire`. La solution passe par le
|
||||
composant `DataTable` partagé et la classe CSS `marches_table`, donc l'effort est
|
||||
quasi identique pour une ou quatre pages.
|
||||
|
||||
## Flux de données / interactions
|
||||
|
||||
1. Au rendu (et à chaque changement de données / largeur de fenêtre), le JS mesure la
|
||||
largeur totale du tableau et la reporte dans l'élément interne de la barre miroir →
|
||||
la barre miroir affiche une glissière proportionnelle.
|
||||
2. Événement `scroll` sur la barre miroir → on applique `scrollLeft` au conteneur du
|
||||
tableau ; et inversement (scroll du tableau → barre miroir), avec garde anti-boucle.
|
||||
3. Si le tableau ne déborde pas, la barre miroir est masquée.
|
||||
|
||||
## Cas limites
|
||||
|
||||
- **Pas de débordement** : barre miroir masquée, en-têtes sticky inoffensifs.
|
||||
- **Pagination / re-render** (pages en `page_action="custom"`) : la largeur peut
|
||||
changer → la synchro doit se recalculer après mise à jour des données.
|
||||
- **Resize de la fenêtre** : recalcul de la largeur miroir.
|
||||
- **Plusieurs tableaux sur une page** (`/observatoire`, `/titulaire`, `/acheteur` ont
|
||||
plusieurs `marches_table`) : le JS doit gérer chaque tableau indépendamment.
|
||||
- **Persistance / tri / filtre** : ne doit pas casser la synchro (réattacher les
|
||||
écouteurs si le DOM est recréé).
|
||||
|
||||
## Tests / validation
|
||||
|
||||
- Vérification **manuelle dans le navigateur** (point critique vu l'incertitude sur le
|
||||
DOM de DataTable) : débordement horizontal sur `/tableau`, sticky des en-têtes en
|
||||
scrollant, synchro des deux barres, comportement sur les pages à tableaux multiples.
|
||||
- S'assurer que les tests Selenium existants ne régressent pas
|
||||
(`pytest tests/test_main.py`).
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Colonnes figées (1re colonne sticky horizontalement).
|
||||
- Réduction du nombre de colonnes par défaut / refonte du sélecteur de colonnes.
|
||||
- `overscroll-behavior` et zones scrollables imbriquées (option A écartée).
|
||||
|
||||
## Verdict du spike
|
||||
|
||||
Le spike (Steps 1–3 exécutés par l'utilisateur en DevTools) doit valider les points suivants. En cas de déviation, le reste du plan reste applicable ; seule l'implémentation du sticky (Task 2) ajustera sa stratégie.
|
||||
|
||||
### Éléments attendus du DOM
|
||||
|
||||
- **Conteneur scrollable** : `.dash-spreadsheet-container` (enfant direct de `.marches_table`)
|
||||
- **Conteneur interne** : `.dash-spreadsheet-inner` (enfant de `.dash-spreadsheet-container`) — peut aussi porter un `overflow` interne
|
||||
- **En-têtes** : sélecteur exact `th.dash-header` (dans un `tr` au sein du tableau)
|
||||
- **Table complète** : `.cell-table` avec ses dimensions (`scrollWidth` >> `clientWidth` du parent → débordement confirmé)
|
||||
|
||||
### Hypothèse sticky
|
||||
|
||||
L'astuce CSS consiste à :
|
||||
|
||||
1. Neutraliser l'`overflow` sur `.dash-spreadsheet-container` et `.dash-spreadsheet-inner` en les ramenant à `overflow: visible` (ou en supprimant le style si possible)
|
||||
2. Appliquer `position: sticky; top: 0; z-index: 10; background: #fff` aux en-têtes `th.dash-header`
|
||||
|
||||
**Verdict attendu :** ✅ Oui — les en-têtes restent collés au haut de la fenêtre quand on scroll verticalement la page, sans recours à du JS supplémentaire (hormis la synchro scrollLeft pour le miroir).
|
||||
|
||||
**Si verdict = ❌ Non :** les en-têtes seront pilotés entièrement en JS (repositionnement au scroll), avec synchronisation du scroll vertical. Le reste du plan (barre miroir, synchro horizontale) reste valable.
|
||||
|
||||
### Références (à noter lors du spike)
|
||||
|
||||
- `scrollWidth` et `clientWidth` de la table vs. ses parents
|
||||
- Styles `overflow` en _Computed_ sur `.dash-spreadsheet-container`, `.dash-spreadsheet-inner` et `.cell-table`
|
||||
- Résultat du test d'hypothèse JS (sticky page fonctionne-t-il ?)
|
||||
@@ -0,0 +1,168 @@
|
||||
# Migration des emails transactionnels vers Brevo (API + templates hébergés)
|
||||
|
||||
**Issue :** #87
|
||||
**Branche :** `87_brevo` (partie de `feature/73_compte_utilisateur`)
|
||||
**Date :** 2026-06-24
|
||||
|
||||
## Contexte
|
||||
|
||||
Les emails transactionnels de decp.info (vérification d'adresse à l'inscription,
|
||||
réinitialisation de mot de passe) sont aujourd'hui envoyés via **Flask-Mail + SMTP**
|
||||
(boîte mail Infomaniak standard). Cette approche n'offre aucune visibilité sur la
|
||||
délivrabilité (le mail de reset est-il arrivé ?), expose au risque de suspension de
|
||||
la boîte mail en cas de pic, et délivre moins bien.
|
||||
|
||||
On migre vers **Brevo** (compte gratuit existant) en utilisant son **API
|
||||
transactionnelle** via le SDK officiel `brevo-python` **v5** (`5.0.0rc1`, une
|
||||
pré-release / release candidate, à installer avec `--pre` et épinglée strictement)
|
||||
et ses **templates hébergés**. Brevo est une société française, données en UE
|
||||
(bon pour le RGPD).
|
||||
|
||||
L'API v5 (vérifiée par introspection du paquet) :
|
||||
|
||||
```python
|
||||
from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem
|
||||
from brevo.core.api_error import ApiError
|
||||
|
||||
client = Brevo(api_key="...", headers={"X-Sib-Sandbox": "drop"}) # headers optionnels
|
||||
client.transactional_emails.send_transac_email(
|
||||
template_id=123,
|
||||
params={"link": "https://..."},
|
||||
sender=SendTransacEmailRequestSender(email="noreply@decp.info", name="decp.info"),
|
||||
to=[SendTransacEmailRequestToItem(email="dest@example.com")],
|
||||
)
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Remplacer entièrement le transport SMTP/Flask-Mail par l'API Brevo, avec deux
|
||||
templates hébergés côté Brevo (un par email), sans changer l'interface publique du
|
||||
mailer ni le comportement des routes appelantes.
|
||||
|
||||
## Périmètre
|
||||
|
||||
### Dans le périmètre
|
||||
|
||||
- Réécriture de `src/auth/mailer.py` pour utiliser le SDK Brevo.
|
||||
- Deux templates Brevo (déjà créés côté web), référencés par leur ID.
|
||||
- Mise à jour des dépendances et des variables d'environnement.
|
||||
- Suppression des templates Jinja d'email locaux.
|
||||
- Réécriture des tests du mailer (hermétiques) + chemin sandbox optionnel.
|
||||
|
||||
### Hors périmètre
|
||||
|
||||
- Le contenu/design des templates Brevo (géré dans l'interface web Brevo).
|
||||
- Les autres usages d'email du projet s'il en existe (variables legacy
|
||||
`SENDER_SERVER_DOMAIN`, `LOGIN_EMAIL`, `FROM_EMAIL`, `TO_EMAIL` de `.template.env`) :
|
||||
à **vérifier** avant suppression ; ne pas y toucher si utilisées ailleurs.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Interface publique inchangée
|
||||
|
||||
Les fonctions appelées depuis `src/auth/routes.py` (lignes 49 et 112) gardent
|
||||
exactement leur signature — **`routes.py` n'est pas modifié** :
|
||||
|
||||
```python
|
||||
send_verification_email(email: str, token: str) -> None
|
||||
send_reset_email(email: str, token: str) -> None
|
||||
```
|
||||
|
||||
### Composants de `src/auth/mailer.py` (réécrit)
|
||||
|
||||
| Fonction | Rôle |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `init_mailer()` | Construit le client `Brevo(api_key=...)` depuis `BREVO_API_KEY` (+ header sandbox si `BREVO_SANDBOX=true`). Plus de paramètre `app`. Stocke le client au niveau module. |
|
||||
| `_send_template(template_id, recipient, params)` | Appelle `client.transactional_emails.send_transac_email(template_id, params, sender=SendTransacEmailRequestSender(...), to=[SendTransacEmailRequestToItem(email=recipient)])`, log + remonte les `ApiError`. |
|
||||
| `send_verification_email(email, token)` | Construit le lien `{base}/auth/verify-email?token=...` et appelle `_send_template(VERIFY_ID, email, {"link": link})`. |
|
||||
| `send_reset_email(email, token)` | Construit le lien `{base}/reinitialiser-mot-de-passe?token=...` et appelle `_send_template(RESET_ID, email, {"link": link})`. |
|
||||
|
||||
Disparaît : toute la logique Jinja (`jinja_loader.searchpath`, `render_template`,
|
||||
les templates `.txt`/`.html`), `MAIL_SUPPRESS_SEND`, l'objet Flask-Mail.
|
||||
|
||||
### Appel d'initialisation
|
||||
|
||||
`src/auth/setup.py:39` passe de `mailer.init_mailer(app)` à `mailer.init_mailer()`
|
||||
(seule modification hors `mailer.py` et tests).
|
||||
|
||||
### Sender
|
||||
|
||||
Le sender (`MAIL_FROM` + `MAIL_FROM_NAME`) doit correspondre à un **expéditeur
|
||||
vérifié dans Brevo** ; le domaine `decp.info` doit être authentifié (SPF/DKIM)
|
||||
côté Brevo. Si le template Brevo définit déjà un sender, le passer explicitement
|
||||
reste possible et prioritaire.
|
||||
|
||||
## Données / flux
|
||||
|
||||
1. Une route (`routes.py`) génère un token et appelle `send_*_email(email, token)`.
|
||||
2. Le mailer construit le lien absolu (`APP_BASE_URL`) et l'envoie comme
|
||||
`params = {"link": link}`.
|
||||
3. Le template Brevo correspondant (`template_id`) injecte `{{ params.link }}` dans
|
||||
son HTML et envoie.
|
||||
4. En cas d'échec API : `ApiException` loggée puis remontée (comme aujourd'hui
|
||||
`flask_mail.send` pouvait lever).
|
||||
|
||||
## Variables d'environnement (`.template.env`)
|
||||
|
||||
**Ajoutées :**
|
||||
|
||||
- `BREVO_API_KEY=` — clé API transactionnelle Brevo
|
||||
- `BREVO_TEMPLATE_VERIFY_ID=` — ID numérique du template de vérification
|
||||
- `BREVO_TEMPLATE_RESET_ID=` — ID numérique du template de reset
|
||||
- `BREVO_SANDBOX=` — `true` pour valider sans délivrer (dev / intégration)
|
||||
- `MAIL_FROM_NAME=decp.info` — nom d'expéditeur
|
||||
|
||||
**Conservées :** `MAIL_FROM`, `APP_BASE_URL`
|
||||
|
||||
**Supprimées :** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`,
|
||||
`SMTP_USE_TLS`, `MAIL_SUPPRESS_SEND`
|
||||
|
||||
## Gestion d'erreur
|
||||
|
||||
- `_send_template` enveloppe l'appel dans un `try/except ApiError` : log
|
||||
(niveau error, sans la clé API) puis `raise` pour que l'appelant gère.
|
||||
- Si `init_mailer()` n'a pas été appelé ou `BREVO_API_KEY` absente : `assert`/erreur
|
||||
explicite, comme l'actuel « Mailer non initialisé ».
|
||||
|
||||
## Stratégie de tests
|
||||
|
||||
Réconciliation validée :
|
||||
|
||||
- **Tests unitaires** (`tests/auth/test_mailer.py`, réécrit) : on **mocke** la méthode
|
||||
`transactional_emails.send_transac_email` du client (monkeypatch) et on capture les
|
||||
kwargs passés pour chaque fonction :
|
||||
- `to[0].email == "a@b.c"`
|
||||
- `template_id == BREVO_TEMPLATE_VERIFY_ID` / `..._RESET_ID`
|
||||
- `params["link"]` contient le bon chemin et le token
|
||||
- Aucun appel réseau, tourne en CI sans clé.
|
||||
- **Mode sandbox Brevo** : pour la **vérification manuelle en dev** — `BREVO_SANDBOX=true`
|
||||
ajoute le header sandbox Brevo ; l'app accepte le déclenchement (signup/reset) et
|
||||
Brevo valide sans délivrer.
|
||||
- **Test d'intégration optionnel** : `@pytest.mark.integration`, skippé par défaut,
|
||||
exécuté seulement si `BREVO_API_KEY` est présent, tape la vraie API en sandbox.
|
||||
|
||||
## Faits vérifiés / points résiduels
|
||||
|
||||
**Vérifié par introspection de `brevo-python==5.0.0rc1`** :
|
||||
|
||||
- Install : `pip install --pre "brevo-python==5.0.0rc1"` (package PyPI `brevo-python`,
|
||||
import `brevo`).
|
||||
- Imports : `from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem` ; `from brevo.core.api_error import ApiError`.
|
||||
- Client : `Brevo(api_key=..., headers={...})` ; envoi via
|
||||
`client.transactional_emails.send_transac_email(template_id=int, params=dict, sender=..., to=[...], headers=dict|None)`.
|
||||
|
||||
**À confirmer en implémentation (ne pas bloquer) :**
|
||||
|
||||
- Valeur exacte du header sandbox Brevo (`X-Sib-Sandbox: drop` est la valeur
|
||||
documentée pour « accepter mais ne pas délivrer ») — à confirmer avec un envoi
|
||||
réel en sandbox.
|
||||
- Usage éventuel des variables legacy `SENDER_SERVER_DOMAIN`/`LOGIN_EMAIL`/
|
||||
`FROM_EMAIL`/`TO_EMAIL` ailleurs dans le code avant toute suppression.
|
||||
|
||||
## Critères de succès
|
||||
|
||||
- L'inscription envoie un email de vérification via Brevo (template hébergé) avec
|
||||
un lien fonctionnel.
|
||||
- La demande de reset envoie l'email de reset via Brevo avec un lien fonctionnel.
|
||||
- `routes.py` inchangé ; toute la suite de tests passe sans réseau.
|
||||
- `flask-mail` et les configs/templates SMTP retirés du dépôt.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Espace « Mon compte » — restructuration et section Compte
|
||||
|
||||
Date : 2026-06-24
|
||||
Statut : design validé
|
||||
Issue : #73 (comptes premium — abonnement mensuel à prix libre)
|
||||
|
||||
## Contexte
|
||||
|
||||
La page `/compte` actuelle (`src/pages/compte.py`) est une page unique étroite
|
||||
qui affiche l'email, un formulaire de changement de mot de passe (POST vers
|
||||
`/auth/change-password`) et un bouton de déconnexion. L'authentification passe
|
||||
par un Blueprint Flask `/auth/*` (`src/auth/routes.py`) avec des formulaires
|
||||
HTML côté serveur ; les pages sont des `register_page` Dash.
|
||||
|
||||
L'objectif est de transformer `/compte` en un **espace à plusieurs sections**
|
||||
(jusqu'à 6 à terme) avec une navigation latérale, et d'implémenter
|
||||
intégralement la première section, **Compte** (`/compte/admin`).
|
||||
|
||||
### Sections cibles
|
||||
|
||||
| Section | URL | Statut | Accès |
|
||||
| ------------ | -------------------- | -------- | ---------- |
|
||||
| Compte | `/compte/admin` | ce lot | compte |
|
||||
| Abonnement | `/compte/abonnement` | coquille | compte |
|
||||
| Mes archives | `/compte/archives` | futur | abonnement |
|
||||
| Mes filtres | `/compte/filtres` | futur | abonnement |
|
||||
| Mon SIRET | `/compte/siret` | futur | abonnement |
|
||||
|
||||
## Niveaux d'accès
|
||||
|
||||
Trois niveaux :
|
||||
|
||||
1. **Visiteur sans compte** → redirigé vers `/connexion?next=<path>`.
|
||||
2. **Compte sans abonnement valide** → accès à _Compte_ et _Abonnement_
|
||||
uniquement ; toute page gated redirige vers `/compte/abonnement`.
|
||||
3. **Compte avec abonnement valide** → accès à toutes les sections.
|
||||
|
||||
L'abonnement n'est pas encore implémenté : on introduit une abstraction
|
||||
`current_user_has_subscription() -> bool` qui renvoie `False` pour l'instant.
|
||||
C'est le seul point à brancher lorsque la facturation arrivera.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Routage (une page par section)
|
||||
|
||||
- Chaque section est un `register_page` distinct sous `/compte/*`, fidèle à la
|
||||
convention « une page = un fichier » du projet.
|
||||
- La page `/compte` actuelle devient une **redirection** vers `/compte/admin`
|
||||
(renvoie `dcc.Location(href="/compte/admin")`).
|
||||
- Ce lot crée `/compte/admin` (section Compte) et la coquille
|
||||
`/compte/abonnement` (page enregistrée minimale, contenu de vente à venir).
|
||||
Les sections gated (`archives`, `filtres`, `siret`) ne sont **pas** créées
|
||||
dans ce lot.
|
||||
|
||||
### Coquille partagée
|
||||
|
||||
Nouveau module `src/pages/_compte_shell.py` exposant :
|
||||
|
||||
- `account_shell(active: str, contenu) -> Component`
|
||||
Construit la mise en page commune :
|
||||
|
||||
- **Sidebar verticale** (desktop) listant les sections **accessibles** à
|
||||
l'utilisateur courant ; l'entrée `active` est surlignée.
|
||||
- **Bouton « ☰ Sections »** + `dbc.Offcanvas` (mobile) reprenant la même
|
||||
liste.
|
||||
- Insère `contenu` dans la zone principale.
|
||||
- Sections gated **masquées** tant que `current_user_has_subscription()` est
|
||||
faux (Compte + Abonnement seules visibles). La vente des fonctionnalités se
|
||||
fera dans le contenu de la section Abonnement, pas via des entrées
|
||||
verrouillées.
|
||||
- Construit avec Dash Bootstrap Components (`dbc.Row`/`dbc.Col`,
|
||||
`dbc.Nav`/`dbc.NavLink`, `dbc.Offcanvas`).
|
||||
|
||||
- `account_guard(require_subscription: bool) -> Component | None`
|
||||
Appelé en tête de chaque `layout()` :
|
||||
- non authentifié → `dcc.Location(href="/connexion?next=<path>")`
|
||||
- authentifié, `require_subscription` et pas d'abonnement →
|
||||
`dcc.Location(href="/compte/abonnement")`
|
||||
- sinon → `None` (la page se rend normalement).
|
||||
|
||||
La définition des sections (libellé, URL, icône, `require_subscription`) est
|
||||
centralisée dans une structure unique dans `_compte_shell.py`, pour que sidebar,
|
||||
offcanvas et gardes restent cohérents et que l'ajout d'une section future tienne
|
||||
en une ligne.
|
||||
|
||||
## Section Compte (`/compte/admin`)
|
||||
|
||||
Agencement : sections empilées avec séparateurs, dans `account_shell`.
|
||||
|
||||
1. **Adresse email** — affiche l'email actuel, champ « nouvelle adresse »,
|
||||
bouton « Mettre à jour l'email » → `POST /auth/change-email`.
|
||||
2. **Mot de passe** — formulaire existant (mot de passe actuel + nouveau +
|
||||
confirmation), inchangé → `POST /auth/change-password`.
|
||||
Sa cible de redirection passe de `/compte` à `/compte/admin`.
|
||||
3. **Zone danger** — encadré rouge, bouton « Supprimer mon compte » qui ouvre
|
||||
une **modale `dbc.Modal`** de confirmation demandant la re-saisie du mot de
|
||||
passe → `POST /auth/delete-account`.
|
||||
|
||||
Le bouton **Déconnexion** est conservé (POST `/auth/logout`).
|
||||
|
||||
Les messages de succès/erreur sont passés en query string et rendus en
|
||||
`dbc.Alert`, sur le modèle actuel ; `ERROR_MESSAGES` est étendu.
|
||||
|
||||
## Changement d'email avec re-vérification
|
||||
|
||||
Le changement d'email **ne prend pas effet immédiatement** : la nouvelle adresse
|
||||
doit être confirmée par email, par cohérence avec l'inscription. L'ancienne
|
||||
adresse reste active tant que la nouvelle n'est pas vérifiée (évite qu'une faute
|
||||
de frappe verrouille le compte).
|
||||
|
||||
Flux :
|
||||
|
||||
1. `POST /auth/change-email` (`@login_required`) :
|
||||
- valide la nouvelle adresse (`validate_email`, normalisée en minuscules) ;
|
||||
- vérifie l'unicité (`get_user_by_email` → erreur `email_taken`) ;
|
||||
- enregistre l'adresse en attente sur l'utilisateur et envoie un lien de
|
||||
vérification à cette nouvelle adresse (réutilise le mécanisme de jetons de
|
||||
vérification existant) ;
|
||||
- redirige `/compte/admin?email_pending=1`.
|
||||
2. Clic sur le lien → la nouvelle adresse devient l'email du compte et le
|
||||
`pending_email` est effacé, puis redirection vers `/compte/admin?email_changed=1`.
|
||||
|
||||
Implications stockage : ajout d'un champ `pending_email` à la table `users`
|
||||
(migration de schéma) et nouvelle fonction DB `update_email(user_id, email)`.
|
||||
Le détail exact du jeton (réutilisation de `create_email_verification_token` vs
|
||||
table dédiée) est laissé au plan d'implémentation.
|
||||
|
||||
## Suppression de compte
|
||||
|
||||
`POST /auth/delete-account` (`@login_required`) :
|
||||
|
||||
- vérifie le mot de passe courant (`check_password_hash`) → sinon
|
||||
`?error=invalid_current_password` ;
|
||||
- purge les jetons (`delete_email_verification_tokens_for_user`,
|
||||
`delete_password_reset_tokens_for_user`) puis `delete_user(current_user.id)` ;
|
||||
- `logout_user()` ;
|
||||
- redirige vers `/?account_deleted=1`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Accès (Selenium)** : les trois niveaux, avec le stub d'abonnement forcé à
|
||||
`True`/`False` ; redirection des non-authentifiés ; redirection des non-abonnés
|
||||
sur une page gated ; présence/masquage des entrées de sidebar.
|
||||
- **Navigation (Selenium)** : `/compte` → `/compte/admin` ; surlignage de la
|
||||
section active ; ouverture de l'offcanvas mobile.
|
||||
- **Routes** :
|
||||
- `change-email` : succès (passage en attente), email déjà pris, email
|
||||
invalide.
|
||||
- `delete-account` : mauvais mot de passe (refus), succès (compte supprimé +
|
||||
déconnexion).
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Contenu réel de la section Abonnement (perks, tarifs, paiement).
|
||||
- Sections Archives, Filtres, Mon SIRET.
|
||||
- Implémentation réelle de la facturation derrière
|
||||
`current_user_has_subscription()`.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Connexion avec LinkedIn (OIDC) — Design
|
||||
|
||||
Date : 2026-06-24
|
||||
Branche : `dev`
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre aux utilisateurs de créer un compte / se connecter à decp.info via
|
||||
LinkedIn, comme les boutons « Se connecter avec Google/GitHub » d'autres sites.
|
||||
Beaucoup d'utilisateurs viennent de LinkedIn ; réduire la friction d'inscription.
|
||||
|
||||
Portée : **LinkedIn uniquement** (pas d'abstraction multi-provider pour
|
||||
l'instant). Code simple et direct.
|
||||
|
||||
## Mécanisme
|
||||
|
||||
LinkedIn fournit _« Sign In with LinkedIn using OpenID Connect »_ (OIDC).
|
||||
On utilise la bibliothèque **Authlib** (intégration Flask) : elle gère la
|
||||
découverte OIDC, la redirection, l'échange `code → token`, la validation du
|
||||
token et le `state` anti-CSRF. Scopes demandés : `openid profile email`.
|
||||
|
||||
On ne stocke que l'**email** (comme l'auth existante), pas le nom ni la photo.
|
||||
|
||||
## Flux
|
||||
|
||||
1. L'utilisateur clique sur **« Connexion avec LinkedIn »** (présent sur
|
||||
`/connexion` et `/inscription`).
|
||||
2. `GET /auth/linkedin` → Authlib redirige vers LinkedIn.
|
||||
3. L'utilisateur autorise → LinkedIn redirige vers
|
||||
`GET /auth/linkedin/callback?code=...&state=...`.
|
||||
4. Authlib échange le `code`, valide le token, expose l'identité OIDC :
|
||||
`sub`, `email`, `email_verified`.
|
||||
5. Résolution / liaison du compte (voir ci-dessous).
|
||||
6. `login_user(user, remember=True)` → redirection vers `/compte/admin`
|
||||
(ou le `next` validé via `safe_next`).
|
||||
|
||||
## Schéma (migrations additives, dans `db._migrate`)
|
||||
|
||||
- `users.password_hash` devient **nullable** : un compte créé via LinkedIn n'a
|
||||
pas de mot de passe. Un tel utilisateur pourra s'en créer un plus tard via
|
||||
le flux « mot de passe oublié ».
|
||||
|
||||
- SQLite ne permet pas de retirer un `NOT NULL` par `ALTER COLUMN`. La
|
||||
migration recrée la table `users` sans la contrainte `NOT NULL` sur
|
||||
`password_hash` si elle est encore présente (copie des données,
|
||||
`PRAGMA table_info` pour détecter l'état). Les nouvelles installations
|
||||
créent directement le schéma sans `NOT NULL` sur `password_hash`.
|
||||
|
||||
- Nouvelle table :
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||
provider TEXT NOT NULL, -- 'linkedin'
|
||||
subject TEXT NOT NULL, -- le 'sub' OIDC stable
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, subject),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
## Résolution / liaison du compte (au callback)
|
||||
|
||||
Logique extraite dans une fonction testable `resolve_oauth_user(provider, subject, email, email_verified)` :
|
||||
|
||||
1. Si `(linkedin, sub)` existe dans `oauth_identities` → on connecte le
|
||||
`user_id` lié.
|
||||
2. Sinon, si un `user` existe déjà avec cet **email** → **liaison
|
||||
automatique** : insert dans `oauth_identities`, et si `email_verified`
|
||||
était `0`, on le passe à `1` (LinkedIn garantit un email vérifié).
|
||||
3. Sinon → **création** d'un nouvel utilisateur (`email`,
|
||||
`password_hash = NULL`, `email_verified = 1`) + insert `oauth_identities`.
|
||||
Aucun email de vérification Brevo n'est envoyé.
|
||||
|
||||
Décision produit : la liaison par email est automatique (l'email LinkedIn est
|
||||
vérifié, le risque est faible).
|
||||
|
||||
## Fichiers touchés
|
||||
|
||||
- **`src/auth/oauth.py`** (nouveau) : `init_oauth(app)` — initialise Authlib et
|
||||
enregistre le provider LinkedIn (config OIDC, client id/secret depuis l'env).
|
||||
- **`src/auth/routes.py`** : deux routes
|
||||
- `GET /auth/linkedin` — démarre le flux (`authorize_redirect`).
|
||||
- `GET /auth/linkedin/callback` — récupère l'identité, appelle
|
||||
`resolve_oauth_user`, `login_user`, redirige.
|
||||
- `resolve_oauth_user(...)` — logique de résolution (testable).
|
||||
- **`src/auth/db.py`** : schéma `oauth_identities`, migration `password_hash`
|
||||
nullable, helpers `get_oauth_identity(provider, subject)`,
|
||||
`link_oauth_identity(provider, subject, user_id)`,
|
||||
`create_oauth_user(email)`.
|
||||
- **`src/auth/setup.py`** : appel à `init_oauth(app)`.
|
||||
- **`src/pages/connexion.py`** et **`src/pages/inscription.py`** : bouton
|
||||
« Connexion avec LinkedIn ».
|
||||
- **`pyproject.toml`** : ajout de la dépendance `authlib`.
|
||||
- **`.template.env`** : `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
|
||||
|
||||
## UI du bouton
|
||||
|
||||
- Libellé : **« Connexion avec LinkedIn »**.
|
||||
- Style : texte blanc sur fond `rgb(10, 102, 194)` (bleu de marque LinkedIn,
|
||||
`#0A66C2`).
|
||||
- Un lien `<a href="/auth/linkedin">` (initiation par GET, pas de CSRF requis),
|
||||
placé sous le formulaire email/mot de passe, séparé par un « ou ».
|
||||
- Présent sur `/connexion` et `/inscription`.
|
||||
|
||||
## Gestion d'erreurs
|
||||
|
||||
- L'utilisateur annule / refuse sur LinkedIn → `/connexion?error=oauth_cancelled`.
|
||||
- Échange de token échoué, ou pas d'email renvoyé par LinkedIn →
|
||||
`/connexion?error=oauth_failed` (loggé via `logger.exception`).
|
||||
- `state` invalide → détecté par Authlib → `oauth_failed`.
|
||||
- Messages ajoutés dans `ERROR_MESSAGES` de `connexion.py`.
|
||||
|
||||
## Tests (dans `tests/auth/`)
|
||||
|
||||
Sans appel réseau réel à LinkedIn : on **mocke** le retour `userinfo` /
|
||||
l'identité OIDC exposée par Authlib.
|
||||
|
||||
- `resolve_oauth_user` :
|
||||
- nouvel utilisateur (création + `email_verified = 1`, pas de password).
|
||||
- liaison par email à un compte existant (insert identity, promotion
|
||||
`email_verified`).
|
||||
- identité déjà liée (retour du même `user_id`, pas de doublon).
|
||||
- Migration : `password_hash` accepte `NULL` ; table `oauth_identities` créée.
|
||||
- Helpers db : `get_oauth_identity`, `link_oauth_identity`, `create_oauth_user`.
|
||||
|
||||
## Prérequis hors code (côté admin du projet)
|
||||
|
||||
1. Créer une application sur le **LinkedIn Developer Portal**.
|
||||
2. Activer le produit _« Sign In with LinkedIn using OpenID Connect »_.
|
||||
3. Déclarer les redirect URIs autorisées :
|
||||
- `http://localhost:8050/auth/linkedin/callback` (dev)
|
||||
- `https://test.decp.info/auth/linkedin/callback` (test.decp.info)
|
||||
- `https://decp.info/auth/linkedin/callback` (prod)
|
||||
4. Récupérer le **Client ID** et le **Client Secret**, les renseigner dans
|
||||
`.env` (`LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
|
||||
|
||||
L'URL de callback est construite à partir de `APP_BASE_URL` (déjà présent dans
|
||||
l'env).
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Google / GitHub / autres providers.
|
||||
- Stockage du nom ou de la photo de profil LinkedIn.
|
||||
- Page de gestion « délier mon compte LinkedIn » (pourra venir plus tard).
|
||||
@@ -0,0 +1,142 @@
|
||||
# Sauvegarde de la base utilisateurs sur S3 — conception
|
||||
|
||||
Date : 2026-06-24
|
||||
Statut : conception validée, prête pour le plan d'implémentation
|
||||
|
||||
## Contexte et objectif
|
||||
|
||||
`users.sqlite` contient les comptes utilisateurs (`src/auth/db.py`) **et** les tokens API
|
||||
(`src/api/tokens_db.py`) — un seul fichier SQLite, ~36 Ko aujourd'hui, attendu jusqu'à
|
||||
~500 utilisateurs × quelques centaines de lignes. C'est la seule donnée non reproductible
|
||||
du projet (la base DECP en Parquet/DuckDB est régénérable, donc hors périmètre).
|
||||
|
||||
L'app tourne sur un VPS unique (systemd + gunicorn, `/var/www/<APP_NAME>`). On veut prévenir
|
||||
toute perte de données par des sauvegardes régulières vers un **stockage compatible S3
|
||||
(non-Amazon)**, avec rotation multi-paliers et un mécanisme de restauration.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
- **Déclencheur** : timer systemd sur le VPS (indépendant de gunicorn, robuste aux
|
||||
redémarrages, pas de duplication entre workers).
|
||||
- **Chiffrement** : les sauvegardes sont chiffrées côté application _avant_ l'envoi
|
||||
(données personnelles : emails, hash de mots de passe, tokens).
|
||||
- **Périmètre** : le seul fichier `users.sqlite` (chemin `USERS_DB_PATH`).
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Un nouveau module `src/backup/` expose une CLI à trois sous-commandes : `backup`, `list`,
|
||||
`restore`. Le timer systemd lance `backup` toutes les heures. Chaque exécution :
|
||||
|
||||
1. produit un **snapshot cohérent** de `users.sqlite` (API de backup en ligne de SQLite,
|
||||
sûre même en cas d'écriture concurrente) ;
|
||||
2. le **compresse** (gzip) puis le **chiffre** ;
|
||||
3. l'**envoie** sur le S3 sous une clé horodatée ;
|
||||
4. applique la **rotation** : recalcule l'ensemble à conserver et supprime les obsolètes.
|
||||
|
||||
## Schéma de rétention
|
||||
|
||||
Union de quatre paliers — une sauvegarde est conservée si elle qualifie pour _au moins un_
|
||||
palier :
|
||||
|
||||
| Palier | Granularité | Horizon | ~ Nb gardés |
|
||||
| ------------ | --------------------- | ------------------- | ----------- |
|
||||
| Horaire | 1 par heure | 12 dernières heures | ~12 |
|
||||
| Bi-quotidien | 1 par tranche de 12 h | 72 dernières heures | ~6 |
|
||||
| Quotidien | 1 par jour | 21 derniers jours | ~21 |
|
||||
| Mensuel | 1 par mois calendaire | 12 derniers mois | ~12 |
|
||||
|
||||
Soit ~51 fichiers au régime permanent, chacun de quelques dizaines de Ko → stockage
|
||||
négligeable.
|
||||
|
||||
### Algorithme — fonction pure
|
||||
|
||||
`select_retained(timestamps: list[datetime], now: datetime) -> set[datetime]` :
|
||||
|
||||
- Pour chaque palier à période fixe (horaire, bi-quotidien, quotidien) : regrouper les
|
||||
horodatages par tranche (`floor((now - t) / période)`), et pour chaque tranche comprise
|
||||
dans l'horizon, garder le **plus récent** de la tranche.
|
||||
- Pour le palier mensuel : regrouper par **mois calendaire** (`(année, mois)`) car les mois
|
||||
ont des durées variables ; garder le plus récent de chaque mois sur les 12 derniers mois.
|
||||
- Le résultat est l'**union** des ensembles retenus de tous les paliers.
|
||||
- L'ensemble à supprimer = tous les horodatages présents − ensemble retenu.
|
||||
|
||||
Aucune I/O dans cette fonction : elle prend la liste des horodatages (extraits des clés S3)
|
||||
et `now`, et renvoie quoi garder. Testée unitairement de façon exhaustive (chevauchements
|
||||
de paliers, bascules d'horizon, mois variables, ensemble vide).
|
||||
|
||||
## Modules (`src/backup/`)
|
||||
|
||||
Chaque module a une responsabilité unique et une interface explicite, testable isolément.
|
||||
|
||||
- **`rotation.py`** — `select_retained` (fonction pure, pas d'I/O). Cœur logique, tests
|
||||
unitaires nombreux.
|
||||
- **`snapshot.py`** — produit un snapshot SQLite cohérent via l'API de backup en ligne
|
||||
(`sqlite3.connect(src).backup(dest)`) vers un fichier temporaire, puis gzip. Renvoie le
|
||||
chemin du fichier compressé.
|
||||
- **`crypto.py`** — chiffrement/déchiffrement symétrique authentifié (Fernet, lib
|
||||
`cryptography`). Clé lue depuis `BACKUP_ENCRYPTION_KEY`. Round-trip testé.
|
||||
- **`storage.py`** — wrapper S3 (`upload`, `list`, `download`, `delete`) via `boto3` avec
|
||||
`endpoint_url` (compatible tout fournisseur S3 non-Amazon). Configuration lue depuis l'env.
|
||||
- **`cli.py`** — `argparse` : `backup`, `list`, `restore`. Point d'entrée appelé par le timer
|
||||
systemd et par l'opérateur pour la restauration.
|
||||
|
||||
### Nommage des objets S3
|
||||
|
||||
Clé : `<S3_BACKUP_PREFIX>/users-<YYYYMMDDTHHMMSSZ>.sqlite.gz.enc`, horodatage UTC ISO 8601
|
||||
compact, triable lexicographiquement. La rotation extrait l'horodatage depuis la clé.
|
||||
|
||||
## Procédure de sauvegarde (`backup`)
|
||||
|
||||
1. Snapshot cohérent de `users.sqlite` → fichier temporaire.
|
||||
2. gzip.
|
||||
3. Chiffrement Fernet.
|
||||
4. Upload sur S3 sous la clé horodatée.
|
||||
5. Rotation : lister les clés sous le préfixe, parser les horodatages, calculer l'ensemble
|
||||
retenu via `select_retained`, supprimer le reste.
|
||||
6. Nettoyage des fichiers temporaires (y compris en cas d'erreur).
|
||||
|
||||
Journalisation de chaque étape (succès/échec, clés uploadées/supprimées) pour suivi dans
|
||||
les logs systemd.
|
||||
|
||||
## Procédure de restauration (`restore`, manuelle, avec garde-fous)
|
||||
|
||||
- `list` → affiche les sauvegardes disponibles (clé, date, taille), triées.
|
||||
- `restore <clé>` :
|
||||
1. télécharge l'objet ;
|
||||
2. déchiffre puis décompresse vers un fichier temporaire ;
|
||||
3. **vérifie l'intégrité** SQLite (`PRAGMA integrity_check`) — refuse de restaurer si KO ;
|
||||
4. fait une **copie de secours** de la base courante (`users.sqlite.bak-<ts>`) ;
|
||||
5. remplace de façon atomique (`os.replace`).
|
||||
- Le script avertit d'**arrêter le service** (`systemctl stop <APP_NAME>`) avant la
|
||||
restauration et de le redémarrer après.
|
||||
|
||||
La restauration est volontairement **manuelle** : un outil de reprise sur sinistre ne doit
|
||||
jamais restaurer automatiquement.
|
||||
|
||||
## Déploiement et configuration
|
||||
|
||||
- Unités systemd dans `deploy/` :
|
||||
- `decpinfo-backup.service` (type `oneshot`, exécute `python -m src.backup backup`) ;
|
||||
- `decpinfo-backup.timer` (`OnCalendar=hourly`, `Persistent=true` pour rattraper un
|
||||
créneau manqué après un redémarrage).
|
||||
- Nouvelles variables dans `.template.env` :
|
||||
- `S3_ENDPOINT_URL`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`,
|
||||
`S3_BACKUP_PREFIX`, `BACKUP_ENCRYPTION_KEY`.
|
||||
- Nouvelles dépendances : `boto3`, `cryptography`.
|
||||
- **Gestion de la clé de chiffrement** : `BACKUP_ENCRYPTION_KEY` doit être sauvegardée
|
||||
hors du serveur (gestionnaire de secrets / coffre). Si elle est perdue, les sauvegardes
|
||||
sont irrécupérables.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unitaires** : `select_retained` (chevauchements, horizons, mois variables, vide) ;
|
||||
round-trip `crypto` ; nommage/parsing des clés.
|
||||
- **Intégration** : snapshot d'une base SQLite réelle → vérifie une base valide ;
|
||||
cycle complet backup → list → restore sur un faux backend S3 (mock `boto3` ou `moto`),
|
||||
vérifie l'égalité du contenu restauré et la copie de secours créée.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Sauvegarde de la base DECP (régénérable).
|
||||
- Restauration automatique / orchestration multi-serveurs.
|
||||
- Réplication temps réel.
|
||||
@@ -0,0 +1,336 @@
|
||||
# Abonnements payants via Frisbii — design
|
||||
|
||||
- **Issue** : [#90](https://github.com/ColinMaudry/decp.info/issues/90)
|
||||
- **Branche** : `feature/90_subscriptions`
|
||||
- **Date** : 2026-06-25
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre à un utilisateur connecté de **souscrire** un abonnement payant et de
|
||||
**résilier** son abonnement, via [Frisbii](https://docs.frisbii.com/) (ex-Reepay),
|
||||
une solution européenne de gestion d'abonnements. L'abonnement débloque les
|
||||
sections premium de l'espace compte (Mes archives, Mes filtres, Mon SIRET), déjà
|
||||
protégées par le mécanisme `require_subscription`.
|
||||
|
||||
Le câblage côté app est déjà prêt : `current_user_has_subscription()` dans
|
||||
`src/pages/_compte_shell.py` est un stub qui renvoie `False`, et
|
||||
`src/pages/compte_abonnement.py` est une page placeholder. Cette fonctionnalité
|
||||
les branche sur Frisbii.
|
||||
|
||||
## Décisions clés
|
||||
|
||||
1. **Session de checkout hébergée + webhooks.** On crée une _subscription session_
|
||||
Frisbii (`POST /v1/session/subscription`) qui renvoie une URL de page de
|
||||
paiement hébergée. Frisbii collecte la carte ; on ne manipule jamais de données
|
||||
de paiement. Les **webhooks** sont la **source de vérité** de l'état d'abonnement.
|
||||
2. **Deux plans fixes, définis côté Frisbii.** decp.info ne référence que leurs
|
||||
_handles_ (via `.env`) :
|
||||
|
||||
- `simple` — **20 € HT / mois**
|
||||
- `soutien` — **50 € HT / mois**
|
||||
|
||||
Les deux plans donnent **le même accès premium** ; le soutien est une
|
||||
contribution supérieure, pas un palier de fonctionnalités.
|
||||
|
||||
3. **Abonnement à durée indéterminée, mois glissants.** L'abonnement est renouvelé
|
||||
chaque mois (période ancrée sur la date d'inscription, comportement par défaut
|
||||
Frisbii — pas de prorata de première période). Il perdure jusqu'à résiliation.
|
||||
4. **Essai gratuit configuré côté Frisbii (2 jours souhaités).** L'essai est un
|
||||
`trial_interval` réglé sur **chaque plan dans le dashboard Frisbii** (aucun code
|
||||
pour le définir, et **la durée n'est pas codée en dur** côté app : elle est lue
|
||||
depuis le plan via l'API). La carte est **collectée à la souscription** (page hébergée)
|
||||
mais débitée seulement à la fin de l'essai ; l'abonnement passe alors
|
||||
automatiquement de `trial` à `active`. Si le paiement échoue → `expired`. Une
|
||||
résiliation pendant l'essai expire en **fin d'essai** (pas de débit). Pendant
|
||||
l'essai, l'utilisateur a **accès aux fonctions premium**.
|
||||
|
||||
**Anti-abus (un seul essai par compte).** Frisbii ne restreint pas l'essai par
|
||||
client : sans garde-fou, un utilisateur pourrait s'abonner, résilier avant la fin
|
||||
de l'essai (sans débit) et recommencer indéfiniment. On mémorise donc côté app un
|
||||
indicateur `trial_used` (positionné dès que l'abonnement entre en `trial` ou
|
||||
`active`). À toute souscription ultérieure d'un utilisateur dont `trial_used` est
|
||||
vrai, la session est créée avec **`no_trial=true`** → passage direct en `active`,
|
||||
débit immédiat, sans nouvel essai. _Limite connue, non traitée :_ un utilisateur
|
||||
pourrait créer plusieurs comptes decp.info (emails différents) pour refarmer des
|
||||
essais — acceptable vu l'essai de 2 jours et le contexte d'intérêt public.
|
||||
|
||||
5. **Résiliation en fin de période courante.** `POST` cancel Frisbii avec le
|
||||
comportement **par défaut** (expiration en fin de période courante — ou fin
|
||||
d'essai si en essai). L'accès est maintenu jusqu'à `current_period_end` renvoyé
|
||||
par Frisbii ; aucun calcul de date côté app.
|
||||
6. **Clé privée serveur uniquement.** HTTP Basic Auth (clé privée en username),
|
||||
jamais exposée au frontend.
|
||||
|
||||
## Architecture
|
||||
|
||||
Nouveau module `src/subscriptions/`, calqué sur `src/auth/`, avec des frontières
|
||||
nettes :
|
||||
|
||||
| Fichier | Rôle | Dépendances | Ne dépend PAS de |
|
||||
| ----------- | -------------------------------------------------------------- | ----------------- | ---------------- |
|
||||
| `client.py` | Client HTTP pur de l'API Frisbii | `requests`, env | DB, Flask |
|
||||
| `db.py` | Table `subscriptions` (réutilise `auth.db.get_conn`) | sqlite | Flask, client |
|
||||
| `plans.py` | Catalogue des plans (clé → handle, libellé, prix, description) | env | DB, Flask |
|
||||
| `routes.py` | Blueprint Flask : subscribe, cancel, webhook | client, db, plans | — |
|
||||
| `setup.py` | `init_subscriptions(app)` | routes | — |
|
||||
|
||||
Côté présentation :
|
||||
|
||||
- `src/pages/compte_abonnement.py` — UI de la page `/compte/abonnement`.
|
||||
- `src/pages/_compte_shell.py` — `current_user_has_subscription()` branché sur
|
||||
`subscriptions.db`.
|
||||
|
||||
### `client.py` — client Frisbii
|
||||
|
||||
Fonctions pures, sans état applicatif (toute config lue depuis l'env) :
|
||||
|
||||
- `_auth()` → tuple HTTP Basic `(FRISBII_API_KEY, "")`.
|
||||
- `get_or_create_customer(handle: str, email: str) -> dict`
|
||||
- Handle déterministe `decpinfo-{user_id}`. GET le customer ; s'il n'existe pas
|
||||
(404), le crée (`POST /v1/customer`). Idempotent.
|
||||
- `create_subscription_session(plan_handle, customer_handle, accept_url, cancel_url) -> str`
|
||||
- `POST /v1/session/subscription` avec `prepare_subscription` (plan + customer) et
|
||||
les URLs de retour. Renvoie l'`url` hébergée.
|
||||
- `cancel_subscription(subscription_handle) -> dict`
|
||||
- Cancel par défaut (fin de période courante). Renvoie l'objet subscription.
|
||||
- `get_subscription(subscription_handle) -> dict` (utilitaire de réconciliation).
|
||||
- `get_plan(plan_handle) -> dict`
|
||||
- `GET /v1/plan/{handle}`. Sert à lire les caractéristiques du plan (dont la durée
|
||||
d'essai `trial_interval`) sans la coder en dur côté app.
|
||||
|
||||
Base URL : `FRISBII_API_BASE_URL` (à confirmer au moment de l'implémentation depuis
|
||||
la doc Frisbii ; valeur par défaut documentée dans `.template.env`). Timeouts
|
||||
explicites sur tous les appels. Les erreurs HTTP lèvent une exception
|
||||
`FrisbiiError` (sous-classe locale) loggée par l'appelant.
|
||||
|
||||
### `db.py` — état d'abonnement
|
||||
|
||||
Table `subscriptions` dans `users.sqlite` (un abonnement courant par utilisateur) :
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
frisbii_customer_handle TEXT,
|
||||
frisbii_subscription_handle TEXT,
|
||||
plan TEXT, -- 'simple' | 'soutien'
|
||||
status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired'
|
||||
current_period_end TEXT, -- ISO 8601, nullable
|
||||
trial_used INTEGER NOT NULL DEFAULT 0, -- 1 dès qu'un essai a été consommé
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer
|
||||
ON subscriptions(frisbii_customer_handle);
|
||||
```
|
||||
|
||||
Le module possède sa propre `init_schema()` (appelée depuis `setup.py`) utilisant
|
||||
`auth.db.get_conn()`, pour rester isolé du schéma auth.
|
||||
|
||||
Fonctions :
|
||||
|
||||
- `upsert_subscription(user_id, customer_handle, subscription_handle, plan, status, current_period_end)`
|
||||
- `get_subscription_by_user(user_id) -> Row | None`
|
||||
- `get_subscription_by_customer(customer_handle) -> Row | None` (résolution webhook)
|
||||
- `set_status(user_id, status, current_period_end=None)`
|
||||
- `has_active_subscription(user_id) -> bool`
|
||||
→ `True` si une ligne existe avec `status` dans (`trial`, `active`) **ou**
|
||||
(`status='cancelled'` **et** `current_period_end` dans le futur). Couvre l'essai en
|
||||
cours et le cas « résilié mais encore valide jusqu'à la fin de la période ».
|
||||
|
||||
### Statuts et cycle de vie
|
||||
|
||||
| Statut | Sens | Accès premium |
|
||||
| ----------- | --------------------------------------------- | --------------------- |
|
||||
| `pending` | Session créée, paiement pas encore confirmé | non |
|
||||
| `trial` | Essai gratuit en cours (2 j), carte collectée | oui |
|
||||
| `active` | Abonnement en cours, renouvelé chaque mois | oui |
|
||||
| `cancelled` | Résilié, valide jusqu'à `current_period_end` | oui (jusqu'à la date) |
|
||||
| `expired` | Période échue (annulé ou échec de paiement) | non |
|
||||
|
||||
### `routes.py` — blueprint Flask
|
||||
|
||||
`subscriptions_bp` (préfixe explicite par route) :
|
||||
|
||||
- `POST /subscriptions/subscribe` — `@login_required`, **CSRF protégé**.
|
||||
- Form `plan=simple|soutien`. Résout le handle via `plans.py` (400 si inconnu).
|
||||
- `get_or_create_customer("decpinfo-{user_id}", email)`.
|
||||
- `upsert_subscription(..., status='pending')`.
|
||||
- **Anti-abus** : si `trial_used` est déjà vrai pour cet utilisateur, la session est
|
||||
créée avec `no_trial=true` (pas de nouvel essai gratuit).
|
||||
- `create_subscription_session(...)` avec
|
||||
`accept_url={APP_BASE_URL}/compte/abonnement?paiement=succes` et
|
||||
`cancel_url={APP_BASE_URL}/compte/abonnement?paiement=annule`.
|
||||
- Redirection **303** vers l'URL hébergée.
|
||||
- En cas d'erreur API : redirection `/compte/abonnement?error=frisbii` + log.
|
||||
- `POST /subscriptions/cancel` — `@login_required`, **CSRF protégé**.
|
||||
- Récupère l'abonnement de l'utilisateur ; 400 s'il n'y en a pas d'actif.
|
||||
- `cancel_subscription(handle)` (défaut = fin de période).
|
||||
- Met à jour le statut localement (`cancelled` + `current_period_end` si renvoyé) ;
|
||||
le webhook confirmera.
|
||||
- Redirection `/compte/abonnement?resiliation=ok`.
|
||||
- `POST /frisbii/webhook` — **CSRF-exempt**, pas d'auth de session.
|
||||
- **Vérifie la signature** via `FRISBII_WEBHOOK_SECRET` selon le schéma documenté
|
||||
par Frisbii (à confirmer depuis la doc webhooks à l'implémentation). Signature
|
||||
invalide → **403**.
|
||||
- Dispatch par type d'événement (mapping vers l'utilisateur via le customer
|
||||
handle stocké) :
|
||||
- `subscription_created` → `status='trial'` si l'abonnement démarre en essai
|
||||
(champ d'essai du payload), sinon `status='active'` ; `current_period_end`
|
||||
(= fin d'essai pendant l'essai) maj.
|
||||
- fin d'essai / premier débit (`subscription_renewed` / `invoice_settled`) →
|
||||
`status='active'`, `current_period_end` maj.
|
||||
- `invoice_settled` / renouvellement → `current_period_end` maj.
|
||||
- `subscription_cancelled` → `status='cancelled'`, `current_period_end` maj.
|
||||
- `subscription_expired` / échec de paiement terminal → `status='expired'`.
|
||||
- Dès que le statut calculé est `trial` ou `active`, positionne `trial_used=1`
|
||||
(anti-abus, valeur collante).
|
||||
- Événement inconnu → **200** (ignoré). Erreur de traitement → **5xx** pour que
|
||||
Frisbii réessaie. Les noms exacts d'événements seront confirmés depuis la doc
|
||||
Frisbii ; le dispatch est piloté par une table `EVENT_HANDLERS` facile à étendre.
|
||||
|
||||
### `setup.py` — initialisation
|
||||
|
||||
`init_subscriptions(app)` :
|
||||
|
||||
- `db.init_schema()`.
|
||||
- Enregistre `subscriptions_bp`.
|
||||
- Exempte la vue webhook de CSRF (même approche que `_auth_csrf.exempt` dans
|
||||
`src/app.py`).
|
||||
- Warnings au démarrage si `FRISBII_API_KEY`, `FRISBII_WEBHOOK_SECRET`,
|
||||
`FRISBII_PLAN_SIMPLE` ou `FRISBII_PLAN_SOUTIEN` manquent (comme Brevo/LinkedIn).
|
||||
|
||||
Appelé depuis `src/app.py` après `init_auth(...)`.
|
||||
|
||||
### `plans.py` — catalogue
|
||||
|
||||
```python
|
||||
PLANS = {
|
||||
"simple": {"handle": env("FRISBII_PLAN_SIMPLE"), "label": "Abonnement simple",
|
||||
"prix_ht": 20, "description": "..."},
|
||||
"soutien": {"handle": env("FRISBII_PLAN_SOUTIEN"), "label": "Abonnement de soutien",
|
||||
"prix_ht": 50, "description": "..."},
|
||||
}
|
||||
```
|
||||
|
||||
`resolve_handle(key) -> str | None` pour les routes ; le dict sert aussi à rendre
|
||||
les cartes de la page.
|
||||
|
||||
`trial_days(key) -> int | None` : lit la durée d'essai **depuis Frisbii**
|
||||
(`client.get_plan(handle)` → `trial_interval`, parsé en jours), avec un **cache**
|
||||
(TTL ~1 h via `src/utils/cache.py`, les plans changeant rarement). Échec API ou plan
|
||||
sans essai → `None` (la mention d'essai est alors masquée, pas de valeur en dur).
|
||||
|
||||
## Page `/compte/abonnement`
|
||||
|
||||
`account_guard("/compte/abonnement", require_subscription=False)` reste (la page est
|
||||
accessible sans abonnement). Le contenu dépend de l'état :
|
||||
|
||||
**Sans abonnement actif** :
|
||||
|
||||
- Deux cartes de plan (Simple 20 € HT/mois, Soutien 50 € HT/mois), chacune avec un
|
||||
formulaire `POST /subscriptions/subscribe` (input caché `plan` + CSRF) et un bouton
|
||||
« S'abonner ». Mention d'essai sur les cartes, selon l'éligibilité de l'utilisateur
|
||||
(`db.has_used_trial`) :
|
||||
- essai non encore utilisé et plan avec essai → « {n} jours d'essai gratuit »
|
||||
(`{n}` lu depuis le plan via `plans.trial_days(key)`) ;
|
||||
- essai déjà utilisé (souscription créée avec `no_trial=true`) → mention explicite
|
||||
**« Sans période d'essai (déjà utilisée) — débit immédiat »** ;
|
||||
- plan sans essai configuré → aucune mention.
|
||||
- Contenu pédagogique (issue #90) :
|
||||
- **À quoi servent les abonnements** : abonnement Frisbii 50 €, serveur Scaleway
|
||||
40 €, espace de coworking 250 €, salaire médian 3 840 €.
|
||||
- **Ce que le soutien permettrait** : rédaction d'études à partir des données (ex.
|
||||
acheteurs aux données introuvables et raisons de la non-publication) ;
|
||||
coordination des bonnes volontés militant pour une législation plus exigeante sur
|
||||
la transparence de la commande publique.
|
||||
|
||||
**Avec abonnement actif** :
|
||||
|
||||
- Plan courant, statut, date de prochain renouvellement / fin de validité
|
||||
(`current_period_end`).
|
||||
- Si `trial` : bandeau « Essai gratuit jusqu'au {date}, puis débit automatique ».
|
||||
- Si `cancelled` : bandeau « Abonnement résilié, actif jusqu'au {date} ».
|
||||
- Si `trial` ou `active` : formulaire `POST /subscriptions/cancel` (CSRF) + bouton
|
||||
« Résilier » (en essai, la résiliation évite tout débit).
|
||||
|
||||
**Messages de retour** (query params lus dans le `layout`) : `paiement=succes`
|
||||
(« Merci, votre abonnement est en cours d'activation »), `paiement=annule`,
|
||||
`resiliation=ok`, `error=frisbii`.
|
||||
|
||||
## `current_user_has_subscription()`
|
||||
|
||||
Dans `_compte_shell.py`, remplacer le stub par :
|
||||
|
||||
```python
|
||||
def current_user_has_subscription() -> bool:
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
return subscriptions.db.has_active_subscription(current_user.id)
|
||||
```
|
||||
|
||||
C'est le seul point de branchement avec le reste de l'espace compte ; le mécanisme
|
||||
`visible_sections` / `guard_redirect` existant fonctionne tel quel.
|
||||
|
||||
## Configuration (`.template.env`)
|
||||
|
||||
```bash
|
||||
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
|
||||
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
|
||||
FRISBII_API_BASE_URL= # base de l'API Frisbii (cf. doc)
|
||||
FRISBII_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois)
|
||||
FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois)
|
||||
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
|
||||
```
|
||||
|
||||
**Prérequis de configuration côté dashboard Frisbii** (hors code, à documenter) :
|
||||
|
||||
- Créer les deux plans mensuels (mois glissants, ancrés sur la date d'inscription)
|
||||
avec un **essai de 2 jours** (`trial_interval`) et collecte de la carte à la
|
||||
souscription.
|
||||
- Configurer un webhook vers `{APP_BASE_URL}/frisbii/webhook` avec les événements
|
||||
d'abonnement et de facturation, et récupérer le secret de signature.
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
| Situation | Comportement |
|
||||
| ---------------------------- | ------------------------------------------------------------------------- |
|
||||
| Échec API à la souscription | Redirect `/compte/abonnement?error=frisbii` + log |
|
||||
| Échec API à la résiliation | Redirect `/compte/abonnement?error=frisbii` + log ; statut local inchangé |
|
||||
| Webhook signature invalide | 403, pas de traitement |
|
||||
| Webhook événement inconnu | 200, ignoré |
|
||||
| Webhook erreur de traitement | 5xx → Frisbii réessaie |
|
||||
| Config Frisbii absente | Warnings au démarrage ; souscription échoue proprement |
|
||||
|
||||
## Tests
|
||||
|
||||
Unitaires (mocks, pas d'appel réseau réel) :
|
||||
|
||||
- `client.py` : auth Basic, get-or-create customer (200 vs 404→create), création de
|
||||
session (URL renvoyée), cancel ; gestion d'erreur HTTP → `FrisbiiError`. HTTP mocké.
|
||||
- `db.py` : upsert / get / set_status ; `has_active_subscription` pour chaque statut
|
||||
(`trial` et `active` → vrai ; `cancelled` futur → vrai, passé → faux ; `pending`
|
||||
et `expired` → faux).
|
||||
- `plans.py` : `resolve_handle` (connu / inconnu) ; `trial_days` (parsing du
|
||||
`trial_interval` renvoyé par un `get_plan` mocké, mise en cache, `None` si échec API
|
||||
ou plan sans essai).
|
||||
- `routes.py` : webhook — signature valide/invalide, dispatch de chaque événement
|
||||
vers le bon changement de statut (payloads factices), résolution par customer
|
||||
handle ; subscribe (redirect 303 vers l'URL de session, statut `pending` créé) ;
|
||||
cancel (appel client + statut `cancelled`).
|
||||
|
||||
Intégration légère (rendu) :
|
||||
|
||||
- Page `/compte/abonnement` : affiche les deux cartes + boutons « S'abonner » sans
|
||||
abonnement ; affiche le bouton « Résilier » et la date avec abonnement actif (DB
|
||||
de test préremplie).
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Changement de plan / upgrade-downgrade en self-service (le client peut résilier et
|
||||
re-souscrire).
|
||||
- Montant de soutien libre (décidé : plans fixes).
|
||||
- Réconciliation périodique automatique (un utilitaire `get_subscription` existe pour
|
||||
un script manuel si besoin, mais pas de cron).
|
||||
- Facturation / historique des factures dans l'UI (Frisbii fournit son propre portail
|
||||
et envoie les factures par email).
|
||||
@@ -0,0 +1,113 @@
|
||||
# Accès gratuit pour tous via `TOUS_ABONNES`
|
||||
|
||||
**Date :** 2026-06-26
|
||||
**Statut :** Design validé
|
||||
|
||||
## Contexte
|
||||
|
||||
La plateforme de paiement Frisbii doit effectuer un _background check_ avant
|
||||
d'autoriser la réception de paiements (plusieurs semaines). En attendant, on
|
||||
veut ouvrir gratuitement à tout utilisateur connecté les fonctionnalités
|
||||
normalement réservées aux abonnés, sans casser le code d'abonnement existant
|
||||
(qui sera réactivé tel quel une fois Frisbii validé).
|
||||
|
||||
## Objectif
|
||||
|
||||
Un drapeau d'environnement `TOUS_ABONNES` qui, lorsqu'il vaut `true` :
|
||||
|
||||
1. donne à tout utilisateur **connecté** l'accès aux fonctionnalités réservées
|
||||
aux abonnés ;
|
||||
2. affiche un bandeau d'information en haut de la page `/compte/abonnement` ;
|
||||
3. désactive (et grise) les boutons « S'abonner » sur `/compte/abonnement`.
|
||||
|
||||
Quand le drapeau est absent ou `false`, le comportement actuel est strictement
|
||||
inchangé.
|
||||
|
||||
## Architecture existante (rappel)
|
||||
|
||||
- `src/pages/_compte_shell.py` centralise l'accès :
|
||||
- `current_user_has_subscription()` → utilisé par `_nav` (sections visibles
|
||||
du menu) **et** `account_guard` (protection des pages réservées) ;
|
||||
- `SECTIONS` marque `archives`, `filtres`, `siret` avec
|
||||
`require_subscription: True`.
|
||||
- `db.has_active_subscription(user_id)` est le contrôle bas-niveau « vrai
|
||||
abonnement payant » ; appelé directement par :
|
||||
- `compte_abonnement.py` (`has_access` → vue « abonnement actif » vs cartes de
|
||||
plans) ;
|
||||
- `auth/routes.py::_post_login_url` (redirection post-login) ;
|
||||
- `subscriptions/routes.py::subscribe` (anti double-abonnement).
|
||||
- `compte_abonnement.py::_plan_card` rend le bouton « S'abonner ».
|
||||
|
||||
## Conception
|
||||
|
||||
### 1. Variable d'environnement
|
||||
|
||||
Dans `src/utils/__init__.py`, suivant la convention de `DEVELOPMENT` :
|
||||
|
||||
```python
|
||||
TOUS_ABONNES = os.getenv("TOUS_ABONNES", "False").lower() == "true"
|
||||
```
|
||||
|
||||
Documentée dans `.template.env`.
|
||||
|
||||
### 2. Déblocage de l'accès (point unique)
|
||||
|
||||
`src/pages/_compte_shell.py::current_user_has_subscription()` :
|
||||
|
||||
```python
|
||||
def current_user_has_subscription() -> bool:
|
||||
from src.subscriptions import db
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
if TOUS_ABONNES:
|
||||
return True
|
||||
return db.has_active_subscription(current_user.id)
|
||||
```
|
||||
|
||||
Ce seul changement débloque les sections `archives`/`filtres`/`siret` dans le
|
||||
menu (`_nav`) **et** lève leur `account_guard`.
|
||||
|
||||
**On ne touche pas** à `db.has_active_subscription()` : il doit continuer à
|
||||
refléter un vrai abonnement payant. Conséquence voulue : sur
|
||||
`/compte/abonnement`, `has_access` reste `False` pour un utilisateur sans
|
||||
abonnement réel → il voit les cartes de plans (désactivées) + le bandeau, et
|
||||
non une fausse vue « abonnement actif ».
|
||||
|
||||
### 3. Bandeau d'information (page `/compte/abonnement` uniquement)
|
||||
|
||||
Dans `compte_abonnement.py::layout`, quand `TOUS_ABONNES`, insérer en haut du
|
||||
`body` (avant les cartes) un `dbc.Alert` (`color="info"`) :
|
||||
|
||||
> Les fonctionnalités normalement accessibles contre un abonnement de 20 € HT
|
||||
> par mois sont accessibles à tous et toutes en attendant la validation de mon
|
||||
> dossier pour recevoir des paiements.
|
||||
|
||||
### 4. Boutons « S'abonner » désactivés et gris
|
||||
|
||||
Dans `compte_abonnement.py::_plan_card`, quand `TOUS_ABONNES`, le bouton est
|
||||
rendu désactivé et gris (`className="btn btn-secondary disabled"`,
|
||||
`disabled=True`). Les cartes restent visibles à titre informatif.
|
||||
|
||||
### 5. Redirection post-login
|
||||
|
||||
**Inchangée.** `_post_login_url` s'appuie sur `db.has_active_subscription`, qui
|
||||
reste `False` pour les non-abonnés réels → redirection vers
|
||||
`/compte/abonnement`, ce qui est le comportement souhaité avec `TOUS_ABONNES`.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Aucune autre fonctionnalité « abonné » n'est gatée ailleurs que via
|
||||
`account_guard` (vérifié : pas de contrôle d'abonnement dans `tableau.py`,
|
||||
exports, sauvegarde de filtres).
|
||||
- Pas de modification du flux de paiement Frisbii ni du webhook.
|
||||
|
||||
## Tests
|
||||
|
||||
- `current_user_has_subscription()` : `True` si connecté + `TOUS_ABONNES`,
|
||||
`False` si non connecté même avec le drapeau, comportement DB normal si
|
||||
drapeau absent.
|
||||
- `visible_sections` : sections réservées visibles via le drapeau (en
|
||||
s'appuyant sur le helper).
|
||||
- `compte_abonnement` : bandeau présent et bouton désactivé quand `TOUS_ABONNES`
|
||||
est actif ; absents sinon.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Sauvegarde des vues du Tableau
|
||||
|
||||
**Date :** 2026-06-29
|
||||
**Statut :** Design validé
|
||||
**Issue :** [#95](https://github.com/ColinMaudry/decp.info/issues/95)
|
||||
|
||||
## Contexte
|
||||
|
||||
La page `/tableau` permet déjà de filtrer, trier et choisir les colonnes des
|
||||
marchés. Ces réglages sont **matérialisés dans l'URL** via trois paramètres
|
||||
(`filtres`, `tris`, `colonnes`) : `sync_url_and_reset_button` les produit (bouton
|
||||
« Partager la vue ») et `restore_view_from_url` les restaure à l'ouverture d'une
|
||||
URL ainsi formée.
|
||||
|
||||
L'issue #95 demande d'aller plus loin : permettre aux utilisateur·ices de
|
||||
**sauvegarder des vues nommées** et de les ré-appliquer en un clic, sans avoir à
|
||||
manipuler ou conserver des URL.
|
||||
|
||||
L'issue mentionnait les trois tableaux (`/tableau`, `/titulaire`, `/acheteur`).
|
||||
**Le périmètre a été resserré à `/tableau` uniquement.** C'est le seul des trois
|
||||
qui gère aujourd'hui les paramètres d'URL et le partage ; `/titulaire` et
|
||||
`/acheteur` ne les supportent pas encore et sont hors périmètre.
|
||||
|
||||
## Objectif
|
||||
|
||||
Pour un·e **abonné·e** sur `/tableau` :
|
||||
|
||||
1. **Sauvegarder** la vue courante (filtres + tris + colonnes) sous un nom
|
||||
personnalisé, saisi dans une modale.
|
||||
2. **Appliquer** une vue sauvegardée en la choisissant dans un menu déroulant.
|
||||
|
||||
Pour un·e **abonné·e** dans l'espace compte :
|
||||
|
||||
3. **Gérer** ses vues sur une nouvelle page `/compte/vues` : lister, renommer,
|
||||
supprimer.
|
||||
|
||||
Les non-abonné·es ne voient aucun de ces contrôles, et toute opération
|
||||
d'écriture est refusée côté serveur.
|
||||
|
||||
## Principe
|
||||
|
||||
Une **vue** = un nom + la query string que `/tableau` sait déjà produire et
|
||||
restaurer (`filtres` + `tris` + `colonnes`). On ne réinvente rien :
|
||||
|
||||
- **Sauvegarder** = construire la query string comme le fait déjà
|
||||
`sync_url_and_reset_button`, puis la stocker avec un nom.
|
||||
- **Appliquer** = naviguer vers `/tableau?<query>` ; `restore_view_from_url`
|
||||
existant fait le reste.
|
||||
|
||||
## Architecture existante (rappel)
|
||||
|
||||
- `src/pages/tableau.py` :
|
||||
- `sync_url_and_reset_button` — construit la query string à partir de
|
||||
`filter_query`, `sort_by`, `hidden_columns` (via `invert_columns`).
|
||||
- `restore_view_from_url` — réagit à `tableau_url.search`, applique
|
||||
`filtres`/`tris`/`colonnes` au DataTable.
|
||||
- `dcc.Location(id="tableau_url", refresh=False)` — la navigation interne ne
|
||||
recharge pas la page.
|
||||
- `src/pages/_compte_shell.py` :
|
||||
- `current_user_has_subscription()` — **point unique** de contrôle d'accès,
|
||||
respecte le drapeau `TOUS_ABONNES`.
|
||||
- `SECTIONS` — liste centralisée des sections de l'espace compte (chaque entrée
|
||||
peut exiger `require_subscription: True`).
|
||||
- `account_guard(path, require_subscription)` — protège une page compte
|
||||
(redirige vers `/connexion` ou `/compte/abonnement`).
|
||||
- `account_shell(active, contenu)` — gabarit (barre latérale + contenu).
|
||||
- `src/subscriptions/db.py` — modèle de référence pour un module DB sur
|
||||
`users.sqlite` : constante `SCHEMA`, `init_schema()`, fonctions CRUD via
|
||||
`src.auth.db.get_conn()`.
|
||||
- `src/subscriptions/setup.py::init_subscriptions` appelle `db.init_schema()` au
|
||||
démarrage ; câblé dans `src/app.py` (`init_subscriptions(app.server)`).
|
||||
- L'identité de l'utilisateur·ice connecté·e est disponible dans les callbacks
|
||||
via `flask_login.current_user` (les callbacks Dash s'exécutent dans le
|
||||
contexte de requête Flask).
|
||||
|
||||
## Conception
|
||||
|
||||
### 1. Stockage — table `saved_views` dans `users.sqlite`
|
||||
|
||||
Nouveau module `src/saved_views/db.py`, calqué sur `src/subscriptions/db.py`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS saved_views (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
table_name TEXT NOT NULL DEFAULT 'tableau',
|
||||
name TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE (user_id, table_name, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_views_user
|
||||
ON saved_views(user_id, table_name);
|
||||
```
|
||||
|
||||
- `table_name` vaut toujours `'tableau'` pour l'instant. La colonne réserve la
|
||||
place pour `/titulaire` et `/acheteur` plus tard, sans surcoût ni UI
|
||||
aujourd'hui.
|
||||
- `query` est la query string telle qu'elle apparaît dans l'URL (par ex.
|
||||
`filtres=...&tris=...&colonnes=...`), produite et consommée exactement comme le
|
||||
fait le partage existant. Appliquer = naviguer vers `/tableau?<query>`.
|
||||
- `UNIQUE (user_id, table_name, name)` empêche les doublons de nom pour un·e même
|
||||
utilisateur·ice.
|
||||
|
||||
Fonctions du module (toutes via `src.auth.db.get_conn()`) :
|
||||
|
||||
| Fonction | Rôle |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `init_schema()` | `executescript(SCHEMA)` — idempotent (`IF NOT EXISTS`). |
|
||||
| `list_views(user_id, table_name)` | Vues de l'utilisateur·ice pour ce tableau, triées par `name`. |
|
||||
| `upsert(user_id, table_name, name, query)` | Insert, ou `ON CONFLICT(user_id, table_name, name) DO UPDATE` → **écrase** `query` et `updated_at`. Enregistrer sous un nom existant **met donc la vue à jour** (pas d'erreur). |
|
||||
| `rename(view_id, user_id, new_name)` | Renomme ; `user_id` dans le `WHERE` garantit la propriété. |
|
||||
| `delete(view_id, user_id)` | Supprime ; `user_id` dans le `WHERE` garantit la propriété. |
|
||||
| `get(view_id, user_id)` | Une vue, contrôle de propriété. |
|
||||
|
||||
`init_schema()` est appelé au démarrage dans `src/app.py`, à côté de
|
||||
`init_subscriptions(app.server)` :
|
||||
|
||||
```python
|
||||
from src.saved_views import db as saved_views_db
|
||||
saved_views_db.init_schema()
|
||||
```
|
||||
|
||||
### 2. UI sur `/tableau` (abonné·es uniquement)
|
||||
|
||||
Ajout d'un conteneur `saved-views-bar` dans la `table-menu` existante de
|
||||
`tableau.py`, **masqué par défaut** (`style={"display": "none"}`). Il contient
|
||||
trois éléments :
|
||||
|
||||
1. **Bouton « Sauvegarder la vue »** — ouvre la modale de nommage.
|
||||
2. **Modale de sauvegarde** — un champ texte (nom) + bouton « Enregistrer ».
|
||||
3. **Menu déroulant « Mes vues »** (`dbc.DropdownMenu`) — la liste des vues.
|
||||
|
||||
#### Affichage conditionnel (gating)
|
||||
|
||||
Un callback rend la barre visible **uniquement pour les abonné·es** :
|
||||
|
||||
- Déclencheur : chargement de la page (Input sur `tableau_url.pathname` ou
|
||||
`href`).
|
||||
- Logique : `style = {}` si `current_user_has_subscription()`, sinon
|
||||
`{"display": "none"}`.
|
||||
|
||||
Les composants restent présents dans le DOM (cachés), donc leurs callbacks sont
|
||||
toujours valides — pas besoin de `suppress_callback_exceptions`. Le masquage
|
||||
côté client ne suffit pas à lui seul : **toute écriture est re-contrôlée côté
|
||||
serveur** (voir ci-dessous).
|
||||
|
||||
#### Sauvegarder
|
||||
|
||||
Callback de la modale (clic sur « Enregistrer ») :
|
||||
|
||||
- States : `filter_query`, `sort_by`, `hidden_columns` du DataTable + valeur du
|
||||
champ nom.
|
||||
- **Re-vérifie `current_user_has_subscription()`** ; si faux, ne fait rien
|
||||
(no-update).
|
||||
- Construit la query string de la même manière que
|
||||
`sync_url_and_reset_button` (réutiliser/extraire la logique commune dans une
|
||||
petite fonction utilitaire pour éviter la duplication).
|
||||
- Appelle `saved_views.db.upsert(current_user.id, "tableau", name, query)`.
|
||||
- Ferme la modale et rafraîchit le menu déroulant ; affiche une confirmation
|
||||
(« Vue « <nom> » enregistrée. »).
|
||||
- Nom vide → message d'erreur inline dans la modale, pas d'enregistrement.
|
||||
|
||||
#### Appliquer
|
||||
|
||||
Callback qui remplit le menu déroulant :
|
||||
|
||||
- Déclencheurs : chargement de la page **et** signal de rafraîchissement émis
|
||||
après une sauvegarde.
|
||||
- Récupère `list_views(current_user.id, "tableau")`.
|
||||
- Rend un `dbc.DropdownMenuItem` par vue, **sous forme de lien** :
|
||||
`href=f"/tableau?{view['query']}"`.
|
||||
- Si la liste est vide, le menu n'est pas affiché (ou est désactivé avec un
|
||||
libellé « Aucune vue enregistrée »).
|
||||
|
||||
Cliquer sur un item navigue vers `/tableau?<query>` (sans rechargement, grâce à
|
||||
`dcc.Location(refresh=False)`), ce qui déclenche `restore_view_from_url`
|
||||
existant. **Aucune nouvelle logique d'application n'est nécessaire.**
|
||||
|
||||
### 3. Page de gestion `/compte/vues`
|
||||
|
||||
#### Section dans `_compte_shell.py`
|
||||
|
||||
Ajouter une entrée à `SECTIONS` :
|
||||
|
||||
```python
|
||||
{
|
||||
"key": "vues",
|
||||
"label": "Mes vues",
|
||||
"href": "/compte/vues",
|
||||
"require_subscription": True,
|
||||
},
|
||||
```
|
||||
|
||||
Cela rend automatiquement le lien visible dans la navigation de l'espace compte
|
||||
pour les abonné·es (via `visible_sections`) et active la protection d'accès.
|
||||
|
||||
#### Page `src/pages/compte_vues.py`
|
||||
|
||||
Même structure que `src/pages/compte_admin.py` :
|
||||
|
||||
```python
|
||||
def layout(**_):
|
||||
guard = account_guard("/compte/vues", require_subscription=True)
|
||||
if guard is not None:
|
||||
return guard
|
||||
contenu = _vues_section()
|
||||
return account_shell("vues", contenu)
|
||||
```
|
||||
|
||||
Contenu (`_vues_section`) :
|
||||
|
||||
- Titre « Mes vues » + courte explication.
|
||||
- **Liste** des vues (`list_views(current_user.id, "tableau")`) : pour chaque
|
||||
vue, son nom, sa date de création, un lien **« Ouvrir »** vers
|
||||
`/tableau?<query>`, un bouton **« Renommer »** et un bouton **« Supprimer »**.
|
||||
- **État vide** : message invitant à créer une vue depuis `/tableau`.
|
||||
|
||||
Actions, via callbacks pattern-matching (ids du type
|
||||
`{"type": "vue-delete", "index": view_id}`), **contrôle de propriété par
|
||||
`user_id`** dans chaque appel DB :
|
||||
|
||||
- **Supprimer** → `delete(view_id, current_user.id)`, puis rafraîchit la liste.
|
||||
- **Renommer** → champ de saisie (inline ou petite modale) →
|
||||
`rename(view_id, current_user.id, new_name)`, puis rafraîchit la liste.
|
||||
|
||||
### 4. Sécurité
|
||||
|
||||
- Le masquage des contrôles sur `/tableau` est **cosmétique** ; la garantie
|
||||
réelle est le contrôle serveur dans chaque callback d'écriture
|
||||
(`current_user_has_subscription()`) et la présence de `user_id` dans tous les
|
||||
`WHERE` des opérations DB (lecture comme écriture).
|
||||
- `/compte/vues` est protégée par `account_guard(..., require_subscription=True)`
|
||||
comme les autres sections réservées.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- `/titulaire` et `/acheteur` : ces pages ne gèrent pas encore les paramètres
|
||||
d'URL ni le partage. La colonne `table_name` réserve la place pour les y
|
||||
étendre plus tard, sans UI ni callback dédiés aujourd'hui.
|
||||
- Aucune modification du partage d'URL existant (« Partager la vue ») ni de la
|
||||
persistance localStorage de la DataTable.
|
||||
- Pas de partage d'une vue sauvegardée entre comptes, ni de vues publiques.
|
||||
- La taille de page et la page courante ne font pas partie d'une vue (cohérent
|
||||
avec le partage existant).
|
||||
|
||||
## Tests
|
||||
|
||||
`uv run pytest`
|
||||
|
||||
### Tests unitaires DB (`src/saved_views/db.py`)
|
||||
|
||||
- `upsert` crée une vue ; `list_views` la retourne.
|
||||
- `upsert` avec un `(user_id, table_name, name)` existant **écrase** `query` et
|
||||
met à jour `updated_at` (pas de doublon, pas d'erreur).
|
||||
- `rename` / `delete` n'affectent que les vues du bon `user_id` (isolation entre
|
||||
comptes).
|
||||
- La suppression d'un·e utilisateur·ice supprime ses vues en cascade
|
||||
(`ON DELETE CASCADE`).
|
||||
|
||||
### Tests de gating
|
||||
|
||||
- Le callback d'affichage de `saved-views-bar` renvoie un style masqué pour un·e
|
||||
non-abonné·e et visible pour un·e abonné·e (en s'appuyant sur
|
||||
`current_user_has_subscription()`).
|
||||
- Le callback de sauvegarde refuse l'écriture (no-update) sans abonnement.
|
||||
- `/compte/vues` redirige un·e non-abonné·e (comportement `account_guard`, déjà
|
||||
couvert par le motif existant).
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
# Charte graphique des boutons — design
|
||||
|
||||
Date : 2026-06-30
|
||||
Statut : validé (charte), à implémenter
|
||||
|
||||
## Problème
|
||||
|
||||
Le thème Bootstrap **Simplex** dérive ses couleurs contextuelles à partir de la
|
||||
couleur primaire. La primaire de decp.info étant un terracotta chaud
|
||||
(`rgb(179, 56, 33)` = `#b33821`), Simplex calcule un **`danger` mauve** (`#9b479f`)
|
||||
et un **`secondary` gris très clair**. Résultat constaté sur `/compte/vues` :
|
||||
|
||||
- bouton **Renommer** (`color="secondary", outline=True`) : gris clair sur fond
|
||||
blanc, quasi illisible ;
|
||||
- bouton **Supprimer** (`color="danger", outline=True`) : mauve, sans rapport
|
||||
sémantique avec une action destructive.
|
||||
|
||||
Les couleurs Simplex ne servent donc pas la lisibilité ni la sémantique. Il faut
|
||||
**redéfinir les styles `primary`, `secondary` et `danger`** des boutons et établir
|
||||
une charte claire où **chaque style a une fonction**.
|
||||
|
||||
## Principe directeur
|
||||
|
||||
**La couleur encode la fonction (sémantique) ; le remplissage encode l'emphase.**
|
||||
|
||||
Trois rôles seulement. Le code applicatif continue d'écrire
|
||||
`color="primary|secondary|danger"` + `outline=True|False` exactement comme avant —
|
||||
le CSS ne fait que restyler ces classes. Aucun changement d'API, aucun helper.
|
||||
|
||||
## La charte
|
||||
|
||||
| Style (dbc) | Fonction | Apparence |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `primary` plein | **L'**action principale validante d'un contexte — idéalement une par écran (Rechercher, Enregistrer, S'abonner, Envoyer) | terracotta plein `#b33821`, texte blanc (dégradé existant conservé) |
|
||||
| `primary` + `outline` | action affirmative de moindre emphase (quand le plein serait trop lourd) | bord + texte terracotta, fond transparent ; survol = plein terracotta texte blanc |
|
||||
| `secondary` + `outline` | action **neutre / alternative** (Renommer, Annuler, navigation) | gris ardoise — bord `#5a6570`, texte `#344054`, fond transparent ; survol = fond gris très clair |
|
||||
| `danger` + `outline` | action **destructive** dans une liste ou le flux courant (Supprimer, Se désabonner) | rouge `#c0392b` — bord + texte, fond transparent ; survol = plein rouge texte blanc |
|
||||
| `danger` plein | **confirmation finale** destructive (dans une modale) | rouge plein `#c0392b`, texte blanc |
|
||||
|
||||
### Règles d'usage
|
||||
|
||||
1. **Une seule action `primary` pleine par contexte.** Tout le reste est `outline`
|
||||
ou `secondary`.
|
||||
2. **Le destructif est `outline` par défaut** ; seul le bouton de confirmation
|
||||
finale (modale) est plein rouge.
|
||||
3. **`secondary` est le défaut neutre** — remplace les boutons gris clair
|
||||
illisibles sur fond blanc.
|
||||
|
||||
## Palette
|
||||
|
||||
Quatre valeurs ajoutées/confirmées ; le reste hérité de Simplex.
|
||||
|
||||
```
|
||||
terracotta #b33821 primary (chaud, marque) — existant
|
||||
ardoise texte #344054 / bord #5a6570 secondary (froid, neutre)
|
||||
rouge #c0392b danger (chaud saturé, distinct du terracotta)
|
||||
disabled bord #ccc / texte #666 état désactivé — existant, conservé
|
||||
```
|
||||
|
||||
Le rouge `#c0392b` est volontairement plus saturé et légèrement plus froid que le
|
||||
terracotta `#b33821`, pour rester distinguable à côté d'un bouton primaire.
|
||||
|
||||
## Périmètre
|
||||
|
||||
### Dans le périmètre
|
||||
|
||||
- Override CSS des classes **boutons uniquement** dans `src/assets/css/style.css` :
|
||||
- `.btn-primary` (harmonisation du dégradé déjà présent)
|
||||
- `.btn-outline-primary`
|
||||
- `.btn-secondary` et `.btn-outline-secondary`
|
||||
- `.btn-danger` et `.btn-outline-danger`
|
||||
- états `:hover`, `:focus-visible`, `:disabled` correspondants
|
||||
- Audit des boutons existants (~46 occurrences `color=…`, dont 8 `outline=True`)
|
||||
pour corriger les cas qui ne respectent pas la charte, en particulier :
|
||||
- `src/saved_views/ui.py` : Renommer (`secondary`+outline), Supprimer
|
||||
(`danger`+outline) — déjà conformes en intention, à revalider visuellement ;
|
||||
- vérifier qu'aucun écran n'a deux `primary` pleins concurrents ;
|
||||
- vérifier que les confirmations destructives en modale sont en `danger` plein
|
||||
et les déclencheurs en liste en `danger` outline.
|
||||
|
||||
### Hors périmètre (inchangé)
|
||||
|
||||
- Les variables racine `--bs-primary`, `--bs-danger`, `--bs-secondary`, etc. **ne
|
||||
sont pas modifiées.** L'override cible exclusivement les sélecteurs `.btn-*`.
|
||||
Conséquence : `dbc.Alert`, badges, `text-danger`, etc. conservent la sémantique
|
||||
Simplex.
|
||||
- `info` / `success` / `warning` : utilisés quasi exclusivement par des
|
||||
`dbc.Alert` (bleu / vert / ambre). On garde la sémantique Simplex pour les
|
||||
alertes. Aucun bouton `info`/`success`/`warning` n'est restylé.
|
||||
- `light` (1 bouton, navbar `src/app.py`) et `link` (1 bouton,
|
||||
`src/saved_views/ui.py`) : laissés tels quels, hors charte des trois rôles.
|
||||
|
||||
## Approche d'implémentation
|
||||
|
||||
**Retenue : CSS-only + audit.**
|
||||
|
||||
Override des classes `.btn-*` dans `style.css`, puis passe de revue des boutons
|
||||
existants. Minimal, sans churn d'API, respecte le fonctionnement de
|
||||
`dash-bootstrap-components`.
|
||||
|
||||
**Écartée : helper Python encapsulant `dbc.Button`.** Plus invasif (toucher tous
|
||||
les appels), aucun bénéfice ici puisque la charte se mappe exactement sur les
|
||||
`color`/`outline` natifs.
|
||||
|
||||
## Détails CSS à respecter
|
||||
|
||||
- Scoper aux sélecteurs `.btn-*` pour ne pas toucher alertes/badges.
|
||||
- Gérer explicitement `:hover`, `:focus-visible` (anneau de focus visible —
|
||||
accessibilité clavier) et `[disabled]`.
|
||||
- Conserver le `border-radius: 3px` et la typographie (`Inter`, poids 400) déjà en
|
||||
place pour `.btn-primary`.
|
||||
- Attention à la spécificité : le `.btn-primary` actuel est ciblé via
|
||||
`button.btn.btn-primary`. Aligner la spécificité des nouvelles règles pour
|
||||
éviter qu'elles s'annulent avec celles de `bootstrap.simplex.css`.
|
||||
- Respecter `prefers-reduced-motion` si des transitions de survol sont ajoutées.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Sur `/compte/vues` : « Renommer » lisible (gris ardoise) et « Supprimer »
|
||||
clairement rouge, plus aucun mauve ni gris clair illisible.
|
||||
- Les trois rôles sont visuellement distincts (terracotta / ardoise / rouge) et
|
||||
cohérents sur toutes les pages.
|
||||
- Les `dbc.Alert` (info/success/warning) sont inchangées.
|
||||
- Focus clavier visible sur tous les boutons.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Vote pour les prochaines fonctionnalités (Roadmap) — Design
|
||||
|
||||
Issue : [#94](https://github.com/ColinMaudry/decp.info/issues/94)
|
||||
Date : 2026-06-30
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre aux abonnés de voter pour les prochaines fonctionnalités depuis une
|
||||
section Roadmap réservée aux abonnés (`/compte/roadmap`). La même roadmap est
|
||||
exposée en lecture seule au public (`/a-propos/roadmap`). Les fonctionnalités
|
||||
sont gérées dans GitHub via des labels ; le changelog du dépôt est affiché en
|
||||
bas des deux pages.
|
||||
|
||||
## Modèle d'attribution des votes
|
||||
|
||||
- Un nouvel abonné reçoit **2 votes** au moment où sa **période d'essai se
|
||||
termine** (passage du statut `trial`/pending à `active`).
|
||||
- Il gagne ensuite **+1 vote par semaine** tant que son abonnement est **actif**.
|
||||
- Les votes ne s'accumulent **pas** pendant une période sans abonnement (gel).
|
||||
- Au **réabonnement**, l'accumulation reprend mais **les 2 votes initiaux ne
|
||||
sont pas re-crédités**.
|
||||
- Un abonné peut voter **plusieurs fois** pour la même fonctionnalité.
|
||||
- Un vote dépensé est **définitif** : pas de retrait possible.
|
||||
|
||||
### Accumulation paresseuse (pas de cronjob)
|
||||
|
||||
État stocké sur la table `subscriptions` (2 colonnes ajoutées par migration) :
|
||||
|
||||
| Colonne | Type | Rôle |
|
||||
| ---------------------- | ---------------------------- | ---------------------------------------------------- |
|
||||
| `votes_balance` | `INTEGER NOT NULL DEFAULT 0` | Solde de votes dépensable |
|
||||
| `votes_credited_until` | `TEXT` (NULL par défaut) | Curseur d'accumulation ; NULL tant que jamais activé |
|
||||
|
||||
Fonction `credit_pending(user_id)`, appelée **au chargement de
|
||||
`/compte/roadmap` et avant chaque vote** :
|
||||
|
||||
1. Charger la ligne d'abonnement de l'utilisateur. Si absente → ne rien faire.
|
||||
2. Si `votes_credited_until` est NULL **et** statut `active` (= fin d'essai
|
||||
atteinte) → créditer les **+2 initiaux**, `votes_credited_until = maintenant`.
|
||||
3. Si `votes_credited_until` posé **et** statut `active` →
|
||||
`semaines = floor((maintenant − votes_credited_until) / 7 jours)` ;
|
||||
si `semaines > 0` : `votes_balance += semaines` et avancer
|
||||
`votes_credited_until` de `semaines × 7 jours`.
|
||||
4. Statut non-`active` → aucun crédit (gel).
|
||||
|
||||
Cette fonction est **idempotente** : recharger la page le même jour ne crédite
|
||||
rien de plus, car le curseur n'avance que par semaines pleines.
|
||||
|
||||
### Gel au désabonnement / réabonnement
|
||||
|
||||
Dans `update_from_webhook` (`src/subscriptions/db.py`) :
|
||||
|
||||
- **Résiliation** (`active` → `cancelled`) : appeler `credit_pending` pour
|
||||
banquer les semaines acquises ; le statut `cancelled` bloque ensuite tout
|
||||
crédit (le curseur reste figé).
|
||||
- **Réabonnement** (`cancelled` → `active`) : remettre
|
||||
`votes_credited_until = maintenant` pour ne pas créditer la période sans
|
||||
abonnement, **sans re-créditer les +2** (curseur non-NULL).
|
||||
|
||||
## Registre des votes émis
|
||||
|
||||
Nouvelle table dans `users.sqlite` :
|
||||
|
||||
```sql
|
||||
CREATE TABLE feature_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- Une ligne par vote émis (vote multiple = plusieurs lignes).
|
||||
- Décompte d'une fonctionnalité = `COUNT(*)` groupé par `issue_number`.
|
||||
- Décomptes **publics** (visibles sur `/a-propos/roadmap`).
|
||||
|
||||
Migrations ajoutées dans `src/migrations.py` (`_MIGRATIONS`) :
|
||||
|
||||
- ajout de `votes_balance` et `votes_credited_until` sur `subscriptions`
|
||||
- création de la table `feature_votes`
|
||||
|
||||
## Intégration GitHub & cache
|
||||
|
||||
Nouveau module `src/utils/roadmap.py` :
|
||||
|
||||
- `fetch_roadmap_issues()`, décoré `@cache.memoize(timeout=3600)` (cache 1 h) :
|
||||
`httpx.get` sur `GET /repos/ColinMaudry/decp.info/issues?state=open`, puis
|
||||
filtrage local par label. Retourne deux listes de dicts
|
||||
`{number, title, html_url}` : les `en cours` et les `mis au vote`.
|
||||
- **Appel anonyme** (pas de `GITHUB_TOKEN`) : suffisant pour un dépôt public
|
||||
avec un cache d'1 heure.
|
||||
- On reste sur `httpx`, déjà dépendance du projet (Dash, `src/utils/data.py`,
|
||||
`src/utils/tracking.py`) — pas d'ajout de `requests`.
|
||||
|
||||
`src/utils/roadmap.py` centralise aussi :
|
||||
|
||||
- la récupération des décomptes (`COUNT` groupé depuis `feature_votes`) ;
|
||||
- un constructeur de composants `render_roadmap(editable: bool)` partagé par les
|
||||
deux pages.
|
||||
|
||||
## Pages & navigation
|
||||
|
||||
### Page abonné — `src/pages/compte_roadmap.py`
|
||||
|
||||
- Route `/compte/roadmap`, `require_subscription=True`, via `account_shell`.
|
||||
- À l'entrée : `credit_pending(user_id)`.
|
||||
- Contenu :
|
||||
1. Bandeau « Il te reste **N** votes » (solde courant).
|
||||
2. Section **« En cours »** (label `en cours`) — titres liés vers GitHub, pas
|
||||
de vote.
|
||||
3. Section **« Au vote »** (label `mis au vote`) — triée par votes
|
||||
décroissants ; chaque fonctionnalité affiche son décompte + un bouton
|
||||
« Voter » (désactivé si solde = 0).
|
||||
4. **Changelog** (`CHANGELOG.md` rendu via `dcc.Markdown`).
|
||||
|
||||
Action de vote : vérifier `votes_balance > 0`, décrémenter `votes_balance`,
|
||||
insérer une ligne `feature_votes`, rafraîchir l'affichage.
|
||||
|
||||
### Page publique — `src/pages/a_propos/roadmap.py`
|
||||
|
||||
- Route `/a-propos/roadmap`, via `apropos_shell`.
|
||||
- Identique mais `render_roadmap(editable=False)` : décomptes publics visibles,
|
||||
**aucun bouton**, pas de bandeau de solde.
|
||||
|
||||
### Navigation
|
||||
|
||||
- Ajout d'une entrée `roadmap` dans `SECTIONS` de `_compte_shell.py`
|
||||
(`require_subscription=True`).
|
||||
- Ajout d'une entrée `roadmap` dans `SECTIONS` de `_apropos_shell.py`.
|
||||
|
||||
## Lien version & changelog
|
||||
|
||||
- Dans `src/app.py` (~ligne 194), le lien du numéro de version pointe vers
|
||||
`/a-propos/roadmap` au lieu de l'URL GitHub du `CHANGELOG.md`.
|
||||
- Lecture de `CHANGELOG.md` (racine du dépôt) rendue via `dcc.Markdown`,
|
||||
partagée par les deux pages.
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Pas de cronjob / timer pour l'accumulation.
|
||||
- Pas de retrait de vote.
|
||||
- Pas de `GITHUB_TOKEN`.
|
||||
- Pas de gestion d'écriture vers GitHub (les fonctionnalités restent gérées
|
||||
manuellement via les labels GitHub).
|
||||
@@ -0,0 +1,210 @@
|
||||
# Handle d'abonnement personnalisé + historique des abonnements — design
|
||||
|
||||
- **Contexte** : suite de [2026-06-25-frisbii-abonnements-design.md](2026-06-25-frisbii-abonnements-design.md)
|
||||
- **Date** : 2026-07-01
|
||||
|
||||
## Objectif
|
||||
|
||||
Remplacer le paramètre `generate_handle: true` (Frisbii génère le handle de
|
||||
l'abonnement) par un handle choisi par colibre, préfixé `abo`, unique. Générer
|
||||
ce handle nécessite de pouvoir consulter l'historique des abonnements d'un
|
||||
utilisateur — ce qui n'est pas possible avec le schéma actuel de
|
||||
`subscriptions`, qui n'a qu'une ligne par utilisateur (`user_id PRIMARY KEY`),
|
||||
écrasée à chaque nouvelle souscription. Ce design scinde donc `subscriptions`
|
||||
en un historique multi-lignes et introduit une table séparée pour l'état
|
||||
cumulatif de l'utilisateur (votes, essai), en plus du changement de handle.
|
||||
|
||||
## Décisions clés
|
||||
|
||||
1. **Format du handle : `abo-{user_id}-{N}`**, `N` incrémental par
|
||||
utilisateur (ex. `abo-42-1`, puis `abo-42-2` en cas de résiliation puis
|
||||
réabonnement). Calculé en scannant les handles déjà utilisés par cet
|
||||
utilisateur dans `subscriptions` (`LIKE 'abo-{user_id}-%'`) et en prenant
|
||||
le suffixe max + 1.
|
||||
2. **`subscriptions` devient un historique multi-lignes** (une ligne par
|
||||
tentative d'abonnement Frisbii), `id` auto-incrémenté comme clé primaire,
|
||||
`user_id` non-unique.
|
||||
3. **Nouvelle table `subscriber_state`** (1 ligne par utilisateur) pour l'état
|
||||
cumulatif indépendant du cycle de vie d'un abonnement particulier
|
||||
(`trial_used`, `votes_balance`, `votes_last_credited_at`) — ces colonnes
|
||||
n'ont pas de sens sur une ligne d'historique précise. Alternative écartée :
|
||||
les ajouter à `users` (mélangerait identité et logique d'abonnement/billing
|
||||
dans `auth/db.py`, qui doit rester focalisé sur l'identité).
|
||||
4. **Le handle est écrit par colibre à la création, pas par le webhook.**
|
||||
Contrairement au comportement actuel (Frisbii génère le handle, on
|
||||
l'apprend via le webhook), colibre choisit `abo-{user_id}-{N}` et
|
||||
l'enregistre dans `subscriptions.frisbii_subscription_handle` **avant**
|
||||
d'appeler l'API Frisbii. Le webhook ne fait plus que lire/matcher sur ce
|
||||
handle (`get_by_handle`), jamais l'écrire.
|
||||
- Ordre retenu : écrire chez nous d'abord, appeler Frisbii ensuite. En cas
|
||||
d'échec de l'appel Frisbii, on se retrouve avec une ligne locale
|
||||
`pending`→`failed` dont le handle n'a jamais existé côté Frisbii — c'est
|
||||
inoffensif (voir décision 5). L'ordre inverse (Frisbii d'abord) est plus
|
||||
risqué : un succès Frisbii suivi d'un échec d'écriture locale laisserait
|
||||
un abonnement réel et potentiellement payant chez Frisbii sans aucune
|
||||
trace côté colibre, avec un risque de collision de handle au prochain
|
||||
essai.
|
||||
5. **Statut `failed`** pour distinguer un échec propre côté Frisbii (juste
|
||||
après `create_pending`) d'un abonnement réellement `pending` chez Frisbii
|
||||
(session créée, paiement pas encore confirmé). Sans ce statut, l'écran
|
||||
`/compte/abonnement` affiche `_active_view` dès qu'une ligne existe pour
|
||||
l'utilisateur (peu importe le statut), ce qui bloquerait indéfiniment le
|
||||
bouton « S'abonner » derrière un écran « Ajouter une méthode de paiement »
|
||||
pour un abonnement fantôme, et « Me désabonner » échouerait aussi (handle
|
||||
inconnu de Frisbii). La ligne `failed` reste en base (pas de suppression)
|
||||
pour que `abo-{user_id}-{N}` ne réutilise jamais ce suffixe — protection
|
||||
utile en cas d'échec réseau ambigu (timeout ne garantit pas que la requête
|
||||
n'a pas été traitée côté Frisbii).
|
||||
6. **Chaque tentative de souscription crée une nouvelle ligne** (pas de
|
||||
réutilisation d'une ligne `pending` existante). Chaque clic sur
|
||||
« S'abonner » qui atteint `create_subscription_session` crée un véritable
|
||||
nouvel objet abonnement chez Frisbii ; réutiliser une ligne locale
|
||||
reviendrait à réutiliser un handle déjà proposé à Frisbii pour un objet
|
||||
différent.
|
||||
|
||||
## Schéma
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
frisbii_customer_handle TEXT,
|
||||
frisbii_subscription_handle TEXT,
|
||||
plan TEXT,
|
||||
prix_ht REAL,
|
||||
status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired' | 'failed'
|
||||
current_period_end TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
|
||||
CREATE UNIQUE INDEX idx_subscriptions_handle ON subscriptions(frisbii_subscription_handle);
|
||||
|
||||
CREATE TABLE subscriber_state (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
trial_used INTEGER NOT NULL DEFAULT 0,
|
||||
votes_balance INTEGER NOT NULL DEFAULT 0,
|
||||
votes_last_credited_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
`ALTER TABLE` ne permet ni de retirer `user_id` comme clé primaire ni de
|
||||
répartir des colonnes vers une nouvelle table. Migration par reconstruction de
|
||||
table, sur le modèle de `_rebuild_users_password_nullable` dans
|
||||
`src/auth/db.py:86` : fonction Python dédiée (pas une entrée de la liste
|
||||
générique `_MIGRATIONS`, qui n'exécute qu'une instruction SQL simple), guard
|
||||
d'idempotence sur `PRAGMA table_info(subscriptions)` (absence de la colonne
|
||||
`id`), transaction unique :
|
||||
|
||||
1. `CREATE TABLE subscriptions_new` (nouveau schéma).
|
||||
2. `INSERT INTO subscriptions_new (...) SELECT user_id, frisbii_customer_handle, ... FROM subscriptions` (les anciennes lignes deviennent l'historique, un `id` frais leur est attribué).
|
||||
3. `INSERT INTO subscriber_state (user_id, trial_used, votes_balance, votes_last_credited_at, updated_at) SELECT ... FROM subscriptions` (depuis l'ancienne table, avant le `DROP`).
|
||||
4. `DROP TABLE subscriptions`, `ALTER TABLE subscriptions_new RENAME TO subscriptions`, recréation des index.
|
||||
|
||||
Appelée depuis `subscriptions/db.py:init_schema()`, `PRAGMA foreign_keys = OFF`
|
||||
pendant le `DROP`/`RENAME` (cascade), comme dans le précédent auth.
|
||||
|
||||
## API `subscriptions/db.py`
|
||||
|
||||
- `get_by_user` → **`get_current(user_id) -> Row | None`** : dernière ligne
|
||||
(`ORDER BY id DESC LIMIT 1`). Remplace tous les usages actuels de
|
||||
« l'abonnement de l'utilisateur ».
|
||||
- `get_by_customer` → **`customer_known(customer_handle) -> bool`** : test
|
||||
d'existence seul (un `frisbii_customer_handle` peut apparaître sur
|
||||
plusieurs lignes d'historique).
|
||||
- **`get_by_handle(subscription_handle) -> Row | None`** (nouveau) : résout la
|
||||
ligne exacte à mettre à jour depuis un webhook.
|
||||
- **`_next_handle(user_id) -> str`** (nouveau, privé) :
|
||||
```python
|
||||
def _next_handle(user_id: int) -> str:
|
||||
prefix = f"abo-{user_id}-"
|
||||
rows = get_conn().execute(
|
||||
"SELECT frisbii_subscription_handle FROM subscriptions "
|
||||
"WHERE user_id = ? AND frisbii_subscription_handle LIKE ?",
|
||||
(user_id, f"{prefix}%"),
|
||||
).fetchall()
|
||||
n = max(
|
||||
(int(r[0][len(prefix):]) for r in rows if r[0][len(prefix):].isdigit()),
|
||||
default=0,
|
||||
)
|
||||
return f"{prefix}{n + 1}"
|
||||
```
|
||||
- **`create_pending(user_id, customer_handle, plan, prix_ht=None) -> tuple[str, int]`** :
|
||||
génère le handle via `_next_handle`, **INSERT** une nouvelle ligne
|
||||
(`status='pending'`, `frisbii_subscription_handle` déjà renseigné), renvoie
|
||||
`(handle, subscription_id)`.
|
||||
- **`mark_failed(subscription_id) -> None`** (nouveau) :
|
||||
`UPDATE subscriptions SET status='failed', updated_at=? WHERE id=?`. Pas de
|
||||
logique de crédit de votes (rien n'a jamais été actif), contrairement à
|
||||
`set_cancelled`.
|
||||
- **`update_from_webhook(subscription_handle, status, current_period_end)`** :
|
||||
signature simplifiée (retrait de `customer_handle`), cible la ligne via
|
||||
`get_by_handle`. `trial_used` déplacé vers `subscriber_state`.
|
||||
- **`set_cancelled(subscription_id, current_period_end)`** : opère par `id`
|
||||
de ligne (plus par `user_id`) — `routes.cancel()` récupère déjà la ligne via
|
||||
`get_current`, lui passe `row["id"]`.
|
||||
- `has_active_subscription`, `has_used_trial`, `credit_pending`, `spend_vote`,
|
||||
`next_recharge_at`, `freeze_votes_cursor` : signatures inchangées, mais
|
||||
relus/écrits sur `subscriber_state` pour le solde/l'essai. `credit_pending`
|
||||
reste la seule fonction à toucher aux deux tables (lit le statut courant sur
|
||||
`subscriptions` via `get_current`, écrit le solde sur `subscriber_state`).
|
||||
`has_used_trial`/les fonctions de vote tolèrent l'absence de ligne
|
||||
`subscriber_state` (première interaction de l'utilisateur : valeurs par
|
||||
défaut, pas d'erreur).
|
||||
|
||||
## `client.py`
|
||||
|
||||
`create_subscription_session` reçoit un paramètre `handle: str` obligatoire ;
|
||||
remplace `"generate_handle": True` par `"handle": handle` dans le corps de la
|
||||
requête `POST /v1/subscription`.
|
||||
|
||||
## `routes.py`
|
||||
|
||||
- `subscribe()` : appelle `db.create_pending(...)` → `(handle, subscription_id)`,
|
||||
passe `handle` à `client.create_subscription_session(..., handle=handle)`.
|
||||
Dans le `except client.FrisbiiError`, appelle `db.mark_failed(subscription_id)`
|
||||
avant de rediriger vers `?error=frisbii`.
|
||||
- `cancel()` : récupère la ligne via `get_current`, passe `row["id"]` à
|
||||
`set_cancelled`.
|
||||
- `webhook()` : garde existant basé sur `customer_known(customer)` ; appelle
|
||||
`update_from_webhook(sub_handle, status, current_period_end)` (sans
|
||||
`customer_handle`).
|
||||
|
||||
## `compte_abonnement.py`
|
||||
|
||||
`layout()` : `if row is not None:` → `if row is not None and row["status"] != "failed":`
|
||||
pour qu'une ligne `failed` ne bloque plus l'affichage de `_plan_cards` (retour
|
||||
au formulaire de souscription).
|
||||
|
||||
## Sites d'appel à migrer
|
||||
|
||||
`get_by_user`/`get_by_customer` sont utilisés dans :
|
||||
`src/pages/compte_roadmap.py`, `src/pages/_compte_shell.py`,
|
||||
`src/pages/compte_abonnement.py`, `src/auth/routes.py` — renommage mécanique
|
||||
vers `get_current`. `tests/subscriptions/test_db.py` et `test_routes.py`
|
||||
nécessitent une réécriture significative (nouvelles signatures, statut
|
||||
`failed`, table `subscriber_state`).
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- **Nettoyage automatique des lignes `pending`/`failed` orphelines** (essais
|
||||
de souscription abandonnés ou échoués). Inoffensif : ne bloque pas la
|
||||
réinscription, ne crée pas de collision de handle. Pas de cron de
|
||||
réconciliation pour l'instant — cohérent avec la décision « pas de
|
||||
réconciliation périodique automatique » du design initial.
|
||||
- **Scoping des colonnes des `SELECT *`** dans `subscriptions/db.py` /
|
||||
`auth/db.py` (évoqué en discussion, explicitement écarté de cette tâche).
|
||||
- **Vérifier une vraie méthode de paiement (Frisbii) avant d'afficher le
|
||||
bandeau « Ajouter une méthode de paiement »** dans `_active_view` pour
|
||||
`status == "pending"`. Aujourd'hui le bandeau s'affiche pour tout `pending`,
|
||||
y compris juste après un ajout de méthode de paiement via
|
||||
`/subscriptions/add-payment` si le webhook n'a pas encore fait passer le
|
||||
statut à `trial`/`active`. `client.get_customer_payment_methods` existe déjà
|
||||
pour ça. Orthogonal à ce design (ne dépend d'aucune décision ci-dessus) —
|
||||
traité comme tâche de suivi séparée, avec son propre petit design (gestion
|
||||
d'erreur API, message si paiement déjà présent mais webhook en retard).
|
||||
@@ -0,0 +1,155 @@
|
||||
# Panneau admin — éditeur générique de tables (`/admin`)
|
||||
|
||||
Date : 2026-07-03
|
||||
Statut : design validé
|
||||
Remplace : [2026-07-03-admin-ui-design.md](2026-07-03-admin-ui-design.md) (pages dédiées liste/détail/journal + formulaire de
|
||||
changement de statut) — abandonné avant merge sur `main` au profit de ce design.
|
||||
|
||||
## Contexte
|
||||
|
||||
Le design précédent (pages `/admin`, `/admin/user/<id>`, `/admin/journal` + un
|
||||
formulaire dédié pour changer un statut d'abonnement) a été entièrement
|
||||
implémenté et revu (9 tâches, revue finale "ready to merge"), mais **jamais
|
||||
mergé sur `main`**. Avant la fusion, il est apparu qu'ajouter une page dédiée
|
||||
à chaque nouveau besoin de support serait trop lent à faire évoluer. `dash_table.DataTable`
|
||||
supporte l'édition de cellule (`editable=True`), ce qui permet une approche
|
||||
plus générique : une seule page `/admin` avec un menu déroulant pour choisir
|
||||
la table SQLite à afficher, filtrer nativement, et éditer directement les
|
||||
cellules.
|
||||
|
||||
## Portée
|
||||
|
||||
Trois tables du schéma `users.sqlite` sont éditables : `users` (hors
|
||||
`password_hash`, totalement exclue), `subscriptions`, `subscriber_state`.
|
||||
Une quatrième table, `admin_actions` (journal d'audit), est consultable dans
|
||||
le même sélecteur mais **en lecture seule**. Toute autre table du schéma
|
||||
(`email_verification_tokens`, `password_reset_tokens`, `oauth_identities`,
|
||||
`saved_views`, `feature_votes`) est hors périmètre — pas dans le sélecteur.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Fichiers
|
||||
|
||||
- **`src/pages/admin/liste.py`** (réécrit) : page unique `/admin` — menu
|
||||
déroulant de sélection de table + `dash_table.DataTable` unique, editable,
|
||||
`filter_action="native"`, `sort_action="native"`, `page_action="native"`,
|
||||
`page_size=20`.
|
||||
- **Supprimés** : `src/pages/admin/detail.py`, `src/pages/admin/journal.py`,
|
||||
`src/admin/routes.py` (le blueprint Flask du formulaire de changement de
|
||||
statut — plus de formulaire, l'édition passe par un callback Dash).
|
||||
- **`src/pages/admin/_shell.py`** simplifié : `admin_nav()` supprimé (une
|
||||
seule page, plus de navigation entre sous-pages) ; `not_admin()` conservé
|
||||
à l'identique.
|
||||
- **`src/admin/guard.py`** (`is_admin()`) : inchangé, réutilisé tel quel.
|
||||
- **`src/admin/db.py`** (`log_action`, `list_actions`) : inchangé, réutilisé
|
||||
pour l'audit des éditions de cellule.
|
||||
- **Nouveau `src/admin/tables.py`** : registre des tables autorisées et
|
||||
fonction générique d'écriture.
|
||||
|
||||
### Registre des tables (`src/admin/tables.py`)
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TableConfig:
|
||||
label: str
|
||||
columns: list[str] # colonnes affichées, dans l'ordre
|
||||
editable_columns: set[str] # sous-ensemble de columns
|
||||
pk: str
|
||||
column_types: dict[str, type] # int | float | str, pour les colonnes éditables
|
||||
dropdowns: dict[str, list[str]] # colonne -> valeurs autorisées (optionnel)
|
||||
target_user_id: Callable[[dict], int | None] # dérive le user_id à loguer depuis une ligne
|
||||
```
|
||||
|
||||
| Table | Colonnes affichées | Éditables | Contraintes |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `users` | id, email, email_verified, siret, pending_email, created_at, updated_at (`password_hash` exclue) | email, email_verified, siret, pending_email | `email_verified` : dropdown 0/1 |
|
||||
| `subscriptions` | id, user_id, frisbii_customer_handle, frisbii_subscription_handle, plan, prix_ht, status, current_period_end, created_at, updated_at | plan, prix_ht, status, current_period_end | `status` : dropdown `SUBSCRIPTION_STATUSES` ; `plan` : dropdown clés de `PLANS` |
|
||||
| `subscriber_state` | user_id, trial_used, votes_balance, votes_last_credited_at, updated_at | trial_used, votes_balance, votes_last_credited_at | `trial_used` : dropdown 0/1 |
|
||||
| `admin_actions` | id, admin_email, action, target_user_id, details, created_at | _(aucune)_ | — |
|
||||
|
||||
Règles fixes, non contournables par la config : la clé primaire et les
|
||||
colonnes `created_at`/`updated_at` ne sont jamais dans `editable_columns`.
|
||||
Les handles Frisbii (`frisbii_customer_handle`, `frisbii_subscription_handle`)
|
||||
et `user_id` (FK) sont explicitement exclus de l'édition — modifier un
|
||||
handle désynchroniserait silencieusement l'état réel côté Frisbii sans rien
|
||||
signaler ; modifier `user_id` casserait le rattachement à l'utilisateur.
|
||||
|
||||
`target_user_id` par table : `users` → `row["id"]`, `subscriptions` →
|
||||
`row["user_id"]`, `subscriber_state` → `row["user_id"]` (sa propre PK),
|
||||
`admin_actions` → non applicable (table en lecture seule, jamais loguée).
|
||||
|
||||
## Flux d'édition
|
||||
|
||||
1. **Callback de sélection de table** — `Input("admin-table-select", "value")` → `Output("admin-table", "data"/"columns"/"dropdown_conditional")`.
|
||||
Charge `SELECT * FROM <table>` où `<table>` est validé contre les clés de
|
||||
`TABLES` avant toute requête — jamais interpolé tel quel depuis l'UI, donc
|
||||
aucune table hors liste blanche n'est accessible même via un payload de
|
||||
callback forgé.
|
||||
2. **Callback d'édition** — `Input("admin-table", "data")`,
|
||||
`State("admin-table", "data_previous")`,
|
||||
`State("admin-table-select", "value")`. Diff ligne par ligne pour
|
||||
localiser la cellule modifiée.
|
||||
- **Revalidation côté serveur** : le callback ignore tout changement sur
|
||||
une colonne absente de `editable_columns`, même si elle est présente
|
||||
dans le payload — le flag `editable` de la DataTable est une aide
|
||||
visuelle côté client, pas une garantie de sécurité. Seul un admin
|
||||
authentifié (`is_admin()` déjà en tête de page) peut atteindre ce code.
|
||||
- **Coercition de type avant écriture** : SQLite étant faiblement typé,
|
||||
écrire sans validation permettrait à du texte non convertible de finir
|
||||
silencieusement dans une colonne `REAL`/`INTEGER`. Chaque colonne
|
||||
éditable a un type attendu (`column_types`) ; une valeur qui ne
|
||||
convertit pas proprement est rejetée : la cellule revient à son
|
||||
ancienne valeur, une alerte s'affiche, rien n'est écrit.
|
||||
- Pour les colonnes à `dropdowns`, la valeur est aussi vérifiée contre la
|
||||
liste autorisée avant écriture (défense en profondeur, en plus du
|
||||
rendu en dropdown côté UI).
|
||||
- Écriture via `set_cell(table: str, pk_value, column: str, value) -> None`
|
||||
dans `src/admin/tables.py` — `UPDATE <table> SET <column> = ? WHERE <pk> = ?`.
|
||||
- Audit : `log_action(current_user.email, f"edit_{table}", target_user_id, f"{column}: {old} → {new}")`.
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
- Table hors liste blanche (requête forgée) : aucune donnée renvoyée, pas de
|
||||
crash.
|
||||
- Coercition de type échouée : cellule restaurée à l'ancienne valeur +
|
||||
`dbc.Alert` d'erreur, rien n'est écrit en base.
|
||||
- `UPDATE` touchant 0 ligne (ligne supprimée entre-temps) : alerte d'erreur,
|
||||
pas d'exception non gérée.
|
||||
|
||||
## Tests
|
||||
|
||||
- **`tests/admin/test_tables.py`** (nouveau, remplace `tests/admin/test_routes.py`) :
|
||||
table hors liste blanche rejetée, colonne non éditable rejetée même
|
||||
présente dans le payload, coercition de type (succès et échec) par type de
|
||||
colonne, écriture réussie relue en base, `admin_actions` loguée avec le
|
||||
bon `target_user_id` par table.
|
||||
- Les callbacks Dash (sélection de table, édition de cellule) sont de
|
||||
simples fonctions Python décorées — testables en les import-appelant
|
||||
directement avec des données factices, sans dispatch Dash ni serveur
|
||||
Flask.
|
||||
- **`tests/admin/test_guard.py`** : inchangé (guard non affecté par ce
|
||||
pivot).
|
||||
- **`tests/admin/test_pages.py`** : les tests anonyme/non-admin → 404 sont
|
||||
conservés à l'identique (page toujours gardée par `is_admin()`). Le test
|
||||
de flux complet est réécrit : login admin réel → `/admin` → sélection de
|
||||
"subscriptions" dans le menu déroulant → édition de la cellule `status`
|
||||
d'une ligne → vérification de la nouvelle valeur affichée et d'une ligne
|
||||
dans `admin_actions` (consultable via le même sélecteur). Même contrainte
|
||||
de nettoyage explicite de `tests/users.test.sqlite` (fichier committé,
|
||||
partagé pour toute la session de tests) qu'auparavant.
|
||||
- Supprimés : `tests/admin/test_routes.py` et toute couverture spécifique à
|
||||
`detail.py`/`journal.py`.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Toute table hors de la liste blanche (`email_verification_tokens`,
|
||||
`password_reset_tokens`, `oauth_identities`, `saved_views`,
|
||||
`feature_votes`) — pas dans le sélecteur, pas éditable.
|
||||
- `password_hash` : totalement exclue de l'affichage de `users` (ni lecture
|
||||
ni édition).
|
||||
- Ajout/suppression de lignes depuis l'éditeur — édition de cellule
|
||||
seulement, pas de création/suppression.
|
||||
- Plusieurs administrateurs (`ADMIN_EMAIL` unique, inchangé du design
|
||||
précédent).
|
||||
- Pagination de `admin_actions` au-delà de `list_actions(limit=200)`
|
||||
(inchangé du design précédent).
|
||||
@@ -0,0 +1,240 @@
|
||||
# Panneau admin interne (`/admin`)
|
||||
|
||||
Date : 2026-07-03
|
||||
Statut : **remplacé** — voir [2026-07-03-admin-table-editor-design.md](2026-07-03-admin-table-editor-design.md).
|
||||
Implémenté (9 tâches, revue finale approuvée) mais jamais mergé sur `main` ;
|
||||
abandonné avant fusion au profit d'un éditeur de tables générique.
|
||||
|
||||
## Contexte
|
||||
|
||||
Pour le débuggage et le support utilisateur, il n'existe aujourd'hui aucun
|
||||
moyen de consulter ou corriger l'état d'un compte (`users.sqlite`) sans passer
|
||||
par SQL en direct sur le serveur. On ajoute un panneau admin interne,
|
||||
accessible à une seule adresse email (variable d'env `ADMIN_EMAIL`), pour
|
||||
consulter la liste des comptes, l'historique complet des abonnements Frisbii
|
||||
d'un compte, et corriger manuellement un statut d'abonnement en cas de
|
||||
désynchronisation avec Frisbii — avec une trace de chaque action.
|
||||
|
||||
Flask-Admin a été écarté : ses `ModelView` supposent un ORM (SQLAlchemy,
|
||||
Peewee, MongoEngine) alors que `src/auth/db.py` et `src/subscriptions/db.py`
|
||||
utilisent `sqlite3` brut. Le gain de Flask-Admin (génération auto des
|
||||
formulaires/listes depuis des modèles ORM) ne s'applique donc pas ici.
|
||||
|
||||
## Architecture générale
|
||||
|
||||
Le panneau reprend le pattern déjà utilisé pour `/compte/*` (voir
|
||||
[2026-06-24-espace-compte-design.md](2026-06-24-espace-compte-design.md)) :
|
||||
des pages Dash (`register_page`) pour la lecture, un Blueprint Flask pour les
|
||||
mutations (POST + redirect + query params pour les messages), le tout sur le
|
||||
même serveur Flask (`use_pages=True`, `src/app.py:87`) où les blueprints
|
||||
enregistrés (`src/auth/routes.py:auth_bp`) coexistent avec le routage de
|
||||
pages Dash sans collision, du moment que les chemins ne se recouvrent pas.
|
||||
|
||||
### Pages Dash (lecture)
|
||||
|
||||
Nouveau package `src/pages/admin/` (miroir de `src/pages/compte/`) :
|
||||
|
||||
- `src/pages/admin/liste.py` → `/admin`
|
||||
- `src/pages/admin/detail.py` → `path_template="/admin/user/<user_id>"`,
|
||||
avec `def layout(user_id=None, **_):`. Dash injecte le segment dynamique
|
||||
comme argument nommé de `layout()`, exactement comme `compte/admin.py`
|
||||
reçoit déjà ses paramètres de query string
|
||||
(`layout(error=None, password_changed=None, ...)`,
|
||||
`src/pages/compte/admin.py:176`). C'est délibérément différent du pattern
|
||||
utilisé par `acheteur.py`/`marche.py` (`path_template` + layout statique +
|
||||
parsing de `pathname` côté client dans un callback,
|
||||
ex. `src/pages/acheteur.py:52`) : ces pages n'ont pas de contrôle d'accès
|
||||
serveur, alors qu'ici `admin_guard`/`get_user_by_id` doivent s'exécuter
|
||||
côté serveur avant le rendu, donc `user_id` doit être disponible
|
||||
synchrone dans `layout()`.
|
||||
- `src/pages/admin/journal.py` → `/admin/journal`
|
||||
|
||||
### Blueprint Flask (mutations)
|
||||
|
||||
Nouveau module `src/admin/routes.py`, `Blueprint("admin", __name__, url_prefix="/admin/actions")`, enregistré dans `src/auth/setup.py`
|
||||
(`init_auth`) juste après `app.register_blueprint(auth_bp)`. Une seule route :
|
||||
|
||||
- `POST /admin/actions/subscription-status`
|
||||
|
||||
### Garde d'accès
|
||||
|
||||
Nouveau module `src/admin/guard.py` :
|
||||
|
||||
```python
|
||||
def is_admin() -> bool:
|
||||
admin_email = os.getenv("ADMIN_EMAIL")
|
||||
return bool(
|
||||
admin_email
|
||||
and current_user.is_authenticated
|
||||
and current_user.email.lower() == admin_email.lower()
|
||||
)
|
||||
```
|
||||
|
||||
Utilisée aux deux points d'entrée :
|
||||
|
||||
- Dans chaque `layout()` Dash (`src/pages/admin/*.py`) : si `not is_admin()`,
|
||||
retourne un composant "404" simple (`html.H1("404")` — pas de redirect, pour
|
||||
ne pas laisser deviner l'existence de la route à un compte non-admin) au
|
||||
lieu du contenu de la page.
|
||||
- Dans le blueprint (`before_request` du blueprint `admin`) : si
|
||||
`not is_admin()`, `abort(404)`. Défense en profondeur — la route de mutation
|
||||
ne doit pas dépendre uniquement du fait que l'UI qui y pointe soit cachée.
|
||||
|
||||
Nouvelle variable d'env `ADMIN_EMAIL` ajoutée à `.template.env`, dans une
|
||||
nouvelle section `# Panneau admin (accès à /admin)`.
|
||||
|
||||
## Couche données
|
||||
|
||||
Pas de nouvelle table de schéma pour users/subscriptions ; une seule nouvelle
|
||||
table pour le journal d'audit (voir plus bas), ajoutée via une migration
|
||||
`src/migrations.py` comme documenté dans `CLAUDE.md`.
|
||||
|
||||
### `src/auth/db.py`
|
||||
|
||||
```python
|
||||
def list_users(limit: int = 1000) -> list[sqlite3.Row]:
|
||||
"""Tous les users, plus récents en premier, plafonné à `limit`."""
|
||||
```
|
||||
|
||||
### `src/subscriptions/db.py`
|
||||
|
||||
```python
|
||||
def list_by_user(user_id: int) -> list[sqlite3.Row]:
|
||||
"""Historique complet des abonnements d'un user, plus récent en premier."""
|
||||
# SELECT * FROM subscriptions WHERE user_id = ? ORDER BY id DESC
|
||||
|
||||
def set_status(subscription_id: int, status: str) -> None:
|
||||
"""Force le statut d'un abonnement (correction manuelle)."""
|
||||
# UPDATE subscriptions SET status = ?, updated_at = ? WHERE id = ?
|
||||
```
|
||||
|
||||
`get_current(user_id)` (déjà existant, `src/subscriptions/db.py:115`) reste
|
||||
utilisé pour identifier l'abonnement "courant" à afficher en tête de liste et
|
||||
cibler par défaut dans le formulaire de changement de statut.
|
||||
|
||||
Statuts valides (déduits de `src/subscriptions/webhooks.py:map_subscription`) :
|
||||
`active`, `trial`, `cancelled`, `expired`, `pending`. Cette liste est
|
||||
centralisée dans une constante `SUBSCRIPTION_STATUSES` (nouveau, dans
|
||||
`src/subscriptions/db.py` ou `plans.py`) réutilisée à la fois pour peupler le
|
||||
dropdown du formulaire et pour valider côté route.
|
||||
|
||||
### Table d'audit `admin_actions`
|
||||
|
||||
Migration `src/migrations.py` (id `0006_create_admin_actions`) :
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS admin_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_email TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_user_id INTEGER,
|
||||
details TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
```
|
||||
|
||||
Nouveau module `src/admin/db.py` (réutilise `get_conn()` de
|
||||
`src/auth/db.py`, même fichier `users.sqlite`) :
|
||||
|
||||
```python
|
||||
def log_action(admin_email: str, action: str, target_user_id: int | None,
|
||||
details: str | None) -> None: ...
|
||||
|
||||
def list_actions(limit: int = 200) -> list[sqlite3.Row]: ...
|
||||
```
|
||||
|
||||
## Pages
|
||||
|
||||
### `/admin` (liste)
|
||||
|
||||
- Garde d'accès en tête de `layout()`.
|
||||
- `dash_table.DataTable(filter_action="native", sort_action="native", page_action="native", page_size=20, ...)` alimenté par `list_users()` —
|
||||
filtrage/tri/pagination entièrement côté navigateur (pas de callback
|
||||
serveur), à la différence de `/tableau` dont le `filter_action="custom"`
|
||||
(`src/pages/tableau.py:74`) n'existe que pour déléguer le filtrage à DuckDB
|
||||
sur ~1,5M lignes ; ici quelques centaines de lignes au plus, le mode natif
|
||||
suffit pour les trois (20 lignes par page).
|
||||
- Colonnes : email, email vérifié, plan courant, statut courant, créé le.
|
||||
- Lien "Voir" par ligne vers `/admin/user/<id>`.
|
||||
- Lien vers `/admin/journal` dans l'en-tête de page.
|
||||
|
||||
### `/admin/user/<user_id>` (détail)
|
||||
|
||||
- Garde d'accès, puis `get_user_by_id(user_id)` ; si absent, composant "user
|
||||
introuvable" (pas une 500).
|
||||
- **Compte** : email, email vérifié, siret, créé le.
|
||||
- **État abonné** (`subscriber_state`) : votes_balance, trial_used —
|
||||
affichage seul, pas d'édition dans ce lot.
|
||||
- **Historique des abonnements** (`list_by_user`) : table triée (plus récent
|
||||
en haut), badge "actuel" sur la ligne correspondant à `get_current`.
|
||||
- **Changer le statut** : formulaire ciblant l'abonnement courant — dropdown
|
||||
des statuts valides + bouton, POST vers `/admin/actions/subscription-status`
|
||||
avec champs cachés `user_id`, `subscription_id`, et le `csrf_token` (même
|
||||
pattern que `src/pages/compte/admin.py:_csrf`).
|
||||
- Bannière succès/erreur lue depuis les query params (`status_changed=1`,
|
||||
`error=invalid_status`), même pattern que `compte/admin.py`
|
||||
(`ERROR_MESSAGES`/`SUCCESS_MESSAGES`).
|
||||
|
||||
### `/admin/journal`
|
||||
|
||||
- Garde d'accès, puis `DataTable` en lecture seule de `list_actions(limit=200)` :
|
||||
date, email admin, action, user ciblé (lien vers `/admin/user/<id>` si
|
||||
`target_user_id` non nul), détails.
|
||||
|
||||
## Route de mutation
|
||||
|
||||
`POST /admin/actions/subscription-status`, protégée par le
|
||||
`before_request` du blueprint (`is_admin()` → sinon `abort(404)`) :
|
||||
|
||||
1. Lit `user_id`, `subscription_id`, `status` du formulaire.
|
||||
2. Valide `status` contre `SUBSCRIPTION_STATUSES` → sinon redirect
|
||||
`/admin/user/<user_id>?error=invalid_status`.
|
||||
3. Vérifie que la subscription `subscription_id` appartient bien à `user_id`
|
||||
(relit la ligne via `get_current`/une lecture directe) avant d'écrire —
|
||||
empêche de modifier l'abonnement d'un autre user par ID forgé dans le
|
||||
formulaire.
|
||||
4. Capture l'ancien statut, appelle `set_status(subscription_id, status)`.
|
||||
5. `log_action(current_user.email, "subscription_status_change", user_id, f"{old_status} → {status}")`.
|
||||
6. Redirect `/admin/user/<user_id>?status_changed=1`.
|
||||
|
||||
CSRF : protection déjà globale via `CSRFProtect(app)` (`src/auth/setup.py:53`),
|
||||
aucun code supplémentaire nécessaire au-delà du champ caché habituel.
|
||||
|
||||
## Tests
|
||||
|
||||
Nouveau dossier `tests/admin/`, calqué sur les conventions existantes :
|
||||
|
||||
- **Unitaires** (`tests/admin/test_guard.py`), sur le modèle de
|
||||
`tests/test_compte_shell.py` : `is_admin()` avec `current_user` mocké
|
||||
(`patch("src.admin.guard.current_user", ...)`) — anonyme → `False`, connecté
|
||||
avec un email différent de `ADMIN_EMAIL` → `False`, email correspondant
|
||||
(insensible à la casse) → `True`, `ADMIN_EMAIL` non défini → `False`.
|
||||
- **Route de mutation** (`tests/admin/test_routes.py`), sur le modèle de
|
||||
`tests/auth/conftest.py` (`app`/`client`/`users_db_path` fixtures). Session
|
||||
admin simulée par injection directe des clés Flask-Login, comme
|
||||
`tests/subscriptions/conftest.py:95` (`logged_in_client`) :
|
||||
`sess["_user_id"] = str(uid); sess["_fresh"] = True`, avec
|
||||
`monkeypatch.setenv("ADMIN_EMAIL", ...)` et un user créé avec cet email.
|
||||
Cas couverts : statut invalide → 302 vers `?error=invalid_status` sans
|
||||
écriture ; subscription n'appartenant pas au user → refusée ; succès →
|
||||
statut mis à jour + une ligne dans `admin_actions` ; accès sans être admin
|
||||
→ 404.
|
||||
- **Pages** (`tests/admin/test_pages.py`, Selenium/`DashComposite`, sur le
|
||||
modèle de `tests/test_compte_pages.py`) : anonyme sur `/admin` → 404 (pas de
|
||||
redirection vers `/connexion`, cohérent avec la garde d'accès qui ne
|
||||
distingue pas anonyme / authentifié non-admin) ; compte non-admin
|
||||
authentifié → 404 ; admin → liste visible, navigation vers un détail,
|
||||
changement de statut reflété à l'écran et dans `/admin/journal`.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Reset de mot de passe et suppression de compte depuis l'admin (l'utilisateur
|
||||
garde ces actions en self-service via `/compte/admin`).
|
||||
- Édition de `subscriber_state` (votes_balance, trial_used).
|
||||
- Plusieurs administrateurs (`ADMIN_EMAIL` unique pour l'instant — passer à
|
||||
une liste serait un changement d'une ligne dans `is_admin()` si besoin
|
||||
futur).
|
||||
- Pagination de `/admin/journal` (reste une liste plate limitée à 200 lignes) ;
|
||||
et pagination de `/admin` au-delà de la limite fixe de `list_users()`
|
||||
(1000 users) — la pagination native ne pagine que les lignes déjà chargées,
|
||||
pas la requête SQL ; à revoir si le volume le justifie.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Cartes /acheteur et /titulaire : afficher la contrepartie (clustering)
|
||||
|
||||
## Objectif
|
||||
|
||||
Sur les fiches `/acheteurs/<id>` et `/titulaires/<id>`, la carte affiche
|
||||
aujourd'hui uniquement la position de l'organisme consulté (un point unique,
|
||||
issu de l'API annuaire, via `point_on_map`). On veut y ajouter la position de
|
||||
la **contrepartie** :
|
||||
|
||||
- sur `/acheteurs/<id>` : les titulaires avec qui cet acheteur a contracté ;
|
||||
- sur `/titulaires/<id>` : les acheteurs qui ont contracté avec ce titulaire.
|
||||
|
||||
Design et code inspirés de `/observatoire` (`get_geographic_maps` /
|
||||
`make_clusters_map` dans `src/figures.py`), notamment le clustering
|
||||
dash-leaflet. Couleurs identiques : acheteur en orange (`#E69F00`), titulaire
|
||||
en bleu ciel (`#56B4E9`).
|
||||
|
||||
## Comportement attendu
|
||||
|
||||
- La carte suit le filtre **Année** de la page (comme le tableau Top
|
||||
titulaires/acheteurs et l'histogramme de distance) — elle n'est donc plus
|
||||
calculée à partir de l'API annuaire mais des données DECP (marchés).
|
||||
- Le marqueur de l'organisme consulté et ceux de la contrepartie utilisent
|
||||
**la même source de données** (`acheteur_longitude`/`latitude` et
|
||||
`titulaire_longitude`/`latitude` des marchés), avec clustering identique à
|
||||
`/observatoire`.
|
||||
- Le cadrage de la carte s'ajuste automatiquement (fitBounds) pour englober
|
||||
l'organisme et toutes ses contreparties, avec un léger padding.
|
||||
- Si aucune coordonnée exploitable n'est disponible (aucun marché
|
||||
géolocalisé, ou dataset sans colonnes longitude/latitude), repli sur une
|
||||
vue France fixe (centre `[46.6, 2.2]`, zoom 5) plutôt qu'une zone vide.
|
||||
- Contrairement à `/observatoire`, **pas** de découpage par région/DOM-TOM ni
|
||||
de bascule chloroplèthe : une seule carte cluster, le volume de points
|
||||
d'un organisme unique restant faible.
|
||||
- La carte dash-leaflet conserve les contrôles de zoom +/- natifs de Leaflet
|
||||
(contrairement à l'actuelle carte Plotly qui n'en a pas) — comportement par
|
||||
défaut de `dl.Map`, aucune configuration spécifique nécessaire.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Nouvelle fonction partagée `build_org_markers` (`src/figures.py`)
|
||||
|
||||
Extraite du corps de `get_geographic_maps` (branche `"clusters"`), pour être
|
||||
réutilisée par `/observatoire` et par la nouvelle carte org :
|
||||
|
||||
```python
|
||||
ORG_COLORS = {
|
||||
"acheteur": "#E69F00", # orange
|
||||
"titulaire": "#56B4E9", # bleu ciel
|
||||
}
|
||||
|
||||
def build_org_markers(lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]) -> list[dict]:
|
||||
"""Regroupe les marchés par point géographique pour un type d'organisme.
|
||||
|
||||
Retourne [] si les colonnes longitude/latitude de ce type sont absentes
|
||||
du LazyFrame (ex: tests/test.parquet), sans lever d'exception.
|
||||
"""
|
||||
```
|
||||
|
||||
Le groupement (par `{org_type}_longitude`, `{org_type}_latitude`,
|
||||
`{org_type}_nom`, comptage `nb_marches`) et le format des marqueurs
|
||||
(`{"lat", "lon", "tooltip", "marker_color"}`) restent identiques à
|
||||
l'implémentation actuelle. Nouveauté : garde d'absence de colonnes via
|
||||
`lff.collect_schema().names()` (cf. `get_considerations_card_content` pour le
|
||||
même pattern) — corrige au passage un crash latent non testé de
|
||||
`get_geographic_maps` sur des jeux de données sans colonnes géo.
|
||||
|
||||
`get_geographic_maps` est mis à jour pour appeler `build_org_markers(lff, org_type)` au lieu de la boucle inline, et pour utiliser `ORG_COLORS` au lieu
|
||||
de son dict `colors` local.
|
||||
|
||||
### Nouvelle fonction `get_org_location_map` (`src/figures.py`)
|
||||
|
||||
```python
|
||||
def get_org_location_map(
|
||||
dff: pl.DataFrame,
|
||||
home_type: Literal["acheteur", "titulaire"],
|
||||
map_id: str,
|
||||
) -> dl.Map:
|
||||
"""Carte cluster (dash-leaflet) de l'organisme et de sa contrepartie."""
|
||||
```
|
||||
|
||||
- Calcule les marqueurs pour `home_type` et son type complémentaire via
|
||||
`build_org_markers`.
|
||||
- Construit jusqu'à deux couches `dl.GeoJSON` clusterisées (une par type
|
||||
présent), réutilisant le JS clientside existant
|
||||
(`dash_clientside.leaflet.pointToLayer` / `clusterToLayer`, inchangé) et le
|
||||
même mécanisme `options={"fillColor": ORG_COLORS[org_type]}` que
|
||||
`make_clusters_map`.
|
||||
- Calcule les bounds `[[lat_min, lon_min], [lat_max, lon_max]]` sur l'ensemble
|
||||
des points (les deux types confondus) et les passe via la prop `bounds` de
|
||||
`dl.Map`, avec `boundsOptions={"padding": [30, 30], "maxZoom": 12}`.
|
||||
- Si aucun point n'est disponible : `center=[46.6, 2.2]`, `zoom=5` (pas de
|
||||
prop `bounds`).
|
||||
- `style={"width": "100%", "height": "300px"}` (cohérent avec la hauteur
|
||||
actuelle de la colonne carte).
|
||||
- Les ids des couches GeoJSON sont dérivés de `map_id`
|
||||
(`f"{map_id}-acheteur"` / `f"{map_id}-titulaire"`).
|
||||
|
||||
## Flux de données (pages)
|
||||
|
||||
Sur `src/pages/acheteur.py` et `src/pages/titulaire.py`, le rendu de la carte
|
||||
est aujourd'hui mélangé dans le callback qui interroge l'API annuaire
|
||||
(`update_acheteur_infos` / `update_titulaire_infos`, déclenché uniquement par
|
||||
l'URL). On sépare :
|
||||
|
||||
- Ce callback existant perd l'`Output` `*_map` et l'appel à `point_on_map` ;
|
||||
il continue de fournir nom/commune/département/région/lien annuaire,
|
||||
inchangés.
|
||||
- **Nouveau callback dédié** par page, avec `Input` sur l'URL **et** le
|
||||
dropdown Année (comme `get_top_titulaires`/`get_top_acheteurs`) :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("acheteur_map", "children"),
|
||||
Input("acheteur_url", "pathname"),
|
||||
Input("acheteur_year", "value"),
|
||||
)
|
||||
def update_acheteur_map(pathname, ach_year):
|
||||
where_sql, params = _acheteur_scope(pathname, ach_year)
|
||||
geo_columns = [
|
||||
c
|
||||
for c in [
|
||||
"uid",
|
||||
"acheteur_longitude",
|
||||
"acheteur_latitude",
|
||||
"acheteur_nom",
|
||||
"titulaire_longitude",
|
||||
"titulaire_latitude",
|
||||
"titulaire_nom",
|
||||
]
|
||||
if c in schema.names()
|
||||
]
|
||||
dff = query_marches(where_sql, params, columns=geo_columns)
|
||||
return get_org_location_map(dff, "acheteur", "acheteur_map_leaflet")
|
||||
```
|
||||
|
||||
Le filtre `geo_columns` sur `schema.names()` évite une erreur SQL DuckDB
|
||||
(colonne inexistante) quand le dataset ne contient pas encore les colonnes
|
||||
longitude/latitude — c'est le cas de `tests/test.parquet` aujourd'hui. Dans
|
||||
ce cas, `build_org_markers` renvoie `[]` pour les deux types et
|
||||
`get_org_location_map` bascule sur la vue France par défaut.
|
||||
|
||||
Symétrique sur `src/pages/titulaire.py` (`update_titulaire_map`,
|
||||
`_titulaire_scope`, `"titulaire"` comme `home_type`).
|
||||
|
||||
## Nettoyage
|
||||
|
||||
`point_on_map` (carte Plotly à point unique basée sur l'annuaire) devient
|
||||
inutilisée une fois les deux pages migrées : suppression de la fonction dans
|
||||
`src/figures.py` et de son import dans `acheteur.py`/`titulaire.py`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Test unitaire de `build_org_markers` : cas nominal (plusieurs points,
|
||||
comptage), cas colonnes absentes (`[]` sans exception), cas coordonnées
|
||||
nulles filtrées.
|
||||
- Test unitaire de `get_org_location_map` : bounds calculés sur des points
|
||||
connus ; repli sur la vue France par défaut quand aucun marqueur.
|
||||
- Pas de nouveau test Selenium dédié (aucun test existant ne navigue
|
||||
actuellement vers `/acheteurs/<id>` ou `/titulaires/<id>` dans le
|
||||
navigateur) ; vérification manuelle via le serveur de dev recommandée
|
||||
après implémentation.
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Pas de découpage par région/DOM-TOM ni de bascule chloroplèthe sur ces
|
||||
pages (réservé à `/observatoire`).
|
||||
- Pas de changement du texte département/région/lien annuaire (reste basé
|
||||
sur l'annuaire des entreprises).
|
||||
- Pas de nouveau filtre autre que celui déjà présent (Année).
|
||||
@@ -0,0 +1,222 @@
|
||||
# Refonte du tunnel d'abonnement — abonnement public
|
||||
|
||||
## Contexte et objectif
|
||||
|
||||
Aujourd'hui, pour s'abonner, un visiteur doit : (1) créer un compte, (2) valider
|
||||
son email, (3) se connecter, (4) seulement là découvrir et entamer le tunnel
|
||||
d'abonnement. Les cards d'abonnement vivent derrière l'authentification, dans
|
||||
`/compte/abonnement`. Ce parcours est trop long et masque l'offre aux visiteurs.
|
||||
|
||||
Objectif : **rendre l'offre d'abonnement visible aux visiteurs non connectés** et
|
||||
raccourcir le tunnel, tout en préservant l'accès des anciens abonnés à leur compte
|
||||
(factures) même s'ils ne sont plus abonnés.
|
||||
|
||||
## Principes
|
||||
|
||||
- **Se connecter ≠ être abonné.** La connexion ne requiert pas d'abonnement (déjà le
|
||||
cas). Un ancien abonné garde l'accès à `/compte/admin` et `/compte/abonnement`
|
||||
(guards en `require_subscription=False`). Il perd seulement l'accès aux sections
|
||||
réservées (`/compte/vues`, `/compte/roadmap`).
|
||||
- **L'offre est publique.** La page `/a-propos/abonnement` (URL inchangée, au
|
||||
singulier) présente les cards et un unique bouton d'entrée dans le tunnel.
|
||||
- **Le choix du plan (simple/soutien) se fait dans `mes-infos`**, pas sur la card.
|
||||
Cela évite de transporter le plan à travers inscription → email → mes-infos.
|
||||
|
||||
## Flow cible
|
||||
|
||||
```
|
||||
Visiteur
|
||||
└─ navbar "Connexion" ─────────────► /connexion (formulaire + lien vers offre)
|
||||
└─ /a-propos/abonnement (public) ──► cards + explainer + bouton "Je m'abonne"
|
||||
│
|
||||
├─ non connecté ────────────► /inscription
|
||||
│ ├─ email : signup → email de validation
|
||||
│ │ └─ clic lien ► /auth/verify-email : auto-login
|
||||
│ │ └─► /compte/abonnement/mes-infos
|
||||
│ └─ LinkedIn (next=mes-infos) ─► /compte/abonnement/mes-infos
|
||||
│
|
||||
└─ déjà connecté, non abonné ► /compte/abonnement/mes-infos
|
||||
|
||||
/compte/abonnement/mes-infos
|
||||
└─ radios plan (simple/soutien, défaut simple) + rappel tarifs
|
||||
+ infos facturation + cases CGU
|
||||
└─ POST /subscriptions/subscribe ► checkout Frisbii
|
||||
|
||||
/compte/abonnement (connecté)
|
||||
├─ abonné actif ──────► vue de gestion (inchangée)
|
||||
└─ non abonné ────────► texte + bouton :
|
||||
├─ has_used_trial → "Me réabonner" ─► /a-propos/abonnement
|
||||
└─ sinon → "M'abonner" ─► /a-propos/abonnement
|
||||
```
|
||||
|
||||
## Décisions actées
|
||||
|
||||
1. **Bouton unique « Je m'abonne »** centré sous les 2 cards de la page publique.
|
||||
Les cards deviennent purement informatives (plus de bouton « S'abonner » par card).
|
||||
2. **Choix du plan par boutons radio dans `mes-infos`** (défaut « simple »), avec
|
||||
rappel des tarifs. Aucun plan transporté depuis la card.
|
||||
3. **URL de la page publique inchangée** : `/a-propos/abonnement` (singulier). Pas de
|
||||
renommage ni de redirect.
|
||||
4. **Cible du bouton conditionnelle à l'état de connexion** (voir détail §2).
|
||||
5. **`/compte/abonnement` non-abonné** : plus de cards, un bouton « M'abonner » /
|
||||
« Me réabonner » selon `has_used_trial(user_id)`.
|
||||
6. **Signal « déjà abonné » = `has_used_trial`** (True quand un abonnement a atteint
|
||||
`trial`/`active`). Un checkout Frisbii abandonné (`pending`/`failed`, aucun accès)
|
||||
→ considéré « jamais abonné » → « M'abonner ».
|
||||
7. **Validation d'email = auto-login** puis redirection vers `mes-infos`.
|
||||
8. **`linkedin_button` paramétrable par `next`** : la connexion garde son
|
||||
comportement, l'inscription route vers `mes-infos`.
|
||||
|
||||
## Changements par fichier
|
||||
|
||||
### 1. `src/pages/a_propos/abonnement.py` — page publique
|
||||
|
||||
Devient le foyer unique des composants cards (refactor depuis `compte/abonnement.py`).
|
||||
|
||||
- **Remonter** ici (auth-agnostiques) : `_plan_card`, `_plan_cards`, `_explainer`.
|
||||
- `_plan_card` : **retirer le bouton « S'abonner » par card**. La carte n'affiche
|
||||
plus que label, tarif, description et le badge d'essai générique
|
||||
(`trial_days(key)` jours, sans personnalisation `trial_used`).
|
||||
- `layout()` devient dynamique (appelé par requête, peut lire `current_user`) et
|
||||
assemble de haut en bas :
|
||||
1. `_plan_cards()` + `_explainer()`
|
||||
2. le bouton **« Je m'abonne »** centré (voir §2)
|
||||
3. `subscription_terms` (CGU existantes)
|
||||
- **Contenu** : la sous-section « Fonctionnalités incluses » + `abonnement_features`
|
||||
de `subscription_terms` fait désormais doublon avec `_explainer` juste au-dessus.
|
||||
→ **la retirer de `subscription_terms`** (garder le reste des CGU tel quel).
|
||||
|
||||
### 2. Bouton « Je m'abonne » (dans `a_propos/abonnement.py`)
|
||||
|
||||
Une fonction dédiée qui décide libellé + cible selon l'état :
|
||||
|
||||
| État | Libellé | Cible |
|
||||
| ---------------------------------- | --------------------------- | -------------------------------------- |
|
||||
| `TOUS_ABONNES` | « Je m'abonne » (désactivé) | `#` + bannière (comme cards actuelles) |
|
||||
| non authentifié | « Je m'abonne » | `/inscription` |
|
||||
| authentifié, sans abonnement actif | « Je m'abonne » | `/compte/abonnement/mes-infos` |
|
||||
| authentifié, abonnement actif | « Gérer mon abonnement » | `/compte/abonnement` |
|
||||
|
||||
Bouton centré, `btn btn-primary`, largeur ajustée.
|
||||
|
||||
### 3. `src/pages/compte/abonnement.py`
|
||||
|
||||
- **Supprimer** `_plan_card`, `_plan_cards`, `_explainer` (déplacés en §1) et l'import
|
||||
`from src.pages.a_propos.abonnement import abonnement_features`.
|
||||
- La branche « non-abonné » (`else` de `layout`, aujourd'hui cards + explainer)
|
||||
devient un court texte d'invitation + un bouton :
|
||||
- `db.has_used_trial(current_user.id)` → « Me réabonner »
|
||||
- sinon → « M'abonner »
|
||||
- href → `/a-propos/abonnement` dans les deux cas.
|
||||
- Le message « Votre abonnement a expiré » (cas `status == "expired"`) est conservé.
|
||||
- La vue « abonné actif » (`_active_view`, résiliation, feedback paiement,
|
||||
`_salaire_modal`, `_tous_abonnes_banner`) est **inchangée**.
|
||||
|
||||
### 4. `src/pages/compte/abonnement_mes_infos.py`
|
||||
|
||||
- **Retirer** la redirection « pas de `?plan=` » (lignes 53-55) : la page est
|
||||
accessible directement.
|
||||
- **Ajouter en tête de formulaire** une sélection de formule sous forme de **cartes
|
||||
cliquables** (réutilisation de `_plan_card` de la page publique), avec rappel des
|
||||
tarifs (20 € HT / 50 € HT). UX voulue : aucune formule sélectionnée par défaut, un
|
||||
texte « Choisissez votre formule » invite l'utilisateur ; la carte sélectionnée
|
||||
prend un fond légèrement teinté. Mécanisme (le POST du `html.Form` ne soumet
|
||||
nativement qu'un champ portant `name`) :
|
||||
- deux cartes, chacune enveloppée dans un `html.Div` cliquable
|
||||
(`id="plan-card-simple"|"plan-card-soutien"`, `n_clicks`, classe `plan-selectable`) ;
|
||||
- un `dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value="")` — vide
|
||||
au départ ; c'est CE champ, natif, qui est soumis (même mécanisme que le champ
|
||||
caché `plan` actuel, déjà lu par `subscribe()`) ;
|
||||
- un callback sur le `n_clicks` des deux cartes qui écrit la formule dans le champ
|
||||
caché, applique la classe `selected` à la carte choisie (retirée de l'autre) et
|
||||
masque l'invite ;
|
||||
- un texte d'invite `id="inf-plan-invite"` visible tant qu'aucune formule n'est choisie.
|
||||
- **Style** : ajouter dans `src/assets/css/style.css`
|
||||
`.plan-selectable { cursor: pointer }` et
|
||||
`.plan-selectable.selected .card { background-color: var(--bs-primary-bg-subtle); border-color: var(--bs-primary) }`.
|
||||
- **Gating du bouton d'envoi** : `_toggle_submit` exige désormais aussi qu'une formule
|
||||
soit sélectionnée (en plus des deux cases rétractation/CGU). Sans plan → désactivé.
|
||||
- **Remplacer** le `dcc.Input(type="hidden", name="plan", value=plan)` fixe (ligne 232) par le champ caché vide synchronisé ci-dessus.
|
||||
- Le reste (prefill Frisbii, SIRET, cases rétractation/CGU) est inchangé.
|
||||
`subscribe()` lit toujours `request.form.get("plan")` → compatible.
|
||||
|
||||
### 5. `src/auth/routes.py` — `verify_email()`
|
||||
|
||||
Après `db.set_email_verified(user_id)` :
|
||||
|
||||
```python
|
||||
user = User(db.get_user_by_id(user_id))
|
||||
login_user(user, remember=True)
|
||||
return redirect("/compte/abonnement/mes-infos")
|
||||
```
|
||||
|
||||
(`login_user` et `User` sont déjà importés.) La page `/verification-email` reste
|
||||
utilisée pour le seul cas `error=invalid_token`.
|
||||
|
||||
### 6. `src/pages/connexion.py`
|
||||
|
||||
- `linkedin_button` accepte un paramètre `next_url` optionnel :
|
||||
|
||||
```python
|
||||
def linkedin_button(next_url: str | None = None):
|
||||
href = "/auth/linkedin"
|
||||
if next_url:
|
||||
href += f"?next={next_url}"
|
||||
# ... inchangé
|
||||
```
|
||||
|
||||
- Le CTA du bas de `/connexion` (« Créer un compte avec mon adresse email » →
|
||||
`/inscription`) devient un lien vers `/a-propos/abonnement` (« Pas encore de
|
||||
compte ? Voir les abonnements »).
|
||||
|
||||
### 7. `src/pages/inscription.py`
|
||||
|
||||
- Appelle `linkedin_button("/compte/abonnement/mes-infos")` pour que l'inscription
|
||||
via LinkedIn finisse dans le tunnel (au lieu de `/compte/admin`).
|
||||
- Le reste (formulaire email, lien « Déjà un compte ? ») inchangé.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Redirection de retour LinkedIn** : `safe_next` (`auth/setup.py:16-19`) n'autorise
|
||||
qu'un chemin interne commençant par un seul `/` (rejette `//` et les URLs
|
||||
absolues). Le `next=/compte/abonnement/mes-infos` passé par l'inscription est un
|
||||
chemin interne valide, filtré à l'entrée (`linkedin_login`) et à la sortie (callback).
|
||||
Pas d'open redirect introduit.
|
||||
- **Auto-login via lien email** : le token de vérification est à usage unique et
|
||||
consommé (`consume_verification_token`). L'auto-login qui en découle est un
|
||||
magic-link classique, acceptable.
|
||||
|
||||
## Cas limites
|
||||
|
||||
- **Abandon sur mes-infos** : l'utilisateur a un compte fonctionnel sans abonnement.
|
||||
`get_current` renvoie `None`, `has_used_trial` False → `/compte/abonnement` affiche
|
||||
« M'abonner ». Cohérent.
|
||||
- **Ancien abonné (expiré/résilié)** : `has_used_trial` True → « Me réabonner ».
|
||||
- **Abonné actif visitant `/a-propos/abonnement`** : bouton « Gérer mon abonnement »
|
||||
→ `/compte/abonnement` (pas de « Je m'abonne » trompeur).
|
||||
- **`TOUS_ABONNES`** : bouton public désactivé + bannière, comme les cards
|
||||
actuelles ; `mes-infos`/`subscribe` déjà gérés en amont.
|
||||
- **Email déjà pris à l'inscription** : comportement existant (`email_taken`),
|
||||
l'utilisateur est invité à se connecter.
|
||||
|
||||
## Tests
|
||||
|
||||
Étendre `tests/` (Selenium `DashComposite`) :
|
||||
|
||||
- Visiteur non connecté : `/a-propos/abonnement` affiche les cards + « Je m'abonne »
|
||||
pointant vers `/inscription`.
|
||||
- Utilisateur connecté sans abonnement : « Je m'abonne » pointe vers `mes-infos` ;
|
||||
`/compte/abonnement` affiche « M'abonner ».
|
||||
- Utilisateur ayant déjà été abonné (`trial_used=1`) : `/compte/abonnement` affiche
|
||||
« Me réabonner ».
|
||||
- `mes-infos` accessible sans `?plan=` ; radios présents, « simple » par défaut ;
|
||||
soumission POST envoie bien `plan`.
|
||||
- `verify_email` : après consommation du token, session authentifiée et redirection
|
||||
vers `mes-infos`.
|
||||
- `safe_next` : `next` externe (`//evil.com`, `https://…`) ignoré au profit du fallback.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Refonte visuelle des cards / de la page publique (on réutilise l'existant).
|
||||
- Modification du parcours de paiement Frisbii lui-même.
|
||||
- Gestion des factures (déjà côté Frisbii, inchangée).
|
||||
@@ -0,0 +1,124 @@
|
||||
# Changer de méthode de paiement (issue #108) — Design
|
||||
|
||||
Date : 2026-07-05
|
||||
Branche : `dev`
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre à un·e abonné·e actif·ve ou en essai de changer sa carte bancaire
|
||||
depuis `/compte/abonnement`, sans repasser par tout le flux d'inscription.
|
||||
|
||||
## Mécanisme Frisbii
|
||||
|
||||
`GET /v1/subscription/{handle}` (déjà implémenté : `client.get_subscription`)
|
||||
renvoie un champ `hosted_page_links.payment_info` : une page hébergée par
|
||||
Frisbii, dédiée au changement de carte sur un abonnement existant
|
||||
(doc : https://docs.frisbii.com/docs/change-payment-method-on-existing-subscription).
|
||||
Elle accepte `accept_url` et `cancel_url` en query params pour rediriger après
|
||||
succès/annulation.
|
||||
|
||||
Ce mécanisme est plus simple que le flux `add-payment` existant (Checkout API
|
||||
`/v1/session/recurring` + callback qui associe la nouvelle méthode via
|
||||
`set_subscription_payment_method`) : pas de session à créer, pas de callback à
|
||||
gérer côté colibre, Frisbii associe directement la nouvelle carte à
|
||||
l'abonnement.
|
||||
|
||||
## Portée
|
||||
|
||||
Bouton **« Changer de méthode de paiement »** affiché uniquement pour
|
||||
`row["status"] in ("trial", "active")` :
|
||||
|
||||
- `pending` garde son bouton actuel « Ajouter une méthode de paiement »
|
||||
(aucune carte n'existe encore, flux différent).
|
||||
- `cancelled` n'a pas ce bouton : l'abonnement s'arrête à la fin de la
|
||||
période en cours, il n'y a plus rien à facturer dessus. Le chemin logique
|
||||
est de reprendre un abonnement (`_reabo_button`, déjà géré ailleurs).
|
||||
|
||||
## Fichiers touchés
|
||||
|
||||
### `src/subscriptions/client.py`
|
||||
|
||||
Nouvelle fonction :
|
||||
|
||||
```python
|
||||
def get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str:
|
||||
sub = get_subscription(sub_handle)
|
||||
url = sub["hosted_page_links"]["payment_info"]
|
||||
parts = urlsplit(url)
|
||||
query = dict(parse_qsl(parts.query))
|
||||
query["accept_url"] = accept_url
|
||||
query["cancel_url"] = cancel_url
|
||||
return urlunsplit(parts._replace(query=urlencode(query)))
|
||||
```
|
||||
|
||||
Utilise `urllib.parse` pour fusionner proprement avec une éventuelle query
|
||||
string déjà présente sur `payment_info` plutôt que de la concaténer
|
||||
naïvement.
|
||||
|
||||
### `src/subscriptions/routes.py`
|
||||
|
||||
Nouvelle route, symétrique à `add_payment()` / `cancel()` :
|
||||
|
||||
```python
|
||||
@subscriptions_bp.route("/subscriptions/change-payment-method", methods=["POST"])
|
||||
@login_required
|
||||
def change_payment_method():
|
||||
base = os.getenv("APP_BASE_URL", "")
|
||||
row = db.get_current(current_user.id)
|
||||
if row is None or not row["frisbii_subscription_handle"]:
|
||||
return "Aucun abonnement actif", 400
|
||||
try:
|
||||
url = client.get_payment_info_url(
|
||||
row["frisbii_subscription_handle"],
|
||||
f"{base}/compte/abonnement?carte=succes",
|
||||
f"{base}/compte/abonnement?carte=annule",
|
||||
)
|
||||
except client.FrisbiiError:
|
||||
logger.exception("Échec de récupération du lien de paiement Frisbii")
|
||||
return redirect("/compte/abonnement?error=frisbii")
|
||||
return redirect(url, code=303)
|
||||
```
|
||||
|
||||
Pas de webhook/callback à gérer : Frisbii associe la nouvelle méthode de
|
||||
paiement à l'abonnement de son côté, et le webhook existant
|
||||
(`/frisbii/webhook`) continuera de refléter l'état de l'abonnement comme
|
||||
aujourd'hui.
|
||||
|
||||
### `src/pages/compte/abonnement.py`
|
||||
|
||||
- `_active_view(row)` : pour `row["status"] in ("trial", "active")`, ajouter
|
||||
un `html.Form` POST vers `/subscriptions/change-payment-method` (CSRF token
|
||||
via `_csrf_input()`), bouton `btn btn-outline-secondary`
|
||||
« Changer de méthode de paiement », affiché à côté du bouton
|
||||
« Me désabonner » existant.
|
||||
- `_feedback(query)` : ajouter la gestion de `query.get("carte")` :
|
||||
- `"succes"` → alerte success « Méthode de paiement mise à jour. »
|
||||
- `"annule"` → alerte secondary « Modification annulée. »
|
||||
|
||||
## Gestion d'erreurs
|
||||
|
||||
- Pas d'abonnement / pas de handle → 400 (cas normalement inatteignable
|
||||
depuis l'UI, le bouton n'étant rendu que si `row` existe et a un statut
|
||||
trial/active, donc un handle).
|
||||
- Échec API Frisbii (`FrisbiiError`) → `logger.exception` +
|
||||
`redirect("/compte/abonnement?error=frisbii")`, réutilise l'alerte
|
||||
« Une erreur est survenue avec le service de paiement. » déjà gérée par
|
||||
`_feedback`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/subscriptions/` : test de `client.get_payment_info_url` — mock de
|
||||
`client.get_subscription` (ou de `_call`), vérifie que `accept_url` et
|
||||
`cancel_url` sont bien ajoutés à l'URL, y compris si `payment_info` a déjà
|
||||
une query string.
|
||||
- Test de la route `change_payment_method` : redirect 303 vers l'URL Frisbii
|
||||
quand un abonnement actif existe ; 400 si pas d'abonnement.
|
||||
- Test de `_feedback()` pour les nouvelles clés `carte=succes` / `carte=annule`.
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Affichage de la carte actuellement enregistrée (marque, 4 derniers
|
||||
chiffres) — pourra venir plus tard via `client.get_customer_payment_methods`.
|
||||
- Bouton pour `cancelled` (cf. Portée ci-dessus).
|
||||
- Gestion multi-méthodes de paiement (le champ `active_payment_methods` de
|
||||
Frisbii ne contient au plus qu'un élément dans notre usage actuel).
|
||||
@@ -0,0 +1,177 @@
|
||||
# Configurer son abonnement (#109)
|
||||
|
||||
## Contexte
|
||||
|
||||
Sur `/compte/abonnement`, un·e abonné·e `active`, `trial` ou `pending` ne
|
||||
peut aujourd'hui ni changer de formule (simple ↔ soutien) ni mettre à jour
|
||||
ses informations de facturation. Cette fonctionnalité l'ajoute, en réutilisant
|
||||
la page `/compte/abonnement/mes-infos` déjà utilisée pour l'abonnement initial.
|
||||
|
||||
On en profite pour afficher le prix (HT + TTC) à côté de la formule courante
|
||||
sur `/compte/abonnement`.
|
||||
|
||||
## Décisions produit
|
||||
|
||||
- **Effet du changement de formule** : à la prochaine échéance
|
||||
(`timing="renewal"`), sans proratisation ni remboursement. Simple à
|
||||
expliquer, aucun mouvement d'argent immédiat.
|
||||
- **Cas `pending`** (abonnement créé mais sans méthode de paiement, billing
|
||||
non démarré) : mise à jour API directe. La formule change avec
|
||||
`timing="immediate"` (aucune échéance à laquelle rattacher un `renewal`) et
|
||||
les infos de facturation sont mises à jour. L'ajout de carte reste un bouton
|
||||
séparé sur `/compte/abonnement`.
|
||||
- **Hint « prochaine échéance »** sous les cards : affiché uniquement pour
|
||||
`active`/`trial`. Rien pour `pending`.
|
||||
|
||||
## Architecture : flexibiliser `mes-infos` (pas de nouvelle page)
|
||||
|
||||
`/compte/abonnement/mes-infos` sert déjà ~80 % de ce dont on a besoin : le
|
||||
formulaire de facturation à deux colonnes (10 champs), la recherche SIRET et
|
||||
son callback, les cards de formule sélectionnables et `_select_plan`, le
|
||||
préremplissage Frisbii, la modale CGU.
|
||||
|
||||
Les deux scénarios ne diffèrent que par : présence des cases à cocher, libellé
|
||||
du bouton, cible/logique du formulaire, formule présélectionnée.
|
||||
|
||||
**Décision : flexibiliser `mes-infos` avec un `mode`** dérivé de l'état de
|
||||
l'abonnement, plutôt qu'une nouvelle page. En Dash, deux pages ne peuvent pas
|
||||
partager les mêmes `id` de composants : une page dupliquée forcerait à
|
||||
renommer chaque `id` **et** à dupliquer les quatre callbacks (`_select_plan`,
|
||||
`_lookup_siret`, `_toggle_submit`, `_toggle_cgu`) — soit l'essentiel du
|
||||
fichier dupliqué pour 20 % de différence. `suppress_callback_exceptions=True`
|
||||
(déjà activé, `src/app.py:88`) rend sûrs les composants rendus
|
||||
conditionnellement.
|
||||
|
||||
**Le `mode` est dérivé de l'état, pas d'un paramètre d'URL.** Le bouton
|
||||
« Configurer mon abonnement » et le bouton « M'abonner » pointent tous deux
|
||||
vers `/compte/abonnement/mes-infos` ; la page s'adapte. Cela reprend la logique
|
||||
du garde déjà présent dans `subscribe()` (refus d'un second abonnement quand
|
||||
un est actif).
|
||||
|
||||
```python
|
||||
row = db.get_current(current_user.id)
|
||||
mode = "configure" if row and row["status"] in ("active", "trial", "pending") else "subscribe"
|
||||
```
|
||||
|
||||
## Section 1 — page `/compte/abonnement`
|
||||
|
||||
Fichier : `src/pages/compte/abonnement.py`.
|
||||
|
||||
1. **Prix à côté de la formule** dans `_active_view`. Réutiliser le motif de
|
||||
`src/pages/a_propos/abonnement.py:34-35` :
|
||||
`{prix_ht} € HT / mois ({prix_ht * 1.2:g} € TTC)`. `plans.plan_meta()`
|
||||
retourne déjà `prix_ht`. Ajouter sous le `html.H3(meta["label"])` un
|
||||
paragraphe discret (`<small>`/muted) avec le prix.
|
||||
2. **Bouton « Configurer mon abonnement »** : un lien
|
||||
(`href="/compte/abonnement/mes-infos"`, classe `btn`) ajouté dans les
|
||||
branches `active`/`trial`/`pending`, aux côtés de « Changer de méthode de
|
||||
paiement » et « Me désabonner ».
|
||||
3. **Message de succès** : ajouter dans `_feedback()` une entrée
|
||||
`maj=succes` → _« Votre abonnement a été mis à jour. »_ (couleur `success`).
|
||||
|
||||
## Section 2 — page `/compte/abonnement/mes-infos`
|
||||
|
||||
Fichier : `src/pages/compte/abonnement_mes_infos.py`.
|
||||
|
||||
`layout()` calcule `row = db.get_current(current_user.id)` et en dérive `mode`.
|
||||
|
||||
Rendu conditionnel piloté par `mode` :
|
||||
|
||||
| Élément | `subscribe` (actuel) | `configure` (nouveau) |
|
||||
| ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Cards de formule | aucune présélectionnée | formule courante (`row["plan"]`) présélectionnée via `_selection_state` |
|
||||
| Cases à cocher (rétractation + CGU) | affichées | **omises** |
|
||||
| Modale CGU | affichée | omise |
|
||||
| Libellé du bouton | « Ajouter une carte de paiement » | **« Mettre à jour mon abonnement »** |
|
||||
| `disabled` initial du bouton | `True` | `False` (une formule est déjà sélectionnée) |
|
||||
| `action` du formulaire | `/subscriptions/subscribe` | `/subscriptions/update` (nouvelle route) |
|
||||
| Texte d'intro | « Choisissez votre formule : » | « Votre formule : » |
|
||||
|
||||
Les colonnes de facturation (`col1`/`col2`), la recherche SIRET et le
|
||||
préremplissage Frisbii sont identiques dans les deux modes.
|
||||
|
||||
### Hint « prochaine échéance »
|
||||
|
||||
- Élément message masqué sous les cards : `id="inf-change-hint"`,
|
||||
`className="d-none"` au départ (texte discret / info).
|
||||
- La formule courante et la date d'échéance sont transmises au callback via un
|
||||
`dcc.Store` caché rempli dans le layout :
|
||||
`{"current_plan": row["plan"], "echeance": format_date_french(row["current_period_end"])}`,
|
||||
lu en `State`. En mode `subscribe`, le Store est absent → hint jamais affiché.
|
||||
- Étendre le callback `_select_plan` (déjà déclenché au clic sur une card) pour
|
||||
produire aussi `className` + `children` du hint :
|
||||
- formule sélectionnée **==** formule courante → masqué (`d-none`)
|
||||
- formule sélectionnée **!=** formule courante **et** statut ∈ {active, trial}
|
||||
→ affiché : _« Le changement d'abonnement sera appliqué à la prochaine
|
||||
échéance : {echeance}. »_
|
||||
- statut `pending` → rien (masqué) quelle que soit la sélection
|
||||
|
||||
### Callbacks
|
||||
|
||||
Les callbacks partagés (`_select_plan`, `_lookup_siret`, `_toggle_cgu`)
|
||||
référencent des `id` qui existent toujours en mode `configure` (sauf
|
||||
`_toggle_cgu`, dont les composants sont absents — toléré par
|
||||
`suppress_callback_exceptions`).
|
||||
|
||||
`_toggle_submit` dépend aujourd'hui des deux cases à cocher, absentes en mode
|
||||
`configure`. En mode `configure` le bouton démarre activé et n'est gardé par
|
||||
aucune case ; le callback dégrade gracieusement (ses `Input` de cases ne sont
|
||||
pas rendus). Forme exacte à confirmer à l'écriture du plan (p. ex. retour
|
||||
`False`/`no_update` quand une formule est présente).
|
||||
|
||||
## Section 3 — route `/subscriptions/update` + client
|
||||
|
||||
Fichier : `src/subscriptions/routes.py`.
|
||||
|
||||
Nouvelle route `update()` (`@login_required`), de forme proche de
|
||||
`subscribe()` mais **sans redirection vers un checkout** :
|
||||
|
||||
1. Charger `row = db.get_current(current_user.id)` ; si absent ou sans
|
||||
`frisbii_subscription_handle` → `400`.
|
||||
2. **Mettre à jour les infos de facturation** : construire le dict `billing`
|
||||
depuis `request.form` (mêmes champs que `subscribe`), persister le SIRET via
|
||||
`auth_db.set_siret`, appeler `client.update_customer(cust, billing)`.
|
||||
(Pas de branche 404 : le customer existe déjà pour tout abo
|
||||
active/trial/pending.)
|
||||
3. **Changer la formule si différente** : lire `plan` dans le formulaire ; si
|
||||
`plans.resolve_handle(plan)` diffère de la formule courante
|
||||
(`row["plan"]`), appeler `client.change_subscription(...)` avec
|
||||
`timing="immediate"` si `row["status"] == "pending"` sinon `"renewal"`.
|
||||
4. Sur `client.FrisbiiError` → `redirect("/compte/abonnement?error=frisbii")`.
|
||||
5. Sur succès → `redirect("/compte/abonnement?maj=succes", code=303)`.
|
||||
|
||||
Nouvelle fonction client dans `src/subscriptions/client.py` :
|
||||
|
||||
```python
|
||||
def change_subscription(sub_handle: str, plan_handle: str, timing: str = "renewal") -> dict:
|
||||
return _call(
|
||||
"PUT",
|
||||
f"/v1/subscription/{sub_handle}",
|
||||
json={"timing": timing, "plan": plan_handle},
|
||||
)
|
||||
```
|
||||
|
||||
Corps validé contre le schéma OpenAPI `ChangeSubscription` (endpoint
|
||||
`PUT /v1/subscription/{handle}`, « Change subscription ») : `timing` est le
|
||||
seul champ requis ; `plan` déclenche le changement de formule. Aucun paramètre
|
||||
de proratisation puisque le changement est à la prochaine échéance.
|
||||
|
||||
**Note sur le timing des infos de facturation** : `update_customer` s'applique
|
||||
immédiatement au customer, donc un changement d'adresse/SIRET prend effet tout
|
||||
de suite même si le changement de _formule_ est différé à l'échéance. C'est
|
||||
normal (adresse/SIRET ne sont pas liés à une période) ; le message de succès
|
||||
reste générique (« mis à jour ») plutôt que d'impliquer que tout attend
|
||||
l'échéance.
|
||||
|
||||
## Tests
|
||||
|
||||
- Fichier de test dédié à cette fonctionnalité (les champs d'abonnement de
|
||||
`tests/test.parquet` ne sont pas concernés ; l'état d'abonnement vit dans
|
||||
SQLite).
|
||||
- Couvrir : dérivation du `mode` selon le statut ; rendu conditionnel
|
||||
(cases à cocher présentes/absentes, libellé du bouton, formule
|
||||
présélectionnée) ; route `update()` (mise à jour customer + appel
|
||||
`change_subscription` avec le bon `timing`, court-circuit si formule
|
||||
inchangée, garde 400 sans abonnement) ; affichage du hint selon
|
||||
statut/sélection.
|
||||
- `client.change_subscription` : vérifier le corps JSON envoyé (mock httpx).
|
||||
@@ -0,0 +1,228 @@
|
||||
# Design — Serveur MCP colibre, lot 1 : les tools (scope A de l'issue #111)
|
||||
|
||||
**Date** : 2026-07-09
|
||||
**Issue** : #111 — Serveur MCP des données colibre, conditionné à l'abonnement
|
||||
**Périmètre de CE design** : **A seulement** — exposer des fonctions métier comme
|
||||
_tools_ MCP via le décorateur `@mcp_enabled` de Dash. **Sans authentification.**
|
||||
La couche d'autorisation OAuth 2.0 + gate abonnement fera l'objet d'un design
|
||||
séparé (scope B).
|
||||
|
||||
## Contexte
|
||||
|
||||
- La migration Dash 4.x (#101) est terminée (Dash 4.4). Le serveur MCP de Dash est
|
||||
disponible à partir de Dash 4.3.0 → prérequis satisfait.
|
||||
- Dash fournit la couche protocole MCP (`enable_mcp=True`, décorateur
|
||||
`@mcp_enabled`, `configure_mcp_server(...)`). Dash **n'implémente pas**
|
||||
l'authentification (cf. `/dash-mcp/auth`) → c'est le scope B, hors de ce design.
|
||||
- Les **DECP sont des données publiques ouvertes**. Exposer recherche/stats en MCP
|
||||
n'est donc pas une fuite de confidentialité ; le gate abonnement (scope B) relève
|
||||
du contrôle d'accès / monétisation, pas du secret. Conséquence : le scope A peut
|
||||
tourner en dev/local sans risque de données.
|
||||
|
||||
## Ce qui existe déjà et qu'on réutilise
|
||||
|
||||
- `src/db.py` : `query_marches(where_sql, params, columns, order_by, limit, offset)`,
|
||||
`count_marches(where_sql, params)`, `aggregate_marches(select_sql, where_sql, params, group_by, order_by, limit, offset)` — accès DuckDB paramétré renvoyant du
|
||||
Polars.
|
||||
- `src/api/filters.py` : `build_where(args, schema) -> (where_sql, params, order_by)`
|
||||
et `parse_aggregators(...)` — moteur de filtres `col__op=valeur` déjà utilisé par
|
||||
l'API REST (`src/api/routes.py`). **On le réutilise tel quel** pour que MCP et REST
|
||||
partagent la même sémantique de filtrage.
|
||||
- `src/utils/search.py` : `search_org(dff, query, org_type)` — recherche floue par
|
||||
nom sur les acheteurs/titulaires (déjà utilisée par la page `/`).
|
||||
- `src/utils/tracking.py` : `track_search(query, category)` — envoi direct à l'API
|
||||
HTTP de tracking Matomo (`matomo.php`).
|
||||
|
||||
## Approches considérées
|
||||
|
||||
- **A — Module MCP fin réutilisant la couche données existante (RETENU).**
|
||||
Nouveau package `src/mcp/`, 4 fonctions `@mcp_enabled` appelant directement
|
||||
`db.*` / `filters.build_where` / `search_org`. Le « service layer » partagé
|
||||
qu'on voudrait existe déjà (`src/db.py` + `src/api/filters.py`) → peu de code neuf.
|
||||
- **B — Extraire un service commun** partagé entre `api/routes.py` et MCP. Meilleure
|
||||
déduplication à terme mais gros refactor de l'API REST pour un gain marginal (la
|
||||
logique est déjà factorisée). Rejeté (YAGNI).
|
||||
- **C — MCP appelle l'API REST en HTTP.** Ajoute un saut HTTP interne en process,
|
||||
perd le typage. Rejeté.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Arborescence
|
||||
|
||||
```
|
||||
src/mcp/
|
||||
__init__.py
|
||||
tools.py # les 4 fonctions @mcp_enabled (surface MCP)
|
||||
serialization.py # Polars → JSON propre (dates ISO, montants, None-safe)
|
||||
stats.py # helpers d'agrégation acheteur/titulaire, partagés par les 2 tools stats_*
|
||||
```
|
||||
|
||||
### Activation (dans `src/app.py`, là où `Dash(...)` est construit)
|
||||
|
||||
```python
|
||||
from dash.mcp import configure_mcp_server
|
||||
|
||||
app = Dash(__name__, ..., enable_mcp=os.getenv("DASH_MCP_ENABLED") == "true")
|
||||
|
||||
configure_mcp_server(
|
||||
include_layout=False,
|
||||
include_callbacks=False,
|
||||
include_pages=False,
|
||||
include_clientside_callbacks=False,
|
||||
) # n'expose QUE les fonctions @mcp_enabled — aucun callback/layout/page d'UI
|
||||
|
||||
import src.mcp.tools # noqa: E402,F401 — l'import enregistre les @mcp_enabled
|
||||
```
|
||||
|
||||
**Sécurité (point de vigilance #111)** : en coupant `include_callbacks/layout/pages/ clientside`, aucun callback d'UI ni nom interne (type `get_data_from_s3`) n'est
|
||||
exposé. La surface se limite aux 4 tools nommés proprement, avec docstrings
|
||||
maîtrisées.
|
||||
|
||||
### Isolation des unités
|
||||
|
||||
- `tools.py` : **uniquement** la surface MCP (signatures, docstrings destinées à
|
||||
l'agent, validation des arguments, appels aux helpers). Ne contient pas de SQL.
|
||||
- `stats.py` : logique d'agrégation acheteur/titulaire, testable sans MCP.
|
||||
- `serialization.py` : conversion Polars → structures JSON-sérialisables, testable
|
||||
isolément.
|
||||
|
||||
## Les 4 tools
|
||||
|
||||
Tous renvoient des structures JSON-sérialisables (dict / list). Montants en euros
|
||||
(float ou int), dates en ISO 8601 (`YYYY-MM-DD`), valeurs manquantes en `null`.
|
||||
Les docstrings sont exposées à l'agent (`expose_docstring=True`) : ce sont elles qui
|
||||
documentent l'outil côté client.
|
||||
|
||||
### 1. `rechercher_organisations(query: str, type: str = "acheteur", limite: int = 20)`
|
||||
|
||||
- `type` ∈ `{"acheteur", "titulaire"}`.
|
||||
- Réutilise `search_org` sur la frame correspondante (mêmes données que la page `/`).
|
||||
- Sortie : `[{ "id", "nom", "departement", "commune" }]`, triée par pertinence,
|
||||
tronquée à `limite`.
|
||||
- Rôle : **résoudre un nom → id** pour alimenter `stats_acheteur` / `stats_titulaire`.
|
||||
|
||||
### 2. `stats_acheteur(acheteur_id: str)`
|
||||
|
||||
Réutilise `aggregate_marches` avec un `where` filtrant sur `acheteur_id`.
|
||||
Sortie :
|
||||
|
||||
```json
|
||||
{
|
||||
"identite": { "id", "nom", "departement", "commune" },
|
||||
"nb_marches": 0,
|
||||
"montant_total": 0,
|
||||
"repartition_annuelle": [ { "annee", "nb_marches", "montant_total" } ],
|
||||
"top_titulaires": [ { "id", "nom", "nb_marches", "montant_total" } ],
|
||||
"top_cpv": [ { "cpv", "libelle", "nb_marches" } ]
|
||||
}
|
||||
```
|
||||
|
||||
- `top_*` limités (ex. 10). Si `acheteur_id` inconnu → `nb_marches: 0` et listes vides
|
||||
(pas d'erreur).
|
||||
|
||||
### 3. `stats_titulaire(titulaire_id: str)`
|
||||
|
||||
Symétrique de `stats_acheteur` : `top_acheteurs` au lieu de `top_titulaires`,
|
||||
montants remportés.
|
||||
|
||||
### 4. `rechercher_marches(...)` — signature **hybride**
|
||||
|
||||
```python
|
||||
rechercher_marches(
|
||||
acheteur_id: str | None = None,
|
||||
titulaire_id: str | None = None,
|
||||
cpv: str | None = None,
|
||||
objet_contient: str | None = None,
|
||||
montant_min: float | None = None,
|
||||
montant_max: float | None = None,
|
||||
date_min: str | None = None, # ISO YYYY-MM-DD (dateNotification)
|
||||
date_max: str | None = None,
|
||||
departement: str | None = None,
|
||||
page: int = 1,
|
||||
filtres_avances: dict | None = None, # échappatoire moteur générique
|
||||
)
|
||||
```
|
||||
|
||||
- Les paramètres nommés sont traduits en tuples `col__op` (ex.
|
||||
`montant_min` → `("montant__greater", ...)`, `objet_contient` →
|
||||
`("objet__contains", ...)`, `date_min` → `("dateNotification__greater", ...)`).
|
||||
- `departement` mappe sur **`acheteur_departement_code`** (intention de requête la
|
||||
plus courante). Pour filtrer sur le département du titulaire ou du lieu
|
||||
d'exécution, l'agent passe par `filtres_avances`.
|
||||
- `filtres_avances` : dict `{"col__op": valeur}` passant au moteur générique complet,
|
||||
**fusionné** avec les paramètres nommés. Couvre toute colonne/opérateur supportés
|
||||
par l'API REST.
|
||||
- L'ensemble passe à `filters.build_where(args, duckdb_schema)` puis
|
||||
`db.query_marches` / `db.count_marches` → **même sémantique que l'API REST**.
|
||||
- Pagination : `page_size` **fixe** (ex. 50), pagination par `page` (offset calculé).
|
||||
- Sortie :
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": { "page": 1, "page_size": 50, "total": 0 },
|
||||
"marches": [
|
||||
{
|
||||
/* colonnes principales du marché */
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Erreurs de filtre (`FilterError`) → message d'erreur clair renvoyé à l'agent (pas
|
||||
d'exception brute).
|
||||
|
||||
## Tracking Matomo des appels MCP
|
||||
|
||||
Nouveau helper dédié dans `src/utils/tracking.py` (on **ne** surcharge **pas**
|
||||
`track_search`, qui gate sur `len(query) >= 4` et attend une requête texte) :
|
||||
|
||||
```python
|
||||
def track_mcp_tool(tool_name: str, query: str | None = None) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
- Même pattern que `track_search` : n'émet que si `not DEVELOPMENT` **et**
|
||||
`MATOMO_DOMAIN` défini. Best-effort (ne doit jamais faire échouer l'appel du tool).
|
||||
- Paramètres envoyés à `matomo.php` :
|
||||
- `action_name = "MCP"` (hiérarchie `f"MCP / {tool_name}"` acceptable pour un arbre
|
||||
lisible dans le rapport Actions),
|
||||
- `dimension1 = tool_name`,
|
||||
- `search` / `search_cat` en plus quand l'outil a une requête texte
|
||||
(`rechercher_organisations`, `rechercher_marches`).
|
||||
- Chaque tool appelle `track_mcp_tool(...)` en début d'exécution.
|
||||
- **Prérequis de déploiement Matomo** : créer un _Custom Dimension_ slot 1, scope
|
||||
**Action**, côté admin Matomo. Sinon `dimension1` est ignoré silencieusement.
|
||||
|
||||
## Déploiement / gating
|
||||
|
||||
- Activation via variable d'environnement `DASH_MCP_ENABLED` (Dash lit nativement
|
||||
cette variable ; on la reflète dans le constructeur).
|
||||
- **Off par défaut.** Activé en dev/local uniquement.
|
||||
- **Pas activé en prod tant que le scope B (OAuth + gate abonnement) n'est pas
|
||||
livré** — sinon le serveur MCP serait ouvert sans contrôle d'accès (feature
|
||||
payante + coût compute).
|
||||
- Documenter la variable dans `.template.env`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Tests unitaires sur les 4 fonctions (données `tests/test.parquet`) :
|
||||
- `rechercher_organisations` : résultats non vides, tri, `limite`, `type` invalide.
|
||||
- `stats_acheteur` / `stats_titulaire` : forme de sortie, id inconnu → vides,
|
||||
troncature des `top_*`.
|
||||
- `rechercher_marches` : fusion params nommés ↔ `filtres_avances`, pagination
|
||||
(`meta.total`, `page`), `FilterError` → message propre.
|
||||
- `serialization` : dates ISO, `null`, montants.
|
||||
- Smoke test : après import de `src.mcp.tools`, le registre MCP contient bien les
|
||||
4 tools attendus (pas de test du protocole MCP de bout en bout, qui nécessiterait
|
||||
un client MCP).
|
||||
- `tests/test.parquet` étant réduit, vérifier que les colonnes utilisées (cpv,
|
||||
montant, dateNotification, acheteur_id, titulaire_id, departement) y sont
|
||||
présentes ; sinon compléter la fixture ou marquer les cas concernés.
|
||||
|
||||
## Hors périmètre (→ scope B, design séparé)
|
||||
|
||||
- Serveur d'autorisation OAuth 2.0 conforme à la spec MCP (2025-06-18) :
|
||||
metadata protected-resource, PKCE, dynamic client registration.
|
||||
- Branchement du gate `subscriptions.has_active_subscription(user_id)` sur
|
||||
l'autorisation MCP.
|
||||
- Documentation de connexion côté client (`claude mcp add …`).
|
||||
@@ -0,0 +1,128 @@
|
||||
# Migration des tables vers Dash AG Grid — Design (Lot 1 : `tableau.py`)
|
||||
|
||||
- **Issues** : #41 (migration AG Grid), #97 (requêtes booléennes, abonnés), #112 (partage de vues abonnés par URL courte)
|
||||
- **Date** : 2026-07-09
|
||||
- **Décision d'archi de référence** : commentaire sur #41
|
||||
|
||||
## Contexte
|
||||
|
||||
Les tables de l'application reposent sur `dash_table.DataTable`, que Plotly abandonne au profit d'AG Grid. `colibre` a fortement personnalisé ses DataTable et les utilise sur 6 emplacements. La page vitrine `tableau.py` est la plus riche : paging / filtre / tri **server-side** sur DuckDB (~1,5 M lignes), partage de vue par URL, vues sauvegardées (abonnés), export Excel, sélecteur de colonnes, persistance, tooltips d'en-tête, liens dans les cellules.
|
||||
|
||||
Cette migration prépare une **version majeure** (pas de rétro-compatibilité) et doit rendre implémentable #97 (requêtes booléennes OR/AND/NOT + parenthèses, réservé aux abonnés).
|
||||
|
||||
## Objectifs
|
||||
|
||||
1. **Préserver les fonctionnalités existantes** de `tableau.py` en passant de `DataTable` à `dag.AgGrid`.
|
||||
2. **Conserver l'apparence de base d'AG Grid** dans un premier temps — le portage de nos overrides CSS (polices Inter, largeurs de colonnes conditionnelles, tailles) est **reporté** au 2e temps.
|
||||
3. **Poser le moteur de requête** (AST booléen → SQL DuckDB) comme socle canonique, pour que #97 soit une extension incrémentale.
|
||||
|
||||
## Non-objectifs (reportés)
|
||||
|
||||
- Portage des overrides CSS des DataTable (2e temps).
|
||||
- Migration des autres pages : `acheteur.py`, `titulaire.py`, `observatoire.py`, `recherche.py`, `admin/liste.py`, `figures.make_table` (Lots 2 et 3).
|
||||
- UI du champ de requête booléenne avancée #97 (le moteur AST est posé ici, l'UI vient ensuite).
|
||||
- Partage de vue par URL courte `?vue=<user_id>_<nom>` (#112).
|
||||
|
||||
## Décisions d'architecture (rappel)
|
||||
|
||||
- **AG Grid = grille d'affichage.** En row model server-side, c'est notre callback Dash qui compile le filtre en SQL ; on ne dépend pas de la puissance de filtrage d'AG Grid.
|
||||
- **Infinite Row Model** pour `tableau.py` (`rowModelType="infinite"`).
|
||||
- **Modèle canonique = AST booléen** (`AND`/`OR`/`NOT` + groupement ; feuilles = `colonne op valeur`), compilé en SQL DuckDB paramétré.
|
||||
- **Deux producteurs** alimentent le même AST : (1) filtres de colonne AG Grid (gratuit, comportement actuel préservé), (2) champ de requête texte inter-colonnes (#97, abonnés — UI reportée).
|
||||
- **Pas de rétro-compat** : l'encodage riche de vue dans l'URL (`?filtres/tris/colonnes` en DSL DataTable) est **retiré**. Le partage passera par les vues sauvegardées (#112).
|
||||
- **Pas d'AG Grid Enterprise.**
|
||||
|
||||
## Architecture cible (Lot 1)
|
||||
|
||||
### Flux de données
|
||||
|
||||
```
|
||||
AG Grid (infinite)
|
||||
│ getRowsRequest = {startRow, endRow, filterModel, sortModel}
|
||||
▼
|
||||
callback Dash `get_rows_tableau`
|
||||
│ filterModel ──► filtermodel_to_ast() ──► AST
|
||||
│ AST ──► ast_to_sql() ──► (where_sql, params)
|
||||
│ sortModel ──► sort_model_to_sql()
|
||||
▼
|
||||
DuckDB (via _fetch_page_sql, réutilisé/adapté) ──► page + total
|
||||
▼
|
||||
getRowsResponse = {rowData, rowCount}
|
||||
```
|
||||
|
||||
### Moteur de requête — nouveau module `src/utils/query_ast.py`
|
||||
|
||||
Représentation canonique et compilateur, indépendants de l'UI :
|
||||
|
||||
- **Types AST** : nœuds `And(children)`, `Or(children)`, `Not(child)`, et feuille `Condition(column, operator, value)`.
|
||||
- `ast_to_sql(node, schema) -> (where_sql, params)` : compile en SQL DuckDB **paramétré**. Valide chaque `column` contre `schema.names()` (jamais de concaténation de valeur utilisateur — même garantie que l'actuel `filter_query_to_sql`).
|
||||
- Les **feuilles texte** réutilisent la logique de `tokenize_text_filter` (`src/utils/table_sql.py`) : insensible casse/accents, wildcards `*`, phrases `+`, multi-mots en `AND`. Les feuilles numériques/date réutilisent la logique de typage de `filter_query_to_sql`.
|
||||
|
||||
Deux traducteurs (producteurs) vers l'AST :
|
||||
|
||||
- `filtermodel_to_ast(filter_model, schema) -> node` : convertit le `filterModel` d'AG Grid (`agTextColumnFilter`, `agNumberColumnFilter`, `agDateColumnFilter`, avec `operator: AND/OR` + `condition1/condition2`) en AST. Colonnes combinées en `And`.
|
||||
- _(reporté #97)_ `query_string_to_ast(text, schema) -> node` : parseur de la syntaxe FR `(béton OR ciment) AND brique AND NOT démolition`. Non implémenté au Lot 1, mais l'AST est prêt à le recevoir.
|
||||
|
||||
> On **retire** l'ancien DSL `{col} icontains valeur && …` de `tableau.py` : `filter_query_to_sql` et le JS `clean_filters` (`src/assets/dash_clientside.js`) ne sont plus utilisés par cette page. On les conserve tant que les autres pages (Lots 2/3) s'en servent, puis on les supprime au dernier lot.
|
||||
|
||||
### Composant grille — `src/figures.py`
|
||||
|
||||
Nouvelle fabrique `ag_grid(...)` (à côté de la classe `DataTable`, qui reste pour les pages non encore migrées) :
|
||||
|
||||
- `dag.AgGrid(rowModelType="infinite", ...)`.
|
||||
- `columnDefs` dérivés de `schema` : `field`, `headerName`, `filter` par type (`agTextColumnFilter` / `agNumberColumnFilter` / `agDateColumnFilter`), `floatingFilter: True`, `headerTooltip` = définition de la colonne (remplace `tooltip_header`), `hide` selon les colonnes masquées.
|
||||
- Cellules à liens (`marche` 🔍, `acheteur_nom`/`titulaire_nom` avec liens détail + 📊, `uid`, ressource) : `cellRenderer: "markdown"` + `dangerously_allow_code=True` sur la grille → le HTML `<a>` produit par `postprocess_page`/`add_links` se rend tel quel. `linkTarget: "_blank"` au besoin.
|
||||
- **Scroll infini** (décidé) : pas de pagination numérotée. Grille à **hauteur fixe** (ex. `calc(100vh - …)`) avec scroll interne → **en-têtes toujours figés**, virtualisation des lignes (seules les lignes visibles + buffer sont rendues), chargement des blocs à la volée. Ne PAS utiliser `domLayout: "autoHeight"` (incompatible avec l'infinite row model).
|
||||
- `dashGridOptions` : `cacheBlockSize` (taille de bloc serveur, ex. 100), `maxBlocksInCache`, `rowBuffer`, `infiniteInitialRowCount`. Optionnel : épingler à gauche les colonnes-clés (lien 🔍 marché, acheteur) via `pinned: "left"` pour rester visibles au scroll horizontal.
|
||||
- **Apparence de base** : pas de thème custom au Lot 1 (thème AG Grid par défaut).
|
||||
|
||||
### Persistance
|
||||
|
||||
- `persistence=True`, `persistence_type="local"`, `persisted_props=["filterModel", "columnState"]` — remplace la persistance actuelle (`filter_query`, `sort_by`). Les tris et la visibilité des colonnes vivent dans `columnState`.
|
||||
|
||||
### Réécriture des callbacks `tableau.py`
|
||||
|
||||
- **Remplacé** : le callback `update_table` (Inputs `page_current/page_size/filter_query/sort_by`) devient `get_rows_tableau` (Input `getRowsRequest` → Output `getRowsResponse`).
|
||||
- **Sélecteur de colonnes** : les callbacks colonnes pilotent désormais `columnDefs`/`columnState` (`hide`) au lieu de `hidden_columns`. `make_column_picker`, `get_default_hidden_columns`, `invert_columns` réutilisés.
|
||||
- **Export Excel** (`download_data`) — chemin **DuckDB** (décidé) : recompile le `filterModel` courant (exposé en `State`) → AST → SQL, récupère les lignes filtrées/triées depuis DuckDB (colonnes masquées exclues), puis `write_styled_excel`. Un seul compilateur (AST→SQL), même chemin de données que la grille → pas de divergence filtre-affiché / filtre-exporté. Remplace l'actuel pipeline Polars (`filter_table_data`/`sort_table_data` sur `LazyFrame`).
|
||||
- **nb_rows / hint téléchargement** : dérivés du `rowCount` et du total (seuil 65 000 lignes conservé).
|
||||
- **Vues sauvegardées (abonnés)** : `saved_views` stocke désormais l'AST (JSON) + `columnState`, au lieu de la query DSL. `build_view_query` / `restore_view_from_url` remplacés par une sérialisation AST. Le _rappel_ de vue reste ; le _partage par URL riche_ est retiré.
|
||||
- **Retiré** : `restore_view_from_url` (partie `?filtres/tris/colonnes`), `sync_url_and_reset_button` (URL riche), bouton « Partager la vue » (revient avec #112), `clean_filters` clientside.
|
||||
- **Conservé** : mode d'emploi (à réécrire pour la nouvelle UX de filtres AG Grid), bouton Réinitialiser, `track_search`.
|
||||
|
||||
### Mode d'emploi
|
||||
|
||||
Le `dcc.Markdown` d'aide et les **liens d'exemple** codés en dur (qui encodent l'ancien DSL dans l'URL) sont **réécrits** pour décrire les filtres de colonne AG Grid. Les exemples « voirie < 40 k€ » / « clause sociale PME » sont retirés ou reformulés (plus d'URL riche).
|
||||
|
||||
## Dépendances
|
||||
|
||||
- Ajouter `dash-ag-grid` (non installé actuellement) : `uv add dash-ag-grid`. Version alignée sur Dash 3.4 (dash-ag-grid 35.x). Vérifier la compatibilité au moment de l'ajout.
|
||||
|
||||
## Cas limites & erreurs
|
||||
|
||||
- `getRowsRequest is None` → `no_update`.
|
||||
- `filterModel` vide → AST vide → `where = TRUE`.
|
||||
- Colonne inconnue dans un filtre → ignorée + `logger.warning` (parité avec l'actuel).
|
||||
- Valeur numérique/date invalide → ignorée + warning.
|
||||
- `rowCount` = 0 → la grille affiche « aucune ligne » (gérer le total à 0 sans casser la pagination).
|
||||
- Sécurité : identifiants de colonnes validés contre le schéma, valeurs toujours paramétrées (jamais concaténées).
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unitaires `query_ast.py`** : `ast_to_sql` (feuilles texte accent-insensitive, wildcard `*`, phrase `+`, numérique `=/>/<`, date ; `And/Or/Not` ; groupement). Réutiliser/adapter les cas de `tests/test_table.py` (ex. `test_filter_table_data_accent_insensitive`).
|
||||
- **Unitaires `filtermodel_to_ast`** : chaque type de filtre AG Grid + `operator AND/OR` + `condition1/condition2`.
|
||||
- **Parité SQL** : un `filterModel` simple doit produire le même résultat que l'ancien DSL équivalent (non-régression).
|
||||
- **Intégration (Selenium/DashComposite)** : chargement de la grille, filtre de colonne, tri, pagination, sélecteur de colonnes, export Excel, persistance locale, vue sauvegardée (abonné).
|
||||
- Suite complète `uv run pytest` uniquement en fin de lot.
|
||||
|
||||
## Décisions tranchées
|
||||
|
||||
- **Pagination** : scroll infini (grille à hauteur fixe, en-têtes figés, virtualisation).
|
||||
- **Export Excel** : chemin DuckDB (AST → SQL), pipeline Polars retiré.
|
||||
|
||||
## Reporté au 2e temps / lots suivants
|
||||
|
||||
- Portage des overrides CSS (apparence).
|
||||
- #97 : `query_string_to_ast` + champ de requête avancé (abonnés).
|
||||
- #112 : partage de vue `?vue=<user_id>_<nom>`.
|
||||
- Migration des Lots 2 (`acheteur`/`titulaire`/`observatoire`) et 3 (`recherche`/`admin`/`figures.make_table`), puis suppression de l'ancien DSL (`filter_query_to_sql`, `clean_filters`).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Migration Dash 3.4 → 4.4 — Design (#101)
|
||||
|
||||
> Statut : design validé le 2026-07-09. Prêt pour le plan d'implémentation.
|
||||
> Issues liées : #101 (cette migration), #41 (AG Grid, suite), #111 (serveur MCP, nécessite Dash ≥ 4.3).
|
||||
|
||||
## 1. Objectif & périmètre
|
||||
|
||||
Monter la dépendance `dash` de **3.4.0 à 4.4.x**, sans changement fonctionnel de l'application.
|
||||
|
||||
**Dans le périmètre :**
|
||||
|
||||
- Consolidation et bump de la déclaration `dash` dans `pyproject.toml`.
|
||||
- Validation et, si nécessaire, bump des libs de l'écosystème Dash (`dash-leaflet`, `dash-extensions`, `dash[testing]`).
|
||||
- Acceptation du nouveau style des composants DCC de Dash 4 ; retrait du CSS custom qui entre en conflit.
|
||||
- Corrections des cassures fonctionnelles éventuelles.
|
||||
|
||||
**Hors périmètre (issues dédiées) :**
|
||||
|
||||
- Migration DataTable → AG Grid → **#41**. Les `dash_table.DataTable` restent en place. Elles sont dépréciées mais fonctionnelles en Dash 4.x (retrait prévu seulement en Dash 5.0).
|
||||
- Serveur MCP des données sous auth → **#111** (nécessite Dash ≥ 4.3, donc cette migration en est le prérequis).
|
||||
- Toute nouvelle fonctionnalité tirant parti de Dash 4.
|
||||
|
||||
## 2. Contexte technique constaté
|
||||
|
||||
- `pyproject.toml` déclare `dash` de façon dédoublée : `"dash==3.4.0"` **et** `"dash[compress]"` (non épinglé). À consolider.
|
||||
- Version installée : `dash` 3.4.0, `dash-bootstrap-components` 2.0.4, `dash-leaflet` 1.1.3, `dash-extensions` 2.0.5.
|
||||
- **Aucun** usage de `run_server`, `long_callback`, `LogoutButton`, `_set_react_version` dans `src/` ni `run.py` → les ruptures Dash 3.0 sont déjà absorbées. React 18.3.1 est déjà le défaut depuis Dash 3.
|
||||
- Composants DCC utilisés que Dash 4 restyle : `dcc.Dropdown` (×11, filtres et sélecteurs de colonnes), `dcc.Input` (×23), `dcc.Loading` (×5), `dcc.Checklist` (×2), `dcc.RadioItems` (×1). Pas de `Slider`, `DatePicker`, `Tabs`, `TextArea` → surface visuelle limitée.
|
||||
- `DataTable` est utilisé dans `recherche`, `tableau`, `acheteur`, `titulaire`, `observatoire`, `admin/liste`, et sous-classé dans `src/figures.py` (`class DataTable(dash_table.DataTable)`). `src/utils/table_sql.py` traduit le DSL de filtre DataTable en SQL DuckDB. Tout cela reste inchangé (relève de #41).
|
||||
|
||||
## 3. Décisions de design
|
||||
|
||||
- **Régressions visuelles : accepter le look Dash 4.** On ne corrige que les cassures fonctionnelles (débordement, illisibilité, comportement rompu). Le CSS custom qui entre en conflit avec le restyling DCC est **retiré** plutôt que patché.
|
||||
- **Épinglage :** pin exact sur la dernière 4.4.x, cohérent avec le style actuel (`dash==3.4.0`). Ligne consolidée en `"dash[compress]==4.4.x"`.
|
||||
- **Bump des libs tierces : seulement si nécessaire.** On ne monte `dash-leaflet` / `dash-extensions` que si la compat Dash 4 l'exige.
|
||||
- **Approche de séquencement : bump groupé (A), dans un worktree isolé basé sur `dev`.** Une seule PR. Justifié par le périmètre contenu et l'absence de ruptures majeures.
|
||||
|
||||
## 4. Étapes d'implémentation (haut niveau)
|
||||
|
||||
Le détail sera produit par le skill `writing-plans`. Séquence prévue :
|
||||
|
||||
1. **Validation de compat (premier, car seul vrai risque).** Dans le worktree : consolider/bumper `dash` dans `pyproject.toml`, `uv sync`, `uv run run.py`. Observer la résolution des dépendances et le démarrage. Point le plus à risque : les cartes **Leaflet** (`dash-leaflet` + clustering via `dash-extensions`). Bumper ces libs si cassées.
|
||||
- Si un blocage dur apparaît (lib tierce sans release compatible Dash 4), **arrêt et réévaluation** avant d'aller plus loin.
|
||||
2. **Corrections fonctionnelles.** Vérifier les 11 `dcc.Dropdown` face aux nouveaux défauts Dash 4 (`optionHeight='auto'`, `closeOnSelect` distinct en multi-select) ; corriger uniquement si comportement cassé.
|
||||
3. **Nettoyage CSS.** Retirer de `src/assets/css/` les overrides devenus inutiles ou cassants suite au restyling DCC.
|
||||
4. **Alignement `dash[testing]`** sur la même version dans le groupe `dev`.
|
||||
|
||||
## 5. Vérification (definition of done)
|
||||
|
||||
- `pre-commit` exécuté (ruff) avant tout `git add` / commit (cf. CLAUDE.md).
|
||||
- `uv run pytest` vert (suite pytest/Selenium, nécessite Chrome/Chromium).
|
||||
- `uv run run.py` démarre l'app sans erreur.
|
||||
- Smoke test manuel local des 6 pages principales : `/`, `/acheteur`, `/titulaire`, `/tableau`, `/marche`, `/observatoire`, **plus** cartes Leaflet et exports (xlsx/csv).
|
||||
|
||||
## 6. Livraison
|
||||
|
||||
- Worktree isolé basé sur `dev` → une PR vers `dev`.
|
||||
- Le merge dans `dev` déclenche l'auto-deploy vers **test.colibre.fr** (validation en conditions réelles = bonus, hors DoD strict).
|
||||
|
||||
## 7. Risques & mitigations
|
||||
|
||||
| Risque | Mitigation |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `dash-leaflet` / `dash-extensions` incompatibles Dash 4 | Étape 1 le révèle tôt ; bump ciblé ; blocage dur → réévaluation avant de continuer |
|
||||
| Régressions des cartes non couvertes par les tests | Smoke manuel explicite dans la DoD (cartes Leaflet) |
|
||||
| CSS custom cassant un composant DCC restylé | Retrait de l'override plutôt que patch |
|
||||
| Conflits de peer-dependencies (React/Dash) à `uv sync` | Détectés à l'étape 1 ; résolus par alignement des versions écosystème |
|
||||
@@ -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 |
|
||||
@@ -1,12 +1,11 @@
|
||||
[project]
|
||||
name = "decp.info"
|
||||
name = "colibre"
|
||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||
version = "2.8.1"
|
||||
version = "3.0.0"
|
||||
requires-python = ">= 3.10"
|
||||
authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }]
|
||||
dependencies = [
|
||||
"dash==3.4.0",
|
||||
"dash[compress]",
|
||||
"dash[compress]==4.4.0",
|
||||
"polars",
|
||||
"gunicorn",
|
||||
"dash-bootstrap-components",
|
||||
@@ -21,9 +20,18 @@ dependencies = [
|
||||
"duckdb",
|
||||
"flask-caching",
|
||||
"pyarrow>=23.0.1",
|
||||
"flask-login",
|
||||
"brevo-python==5.0.0rc1",
|
||||
"flask-wtf",
|
||||
"email-validator",
|
||||
"authlib",
|
||||
"orjson",
|
||||
"flask-cors>=6.0.2",
|
||||
"flask-smorest>=0.46.0",
|
||||
"marshmallow>=3.20.0",
|
||||
"boto3",
|
||||
"cryptography",
|
||||
"dash-ag-grid>=35.2.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -33,8 +41,10 @@ dev = [
|
||||
"pre-commit",
|
||||
"selenium",
|
||||
"webdriver-manager",
|
||||
"dash[testing]",
|
||||
"dash[testing]==4.4.0",
|
||||
"fastexcel",
|
||||
"openpyxl",
|
||||
"moto[s3]",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -46,7 +56,17 @@ env = [
|
||||
"REBUILD_DUCKDB=true",
|
||||
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
|
||||
"USERS_DB_PATH=tests/users.test.sqlite",
|
||||
"SECRET_KEY=test-secret-do-not-use-in-prod",
|
||||
"MAIL_FROM=test@colibre.fr",
|
||||
"MAIL_SUPPRESS_SEND=true",
|
||||
"APP_BASE_URL=http://localhost:8050",
|
||||
"SMTP_HOST=localhost",
|
||||
"SMTP_PORT=25",
|
||||
"WTF_CSRF_ENABLED=False",
|
||||
"MATOMO_TRACKING_ENABLED=false",
|
||||
"DATA_SCHEMA_LOCAL=/home/colin/git/decp-processing/dist/schema.json",
|
||||
]
|
||||
addopts = "-p no:warnings"
|
||||
markers = [
|
||||
"integration: tests touchant des services externes (Brevo) ; skippés par défaut en CI",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.auth.db import get_conn
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def log_action(
|
||||
admin_email: str, action: str, target_user_id: int | None, details: str | None
|
||||
) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT INTO admin_actions (admin_email, action, target_user_id, details, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(admin_email, action, target_user_id, details, _now()),
|
||||
)
|
||||
|
||||
|
||||
def list_actions(limit: int = 200) -> list[sqlite3.Row]:
|
||||
return (
|
||||
get_conn()
|
||||
.execute("SELECT * FROM admin_actions ORDER BY id DESC LIMIT ?", (limit,))
|
||||
.fetchall()
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def is_admin() -> bool:
|
||||
admin_email = os.getenv("ADMIN_EMAIL")
|
||||
return bool(
|
||||
admin_email
|
||||
and current_user.is_authenticated
|
||||
and current_user.email.lower() == admin_email.lower()
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from src.auth.db import get_conn
|
||||
from src.subscriptions.db import SUBSCRIPTION_STATUSES
|
||||
from src.subscriptions.plans import PLANS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableConfig:
|
||||
columns: list[str]
|
||||
editable_columns: frozenset[str]
|
||||
pk: str
|
||||
column_types: dict[str, type]
|
||||
dropdowns: dict[str, list[str]]
|
||||
target_user_id: Callable[[dict], int | None]
|
||||
sort_by: str
|
||||
join_user_email: bool = False
|
||||
|
||||
|
||||
TABLES: dict[str, TableConfig] = {
|
||||
"users": TableConfig(
|
||||
columns=[
|
||||
"id",
|
||||
"email",
|
||||
"email_verified",
|
||||
"siret",
|
||||
"pending_email",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"email", "email_verified", "siret", "pending_email"}
|
||||
),
|
||||
pk="id",
|
||||
column_types={
|
||||
"email": str,
|
||||
"email_verified": int,
|
||||
"siret": str,
|
||||
"pending_email": str,
|
||||
},
|
||||
dropdowns={"email_verified": ["0", "1"]},
|
||||
target_user_id=lambda row: row["id"],
|
||||
sort_by="updated_at",
|
||||
),
|
||||
"subscriptions": TableConfig(
|
||||
columns=[
|
||||
"id",
|
||||
"user_id",
|
||||
"email",
|
||||
"frisbii_customer_handle",
|
||||
"frisbii_subscription_handle",
|
||||
"plan",
|
||||
"prix_ht",
|
||||
"status",
|
||||
"current_period_end",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset({"plan", "prix_ht", "status", "current_period_end"}),
|
||||
pk="id",
|
||||
column_types={
|
||||
"plan": str,
|
||||
"prix_ht": float,
|
||||
"status": str,
|
||||
"current_period_end": str,
|
||||
},
|
||||
dropdowns={
|
||||
"status": list(SUBSCRIPTION_STATUSES),
|
||||
"plan": list(PLANS.keys()),
|
||||
},
|
||||
target_user_id=lambda row: row["user_id"],
|
||||
sort_by="updated_at",
|
||||
join_user_email=True,
|
||||
),
|
||||
"subscriber_state": TableConfig(
|
||||
columns=[
|
||||
"user_id",
|
||||
"email",
|
||||
"trial_used",
|
||||
"votes_balance",
|
||||
"votes_last_credited_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"trial_used", "votes_balance", "votes_last_credited_at"}
|
||||
),
|
||||
pk="user_id",
|
||||
column_types={
|
||||
"trial_used": int,
|
||||
"votes_balance": int,
|
||||
"votes_last_credited_at": str,
|
||||
},
|
||||
dropdowns={"trial_used": ["0", "1"]},
|
||||
target_user_id=lambda row: row["user_id"],
|
||||
sort_by="updated_at",
|
||||
join_user_email=True,
|
||||
),
|
||||
"admin_actions": TableConfig(
|
||||
columns=[
|
||||
"id",
|
||||
"admin_email",
|
||||
"action",
|
||||
"target_user_id",
|
||||
"details",
|
||||
"created_at",
|
||||
],
|
||||
editable_columns=frozenset(),
|
||||
pk="id",
|
||||
column_types={},
|
||||
dropdowns={},
|
||||
target_user_id=lambda row: None,
|
||||
sort_by="created_at",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_rows(table: str) -> list[dict]:
|
||||
cfg = TABLES[table]
|
||||
if cfg.join_user_email:
|
||||
real_cols = [col for col in cfg.columns if col != "email"]
|
||||
cols_sql = ", ".join(f"{table}.{col}" for col in real_cols)
|
||||
query = (
|
||||
f"SELECT {cols_sql}, users.email AS email FROM {table} "
|
||||
f"LEFT JOIN users ON users.id = {table}.user_id "
|
||||
f"ORDER BY {table}.{cfg.sort_by} DESC"
|
||||
)
|
||||
else:
|
||||
cols_sql = ", ".join(cfg.columns)
|
||||
query = f"SELECT {cols_sql} FROM {table} ORDER BY {cfg.sort_by} DESC"
|
||||
rows = get_conn().execute(query).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _coerce_value(table: str, column: str, value):
|
||||
cfg = TABLES[table]
|
||||
if column not in cfg.editable_columns:
|
||||
raise ValueError(f"Colonne non éditable : {column}")
|
||||
if column in cfg.dropdowns and str(value) not in cfg.dropdowns[column]:
|
||||
raise ValueError(f"Valeur non autorisée pour {column} : {value!r}")
|
||||
expected_type = cfg.column_types[column]
|
||||
try:
|
||||
if expected_type is int:
|
||||
return int(value)
|
||||
if expected_type is float:
|
||||
return float(value)
|
||||
return str(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Valeur invalide pour {column} : {value!r}") from exc
|
||||
|
||||
|
||||
def set_cell(table: str, pk_value, column: str, value) -> None:
|
||||
if table not in TABLES:
|
||||
raise ValueError(f"Table inconnue : {table}")
|
||||
cfg = TABLES[table]
|
||||
coerced = _coerce_value(table, column, value)
|
||||
try:
|
||||
cursor = get_conn().execute(
|
||||
f"UPDATE {table} SET {column} = ? WHERE {cfg.pk} = ?", (coerced, pk_value)
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ValueError(
|
||||
f"Écriture refusée pour {column} (contrainte violée) : {value!r}"
|
||||
) from exc
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError(
|
||||
f"Ligne introuvable (table={table}, {cfg.pk}={pk_value!r}) — "
|
||||
"probablement supprimée entre-temps."
|
||||
)
|
||||
|
||||
|
||||
def find_changed_cell(
|
||||
data: list[dict], data_previous: list[dict] | None
|
||||
) -> tuple[int, str, object, object] | None:
|
||||
if data_previous is None or len(data) != len(data_previous):
|
||||
return None
|
||||
if not data or not data_previous:
|
||||
return None
|
||||
if set(data[0].keys()) != set(data_previous[0].keys()):
|
||||
return None
|
||||
for i, (new_row, old_row) in enumerate(zip(data, data_previous)):
|
||||
for col, new_val in new_row.items():
|
||||
old_val = old_row.get(col)
|
||||
if new_val != old_val:
|
||||
return i, col, old_val, new_val
|
||||
return None
|
||||
@@ -5,7 +5,7 @@ from src.api import routes
|
||||
|
||||
def init_api(server) -> None:
|
||||
"""Enregistre le blueprint d'API privée sur le serveur Flask."""
|
||||
server.config.setdefault("API_TITLE", "decp.info API")
|
||||
server.config.setdefault("API_TITLE", "colibre API")
|
||||
server.config.setdefault("API_VERSION", "v1")
|
||||
server.config.setdefault("OPENAPI_VERSION", "3.0.3")
|
||||
server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1")
|
||||
|
||||
@@ -16,6 +16,7 @@ def _abort_401(message: str):
|
||||
def require_token(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
print(API_AUTH_DISABLED)
|
||||
if not API_AUTH_DISABLED:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from flask import g, request
|
||||
import orjson
|
||||
from flask import Response, g, request
|
||||
from flask_smorest import Blueprint, abort
|
||||
|
||||
from src.api import tracking
|
||||
@@ -12,7 +13,7 @@ bp = Blueprint(
|
||||
"api_v1",
|
||||
"api_v1",
|
||||
url_prefix="/api/v1",
|
||||
description="API privée decp.info — accès tabulaire aux marchés publics.",
|
||||
description="API privée colibre — accès tabulaire aux marchés publics.",
|
||||
)
|
||||
|
||||
MAX_PAGE_SIZE = 1000
|
||||
@@ -172,9 +173,6 @@ def data():
|
||||
Exemple d'agrégation :
|
||||
`?acheteur_departement_code__groupby&uid__count&montant__sum`
|
||||
"""
|
||||
import polars as pl
|
||||
import polars.selectors as cs
|
||||
|
||||
page, page_size = _parse_pagination()
|
||||
columns = _parse_columns()
|
||||
count_results = request.args.get("count_results", "true").lower() != "false"
|
||||
@@ -201,16 +199,20 @@ def data():
|
||||
limit=page_size,
|
||||
offset=(page - 1) * page_size,
|
||||
)
|
||||
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||
# Si la page est partielle, on connaît le total exact ; sinon on ne sait pas.
|
||||
agg_total = (
|
||||
(page - 1) * page_size + df.height if df.height < page_size else None
|
||||
)
|
||||
return {
|
||||
"data": df_ready.to_dicts(),
|
||||
"meta": {"page": page, "page_size": page_size},
|
||||
"links": _build_links(page, page_size, agg_total),
|
||||
}
|
||||
return Response(
|
||||
orjson.dumps(
|
||||
{
|
||||
"data": df.to_dicts(),
|
||||
"meta": {"page": page, "page_size": page_size},
|
||||
"links": _build_links(page, page_size, agg_total),
|
||||
}
|
||||
),
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
df = query_marches(
|
||||
where_sql=where_sql,
|
||||
@@ -221,16 +223,18 @@ def data():
|
||||
offset=(page - 1) * page_size,
|
||||
)
|
||||
|
||||
# JSON ne sérialise pas date/datetime nativement → cast en string ISO
|
||||
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||
|
||||
total = count_marches(where_sql, params) if count_results else None
|
||||
meta = {"page": page, "page_size": page_size}
|
||||
if total is not None:
|
||||
meta["total"] = total
|
||||
|
||||
return {
|
||||
"data": df_ready.to_dicts(),
|
||||
"meta": meta,
|
||||
"links": _build_links(page, page_size, total),
|
||||
}
|
||||
return Response(
|
||||
orjson.dumps(
|
||||
{
|
||||
"data": df.to_dicts(),
|
||||
"meta": meta,
|
||||
"links": _build_links(page, page_size, total),
|
||||
}
|
||||
),
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
TOKEN_PREFIX = "decpinfo_"
|
||||
TOKEN_PREFIX = "colibre_"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
|
||||
@@ -59,7 +59,7 @@ def enqueue_matomo_event(
|
||||
site_id = os.getenv("MATOMO_SITE_ID")
|
||||
if not url or not site_id:
|
||||
return
|
||||
full_url = f"https://decp.info{path}"
|
||||
full_url = f"https://colibre.fr{path}"
|
||||
if query_string:
|
||||
full_url += f"?{query_string}"
|
||||
params = {
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
# ruff: noqa: E402 -- sys.path manipulation must precede third-party imports
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
|
||||
# Sur les serveurs où le package est installé en mode éditable, pip peut ajouter
|
||||
# src/ à sys.path via un fichier .pth. Combiné à la racine du projet (déjà dans
|
||||
# sys.path via gunicorn), Dash's use_pages enregistre alors chaque page deux fois :
|
||||
# une fois comme pages.X et une fois comme src.pages.X → erreur "duplicate paths".
|
||||
_src_dir = str(Path(__file__).parent.resolve())
|
||||
while _src_dir in sys.path:
|
||||
sys.path.remove(_src_dir)
|
||||
|
||||
import dash_bootstrap_components as dbc
|
||||
import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads
|
||||
import tomllib
|
||||
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
|
||||
from dash import (
|
||||
ALL,
|
||||
Dash,
|
||||
Input,
|
||||
Output,
|
||||
State,
|
||||
callback,
|
||||
ctx,
|
||||
dcc,
|
||||
html,
|
||||
page_container,
|
||||
page_registry,
|
||||
)
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, Response
|
||||
from flask import Flask, Response, redirect
|
||||
from flask_login import current_user
|
||||
|
||||
from src.auth.setup import init_auth
|
||||
from src.utils import DEVELOPMENT
|
||||
from src.utils.cache import cache
|
||||
|
||||
@@ -20,7 +45,7 @@ META_TAGS = [
|
||||
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
|
||||
{
|
||||
"name": "keywords",
|
||||
"content": "commande publique, decp, marchés publics, données essentielles",
|
||||
"content": "commande publique, decp, marchés publics, données essentielles, colibre",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -32,7 +57,7 @@ if DEVELOPMENT:
|
||||
# fonctions memoizées (@cache.memoize) dès l'import (ex. tableau.py).
|
||||
server = Flask(__name__)
|
||||
|
||||
cache_dir = os.getenv("CACHE_DIR", "/tmp/decp-cache")
|
||||
cache_dir = os.getenv("CACHE_DIR", "/tmp/colibre-cache")
|
||||
|
||||
if os.path.exists(cache_dir):
|
||||
rmtree(cache_dir)
|
||||
@@ -49,46 +74,104 @@ cache.init_app(
|
||||
},
|
||||
)
|
||||
|
||||
_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true"
|
||||
|
||||
app: Dash = Dash(
|
||||
server=server,
|
||||
title="decp.info",
|
||||
# name="src" (et non "src.app") pour que use_pages enregistre les pages sous
|
||||
# le namespace `src.pages.*`, identique aux imports inter-pages (ex.
|
||||
# inscription.py: `from src.pages.connexion import linkedin_button`). Sinon
|
||||
# Dash découvre `pages.connexion` tandis que l'import explicite crée
|
||||
# `src.pages.connexion` : deux identités, même URL → "duplicate paths" →
|
||||
# check_for_duplicate_pathnames lève à chaque requête → 500 sur toute l'app.
|
||||
name="src",
|
||||
title="Colibre",
|
||||
use_pages=True,
|
||||
suppress_callback_exceptions=True,
|
||||
compress=True,
|
||||
enable_mcp=_mcp_enabled,
|
||||
meta_tags=META_TAGS,
|
||||
)
|
||||
|
||||
init_auth(app.server)
|
||||
|
||||
# Exempter les routes internes de Dash de la protection CSRF.
|
||||
# Dash enregistre ses routes directement sur le serveur Flask (pas via blueprint),
|
||||
# donc on itère la url_map après init_auth pour cibler les bons endpoints.
|
||||
from src.auth.setup import _csrf as _auth_csrf # noqa: E402
|
||||
|
||||
if _auth_csrf is not None:
|
||||
for _rule in app.server.url_map.iter_rules():
|
||||
if _rule.rule.startswith("/_dash") or _rule.rule.startswith("/_reload"):
|
||||
_vf = app.server.view_functions.get(_rule.endpoint)
|
||||
if _vf is not None:
|
||||
_auth_csrf.exempt(_vf)
|
||||
|
||||
from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready first
|
||||
|
||||
init_api(app.server)
|
||||
|
||||
# Serveur MCP (issue #111, scope A) : n'expose QUE les fonctions @mcp_enabled,
|
||||
# jamais les callbacks/layout/pages d'UI. Activé via DASH_MCP_ENABLED=true.
|
||||
if _mcp_enabled:
|
||||
from dash.mcp import configure_mcp_server # noqa: E402
|
||||
|
||||
configure_mcp_server(
|
||||
include_layout=False,
|
||||
include_callbacks=False,
|
||||
include_pages=False,
|
||||
include_clientside_callbacks=False,
|
||||
)
|
||||
import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled
|
||||
|
||||
from src.subscriptions.setup import init_subscriptions # noqa: E402
|
||||
|
||||
init_subscriptions(app.server)
|
||||
|
||||
from src.saved_views import db as saved_views_db # noqa: E402
|
||||
|
||||
saved_views_db.init_schema()
|
||||
|
||||
from src.roadmap import db as roadmap_db # noqa: E402
|
||||
|
||||
roadmap_db.init_schema()
|
||||
|
||||
|
||||
# robots.txt
|
||||
@app.server.route("/robots.txt")
|
||||
def robots():
|
||||
text = """User-agent: *
|
||||
Allow: /
|
||||
"""
|
||||
Sitemap: https://colibre.fr/sitemap.xml
|
||||
"""
|
||||
return Response(text, mimetype="text/plain")
|
||||
|
||||
|
||||
# Index de sitemaps + sous-sitemaps paginés (voir src.utils.sitemap).
|
||||
from src.utils import sitemap as _sitemap # noqa: E402 # src.db doit être prêt
|
||||
|
||||
|
||||
@app.server.route("/sitemap.xml")
|
||||
def sitemap():
|
||||
base_url = "https://decp.info"
|
||||
pages = [
|
||||
"/",
|
||||
"/observatoire",
|
||||
"/tableau",
|
||||
"/a-propos",
|
||||
"/etapes",
|
||||
]
|
||||
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
for page in pages:
|
||||
xml += " <url>\n"
|
||||
xml += f" <loc>{base_url}{page}</loc>\n"
|
||||
xml += " </url>\n"
|
||||
xml += "</urlset>"
|
||||
return Response(xml, mimetype="text/xml")
|
||||
return Response(_sitemap.build_index(), mimetype="application/xml")
|
||||
|
||||
|
||||
@app.server.route("/sitemap-pages.xml")
|
||||
def sitemap_pages():
|
||||
return Response(_sitemap.build_pages(), mimetype="application/xml")
|
||||
|
||||
|
||||
@app.server.route("/sitemap-<segment>-<int:page>.xml")
|
||||
def sitemap_org(segment: str, page: int):
|
||||
xml = _sitemap.build_org_page(segment, page)
|
||||
if xml is None:
|
||||
return Response("Not found", status=404)
|
||||
return Response(xml, mimetype="application/xml")
|
||||
|
||||
|
||||
@app.server.route("/llms.txt")
|
||||
def llms():
|
||||
return redirect("/assets/llms.md")
|
||||
|
||||
|
||||
with open("./pyproject.toml", "rb") as f:
|
||||
@@ -102,9 +185,21 @@ app.index_string = """
|
||||
<head>
|
||||
{%metas%}
|
||||
<title>{%title%}</title>
|
||||
{%favicon%}
|
||||
<link rel="shortcut icon" href="/assets/icons/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png">
|
||||
<link rel="manifest" href="/assets/icons/site.webmanifest">
|
||||
{%css%}
|
||||
<!-- canonical link -->
|
||||
<!-- canonical auto-référent : l'index_string Dash est partagé par toutes
|
||||
les pages, donc on pose le href côté client d'après l'URL courante
|
||||
(sans query string). Google exécute le JS au rendu. -->
|
||||
<link rel="canonical" id="canonical-link">
|
||||
<script type="application/javascript">
|
||||
document.getElementById('canonical-link').setAttribute(
|
||||
'href', window.location.origin + window.location.pathname
|
||||
);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{%app_entry%}
|
||||
@@ -139,12 +234,12 @@ navbar = dbc.Navbar(
|
||||
children=[
|
||||
html.Div(
|
||||
[
|
||||
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
|
||||
dcc.Link(html.H1("colibre"), href="/", className="logo"),
|
||||
html.P(
|
||||
[
|
||||
html.A(
|
||||
version,
|
||||
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
|
||||
href="/a-propos/roadmap",
|
||||
)
|
||||
],
|
||||
className="version",
|
||||
@@ -177,14 +272,17 @@ navbar = dbc.Navbar(
|
||||
dbc.NavItem(
|
||||
dbc.NavLink(
|
||||
page["name"].replace(" ", " "),
|
||||
href=page["relative_path"],
|
||||
href=page["relative_path"] + "/presentation"
|
||||
if page["name"] == "À propos"
|
||||
else page["relative_path"],
|
||||
active="exact",
|
||||
)
|
||||
)
|
||||
for page in page_registry.values()
|
||||
if page["name"]
|
||||
in ["Recherche", "À propos", "Tableau", "Observatoire"]
|
||||
],
|
||||
]
|
||||
+ [html.Div(id="auth-nav-slot")],
|
||||
className="ms-auto",
|
||||
navbar=True,
|
||||
),
|
||||
@@ -201,6 +299,7 @@ navbar = dbc.Navbar(
|
||||
|
||||
app.layout = html.Div(
|
||||
[
|
||||
dcc.Store(id="csrf-token"),
|
||||
navbar,
|
||||
dbc.Container(
|
||||
page_container,
|
||||
@@ -221,3 +320,35 @@ def toggle_navbar_collapse(n, is_open):
|
||||
if n:
|
||||
return not is_open
|
||||
return is_open
|
||||
|
||||
|
||||
@callback(
|
||||
Output("auth-nav-slot", "children"),
|
||||
Input("auth-nav-slot", "id"),
|
||||
)
|
||||
def _auth_nav(_):
|
||||
if current_user.is_authenticated:
|
||||
# email = current_user.email
|
||||
# display = email if len(email) <= 30 else email[:27] + "..."
|
||||
display = "★★★"
|
||||
return dbc.NavItem(dbc.NavLink(display, href="/compte/admin"))
|
||||
return dbc.NavItem(dbc.NavLink("Connexion", href="/connexion"))
|
||||
|
||||
|
||||
@callback(
|
||||
Output("csrf-token", "data"),
|
||||
Input("_pages_location", "pathname"),
|
||||
Input("auth-nav-slot", "children"),
|
||||
)
|
||||
def _generate_csrf_token(*_):
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
|
||||
return generate_csrf()
|
||||
|
||||
|
||||
@callback(
|
||||
Output({"type": "csrf-input", "index": ALL}, "value"),
|
||||
Input("csrf-token", "data"),
|
||||
)
|
||||
def _fill_csrf_inputs(token):
|
||||
return [token] * len(ctx.outputs_list)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>decp.info — indisponible</title>
|
||||
<style>
|
||||
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600);
|
||||
|
||||
:root {
|
||||
--primary-color: rgb(179, 56, 33);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
background-color: rgb(255 240 240 / 40%);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
a.logo {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.logo h1 {
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
a.logo h1 span {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
p.version {
|
||||
font-family: "Fira Code", monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
padding: 40px 48px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #444;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card p:last-child {
|
||||
margin-bottom: 0;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo-wrapper">
|
||||
<a class="logo" href="/">
|
||||
<h1>decp<span>.info</span></h1>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
<h2>Site temporairement indisponible</h2>
|
||||
<p>
|
||||
decp.info rencontre actuellement un problème technique. Une alerte m'a
|
||||
été envoyée et je travaille à rétablir le service.
|
||||
</p>
|
||||
<p>
|
||||
Le site devrait être de nouveau disponible prochainement. Merci de
|
||||
votre patience.
|
||||
</p>
|
||||
<p>Colin Maudry (colin@colmo.tech)</p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 484 KiB |
@@ -7,6 +7,10 @@
|
||||
--bs-font-monospace: "Fira Code";
|
||||
--primary-color: rgb(179, 56, 33);
|
||||
--primary-color-text: #b33821;
|
||||
/* Dash 4 utilise cette variable pour l'état actif/focus des composants
|
||||
dcc (Dropdown, Input, Tabs, DatePicker...) ; défaut violet remplacé
|
||||
par notre couleur primaire. */
|
||||
--Dash-Fill-Interactive-Strong: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -39,21 +43,37 @@ h3 {
|
||||
margin: 36px 0 20px 0;
|
||||
}
|
||||
|
||||
/* Base Button Styles
|
||||
button {
|
||||
font-weight: 400;
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
appearance: auto;
|
||||
border: solid var(--primary-color) 1px;
|
||||
} */
|
||||
/* ==========================================================================
|
||||
Boutons — charte à 3 rôles (couleur = fonction, remplissage = emphase)
|
||||
Voir docs/superpowers/specs/2026-06-30-charte-boutons-design.md
|
||||
Override scopé aux classes .btn-* uniquement (n'affecte pas les alertes,
|
||||
badges, ni text-danger qui héritent de la sémantique Simplex).
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--btn-terracotta: #b33821;
|
||||
--btn-terracotta-text: #fff;
|
||||
--btn-secondary-text: #344054;
|
||||
--btn-secondary-border: #5a6570;
|
||||
--btn-secondary-hover-bg: #f1f3f5;
|
||||
--btn-danger: #c0392b;
|
||||
--btn-danger-text: #fff;
|
||||
}
|
||||
|
||||
/* Base commune : rayon + typo */
|
||||
.btn {
|
||||
border-radius: 3px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* --- PRIMARY plein : action principale validante (1 par contexte) --- */
|
||||
button.btn.btn-primary,
|
||||
a.btn.btn-primary,
|
||||
button.show-hide {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
outline: 0;
|
||||
color: #fff;
|
||||
color: var(--btn-terracotta-text);
|
||||
border: 0;
|
||||
height: 30px;
|
||||
padding-top: 2px;
|
||||
@@ -65,7 +85,9 @@ button.show-hide {
|
||||
}
|
||||
|
||||
button.btn.btn-primary:hover,
|
||||
a.btn.btn-primary:hover,
|
||||
button.show-hide:hover {
|
||||
color: var(--btn-terracotta-text);
|
||||
background-image: linear-gradient(
|
||||
rgb(239, 126, 103),
|
||||
rgb(209, 86, 63) 26%,
|
||||
@@ -73,18 +95,82 @@ button.show-hide:hover {
|
||||
);
|
||||
}
|
||||
|
||||
/* --- PRIMARY outline : action affirmative de moindre emphase --- */
|
||||
.btn.btn-outline-primary {
|
||||
color: var(--btn-terracotta);
|
||||
border: 1px solid var(--btn-terracotta);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-outline-primary:hover,
|
||||
.btn.btn-outline-primary:focus-visible {
|
||||
color: var(--btn-terracotta-text);
|
||||
background-color: var(--btn-terracotta);
|
||||
border-color: var(--btn-terracotta);
|
||||
}
|
||||
|
||||
/* --- SECONDARY : action neutre / alternative (gris ardoise) --- */
|
||||
.btn.btn-secondary,
|
||||
.btn.btn-outline-secondary {
|
||||
color: var(--btn-secondary-text);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-secondary:hover,
|
||||
.btn.btn-secondary:focus-visible,
|
||||
.btn.btn-outline-secondary:hover,
|
||||
.btn.btn-outline-secondary:focus-visible {
|
||||
color: var(--btn-secondary-text);
|
||||
background-color: var(--btn-secondary-hover-bg);
|
||||
border-color: var(--btn-secondary-border);
|
||||
}
|
||||
|
||||
/* --- DANGER outline : action destructive dans le flux courant --- */
|
||||
.btn.btn-outline-danger {
|
||||
color: var(--btn-danger);
|
||||
border: 1px solid var(--btn-danger);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-outline-danger:hover,
|
||||
.btn.btn-outline-danger:focus-visible {
|
||||
color: var(--btn-danger-text);
|
||||
background-color: var(--btn-danger);
|
||||
border-color: var(--btn-danger);
|
||||
}
|
||||
|
||||
/* --- DANGER plein : confirmation finale destructive (modale) --- */
|
||||
.btn.btn-danger {
|
||||
color: var(--btn-danger-text);
|
||||
border: 1px solid var(--btn-danger);
|
||||
background-color: var(--btn-danger);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-danger:hover,
|
||||
.btn.btn-danger:focus-visible {
|
||||
color: var(--btn-danger-text);
|
||||
background-color: #a93226;
|
||||
border-color: #a93226;
|
||||
}
|
||||
|
||||
/* --- État désactivé commun (conserve l'existant) --- */
|
||||
.btn[disabled],
|
||||
button[disabled] {
|
||||
border-color: #ccc;
|
||||
color: #666;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
button:hover:not([disabled]) {
|
||||
background-color: #fee;
|
||||
}
|
||||
|
||||
/* Global Link Styles */
|
||||
#_pages_content a {
|
||||
color: #993333;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn {
|
||||
transition: background-color 0.15s ease, color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -138,6 +224,24 @@ p.version > a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-nav .nav-link {
|
||||
color: inherit;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.account-nav .nav-link:hover {
|
||||
background-color: rgba(255, 240, 240, 0.9);
|
||||
}
|
||||
|
||||
.account-nav .nav-link.active {
|
||||
color: #d9230f;
|
||||
font-weight: 600;
|
||||
border-left-color: #d9230f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#announcements {
|
||||
margin: 25px 40px 0 60px;
|
||||
font-size: 90%;
|
||||
@@ -181,13 +285,14 @@ p.version > a {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
/* --- Dashboard inputs --- */
|
||||
/* Account form */
|
||||
|
||||
.Select--multi .Select-value {
|
||||
color: var(--primary-color) !important;
|
||||
background-color: rgba(255, 240, 240, 0.4) !important;
|
||||
input[type="email"] {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* --- Dashboard inputs --- */
|
||||
|
||||
#filters .row > * {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -214,6 +319,55 @@ p.version > a {
|
||||
margin: 8px 16px 8px 0;
|
||||
}
|
||||
|
||||
/* Barre d'outils du tableau (/tableau) : une rangée d'actions compactes
|
||||
au-dessus d'une ligne d'infos discrète. .table-menu (autres pages) intacte. */
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin: 14px 0 6px 0;
|
||||
}
|
||||
|
||||
.table-toolbar > * {
|
||||
margin: 0 12px 8px 0;
|
||||
}
|
||||
|
||||
/* Mode d'emploi : aide auxiliaire, rejetée en fin de barre sans toucher au DOM */
|
||||
.table-toolbar #tableau_help_open {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.table-meta {
|
||||
margin: 0 0 16px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Raison du téléchargement bloqué : visible (pas une infobulle), mise en avant
|
||||
sobrement avec la couleur terracotta de la marque. */
|
||||
.table-meta .dl-hint {
|
||||
color: #b33821;
|
||||
}
|
||||
|
||||
/* Légende des boutons en tête du mode d'emploi : boutons reproduits à
|
||||
l'identique mais inertes, alignés en face de leur fonction. */
|
||||
.help-legend-table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.help-legend-table td {
|
||||
padding: 4px 16px 4px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.help-legend-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.help-legend .btn {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#source_table {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
@@ -354,15 +508,84 @@ table.cell-table th {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Bootstrap's CSS cascade defeats dash_table's own inline `display: none`
|
||||
toggle on dropdown-cell menus (react-select v1 renders the menu hidden
|
||||
first to measure it, then flips to visible — Bootstrap's reset wins that
|
||||
fight, so the menu never becomes visible even though the cell's "open"
|
||||
state is correct). Applies to every editable dropdown column in every
|
||||
DataTable, not just one table. See:
|
||||
https://github.com/plotly/dash-table/issues/956 */
|
||||
.Select-menu-outer {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.marches_table.stuck {
|
||||
position: relative;
|
||||
right: 200px;
|
||||
}
|
||||
|
||||
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
|
||||
|
||||
/* Contenir le scroll horizontal dans le conteneur Dash (élimine la scrollbar native en bas de page) */
|
||||
/* overflow-y:clip évite la conversion CSS visible→auto qui ajouterait une scrollbar verticale */
|
||||
.marches_table .dash-spreadsheet-container {
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: clip !important;
|
||||
}
|
||||
|
||||
/* L'inner reste visible pour que le tableau se déploie librement en largeur */
|
||||
.marches_table .dash-spreadsheet-inner {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.marches_table .cell-table tr:nth-child(even) td {
|
||||
background-color: rgb(255 240 240 / 40%);
|
||||
}
|
||||
|
||||
/* Colonne "Marché" : lien loupe centré et sans soulignement */
|
||||
table.cell-table td[data-dash-column="marche"] .dash-cell-value {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
td[data-dash-column="marche"] a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
th[data-dash-column="marche"].dash-filter input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Barre de défilement horizontale, collée en haut du tableau */
|
||||
.marches_table .dt-hscroll {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 11;
|
||||
height: 12px;
|
||||
background-color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.marches_table .dt-hscroll.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Thumb custom — toujours visible, draggable */
|
||||
.marches_table .dt-hscroll-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
height: calc(100% - 4px);
|
||||
min-width: 40px;
|
||||
background-color: orange;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.marches_table .dt-hscroll-thumb:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Column Visibility Menu */
|
||||
.column-actions {
|
||||
margin-right: 8px;
|
||||
@@ -551,6 +774,13 @@ input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* Dash 4 ajoute des boutons +/- (steppers) sur les dcc.Input type="number" ;
|
||||
inutiles ici (pas d'incrémentation pertinente pour des montants), on les
|
||||
masque pour que le champ récupère l'espace (flex: 1 1 0 sur l'input). */
|
||||
.dash-input-stepper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Page /etapes : graphique données par étape et par seuil ===== */
|
||||
|
||||
.etapes-chart-scroll {
|
||||
@@ -755,6 +985,11 @@ input[type="number"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Roadmap */
|
||||
|
||||
.roadmap-vote-list input[type="text"] {
|
||||
}
|
||||
|
||||
/* --- Bascule desktop / mobile au point de rupture 768 px --- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -765,3 +1000,30 @@ input[type="number"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Écran de chargement initial (avant hydratation de l'app Dash) */
|
||||
|
||||
._dash-loading {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
._dash-loading::after {
|
||||
content: "Chargement...";
|
||||
font-size: 1.1rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.plan-selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.plan-selectable .card {
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.plan-selectable.selected .card {
|
||||
background-color: var(--bs-primary-bg-subtle);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
window.dash_clientside = Object.assign({}, window.dash_clientside, {
|
||||
leaflet: {
|
||||
pointToLayer: function (feature, latlng, context) {
|
||||
// Le point de l'organisme consulté (is_home) est rendu comme une
|
||||
// icône (comme les clusters), pas comme un circleMarker SVG : les
|
||||
// icônes vivent dans le markerPane, toujours au-dessus du overlayPane
|
||||
// (SVG) où se trouvent les circleMarkers, et gardent une taille CSS
|
||||
// fixe (contrairement aux tracés SVG, redimensionnés visuellement
|
||||
// pendant l'animation de zoom).
|
||||
if (feature.properties.is_home) {
|
||||
const size = 22;
|
||||
const icon = L.divIcon({
|
||||
html: `<div style="background-color: ${feature.properties.marker_color}; width: ${size}px; height: ${size}px; border-radius: 50%; border: 2px solid white; box-sizing: border-box;"></div>`,
|
||||
className: "org-home-marker",
|
||||
iconSize: L.point(size, size),
|
||||
});
|
||||
return L.marker(latlng, { icon: icon, zIndexOffset: 1000 }).bindTooltip(
|
||||
feature.properties.tooltip
|
||||
);
|
||||
}
|
||||
return L.circleMarker(latlng, {
|
||||
radius: 5,
|
||||
fillColor: feature.properties.marker_color,
|
||||
|
||||
|
Before Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 537 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"colibre","short_name":"colibre","icons":[{"src":"/assets/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#b33821","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -0,0 +1,26 @@
|
||||
# colibre
|
||||
|
||||
colibre est une plateforme permettant d'explorer, filtrer et visualiser les données des marés publics français. Elle est alimentée par des données publiées en Open Data et propose également une API REST JSON accessible sur abonnement (contacter <colin@colmo.tech>).
|
||||
|
||||
## Schéma des données
|
||||
|
||||
Le schéma des données est au format TableSchema et peut être librement téléchargé à cette adresse : <https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a>
|
||||
|
||||
## Données source
|
||||
|
||||
Les données sont publiées en Open Data aux formats Parquet et CSV :
|
||||
|
||||
- Parquet : <https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432>
|
||||
- CSV : <https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52>
|
||||
|
||||
## API
|
||||
|
||||
L'API nécessite un jeton d'authentification pour se connecter. Elle utilise le même schéma que les données et renvoie les données au format JSON.
|
||||
|
||||
- Point d'accès : <https://colibre.fr/api/v1/data>
|
||||
- Spec Open API : <https://colibre.fr/api/v1/openapi.json>
|
||||
- Health : <https://colibre.fr/api/v1/health>
|
||||
|
||||
## Sources de données
|
||||
|
||||
Les données proviennent de nombreuses sources de données, le projet publie des statistiques au format CSV : <https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234>
|
||||
@@ -0,0 +1,67 @@
|
||||
/* FLIP animation for #roadmap-vote-list — only animates items that move */
|
||||
|
||||
(function () {
|
||||
var firstPositions = {};
|
||||
var debounceTimer = null;
|
||||
var listObserver = null;
|
||||
var observedList = null;
|
||||
|
||||
function getKey(el) {
|
||||
var a = el.querySelector("a");
|
||||
return a ? a.getAttribute("href") : el.textContent.trim().slice(0, 40);
|
||||
}
|
||||
|
||||
function capturePositions() {
|
||||
var list = document.getElementById("roadmap-vote-list");
|
||||
if (!list) return;
|
||||
firstPositions = {};
|
||||
list.querySelectorAll(".list-group-item").forEach(function (el) {
|
||||
firstPositions[getKey(el)] = el.getBoundingClientRect().top;
|
||||
});
|
||||
}
|
||||
|
||||
function applyFlip() {
|
||||
var list = document.getElementById("roadmap-vote-list");
|
||||
if (!list || Object.keys(firstPositions).length === 0) return;
|
||||
list.querySelectorAll(".list-group-item").forEach(function (el) {
|
||||
var key = getKey(el);
|
||||
var first = firstPositions[key];
|
||||
if (first === undefined) return;
|
||||
var dy = first - el.getBoundingClientRect().top;
|
||||
if (Math.abs(dy) < 2) return;
|
||||
el.style.transition = "none";
|
||||
el.style.transform = "translateY(" + dy + "px)";
|
||||
void el.offsetWidth;
|
||||
el.style.transition = "transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
el.style.transform = "";
|
||||
});
|
||||
firstPositions = {};
|
||||
}
|
||||
|
||||
function attachObserver() {
|
||||
var list = document.getElementById("roadmap-vote-list");
|
||||
if (!list || list === observedList) return;
|
||||
if (listObserver) listObserver.disconnect();
|
||||
observedList = list;
|
||||
listObserver = new MutationObserver(function () {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(applyFlip, 50);
|
||||
});
|
||||
listObserver.observe(list, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
if (e.target.closest('[id*="roadmap-vote"]')) capturePositions();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
setInterval(attachObserver, 1000);
|
||||
attachObserver();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,154 @@
|
||||
// Barre de défilement horizontale pour les tableaux (.marches_table) — #82
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function setup(wrapper) {
|
||||
if (wrapper.dataset.hscrollReady === "1") return;
|
||||
|
||||
const dashContainer = wrapper.querySelector(".dash-spreadsheet-container");
|
||||
if (!dashContainer) return;
|
||||
|
||||
// Garde posée après la vérification de dashContainer, avant toute manipulation DOM
|
||||
// qui déclencherait rootObs et provoquerait une re-entrée dans setup().
|
||||
wrapper.dataset.hscrollReady = "1";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "dt-hscroll is-hidden";
|
||||
const thumb = document.createElement("div");
|
||||
thumb.className = "dt-hscroll-thumb";
|
||||
bar.appendChild(thumb);
|
||||
wrapper.insertBefore(bar, wrapper.firstChild);
|
||||
|
||||
// Métriques basées sur le conteneur scrollable (pas la page).
|
||||
// dashContainer a overflow-x:hidden → scrollLeft est contrôlable par JS.
|
||||
const metrics = () => {
|
||||
const total = dashContainer.scrollWidth;
|
||||
const visible = dashContainer.clientWidth;
|
||||
const thumbW = Math.max(40, (visible / total) * visible);
|
||||
const scrollRange = total - visible;
|
||||
const thumbRange = visible - thumbW;
|
||||
return { total, visible, thumbW, scrollRange, thumbRange };
|
||||
};
|
||||
|
||||
// Mise à jour de la position du thumb selon dashContainer.scrollLeft.
|
||||
const syncThumb = () => {
|
||||
const { total, visible, thumbW, scrollRange, thumbRange } = metrics();
|
||||
if (total <= visible + 1 || thumbRange <= 0) return;
|
||||
thumb.style.width = thumbW + "px";
|
||||
const fraction =
|
||||
scrollRange > 0 ? dashContainer.scrollLeft / scrollRange : 0;
|
||||
thumb.style.left = Math.round(fraction * thumbRange) + "px";
|
||||
};
|
||||
|
||||
// --- Drag souris + tactile ---
|
||||
let dragStartX = null;
|
||||
let dragScrollStart = null;
|
||||
|
||||
const startDrag = (clientX) => {
|
||||
dragStartX = clientX;
|
||||
dragScrollStart = dashContainer.scrollLeft;
|
||||
};
|
||||
const moveDrag = (clientX) => {
|
||||
if (dragStartX === null) return;
|
||||
const dx = clientX - dragStartX;
|
||||
const { scrollRange, thumbRange } = metrics();
|
||||
if (thumbRange <= 0) return;
|
||||
dashContainer.scrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(scrollRange, dragScrollStart + (dx / thumbRange) * scrollRange)
|
||||
);
|
||||
};
|
||||
const endDrag = () => {
|
||||
dragStartX = null;
|
||||
};
|
||||
|
||||
thumb.addEventListener("mousedown", (e) => {
|
||||
startDrag(e.clientX);
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener("mousemove", (e) => moveDrag(e.clientX));
|
||||
document.addEventListener("mouseup", endDrag);
|
||||
|
||||
thumb.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
startDrag(e.touches[0].clientX);
|
||||
e.preventDefault();
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
document.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (dragStartX !== null) {
|
||||
moveDrag(e.touches[0].clientX);
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
document.addEventListener("touchend", endDrag);
|
||||
|
||||
// Clic sur le track (hors thumb) : saute à la position cliquée.
|
||||
bar.addEventListener("click", (e) => {
|
||||
if (e.target === thumb) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const { scrollRange, thumbW, thumbRange } = metrics();
|
||||
const fraction = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left - thumbW / 2) / thumbRange)
|
||||
);
|
||||
dashContainer.scrollLeft = fraction * scrollRange;
|
||||
});
|
||||
|
||||
// Scroll molette/trackpad horizontal → redirigé vers le conteneur.
|
||||
// SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page.
|
||||
wrapper.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
e.preventDefault();
|
||||
dashContainer.scrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
dashContainer.scrollWidth - dashContainer.clientWidth,
|
||||
dashContainer.scrollLeft + e.deltaX
|
||||
)
|
||||
);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Synchronise le thumb quand le conteneur défile (drag, wheel, ou autre).
|
||||
// SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page.
|
||||
dashContainer.addEventListener("scroll", syncThumb);
|
||||
|
||||
const refresh = () => {
|
||||
const hasOverflow =
|
||||
dashContainer.scrollWidth > dashContainer.clientWidth + 1;
|
||||
bar.classList.toggle("is-hidden", !hasOverflow);
|
||||
if (hasOverflow) syncThumb();
|
||||
};
|
||||
|
||||
// Recalcule quand le tableau change (pagination, tri, filtre, données).
|
||||
const obs = new MutationObserver(() => refresh());
|
||||
obs.observe(dashContainer, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
});
|
||||
// SPA : ce listener est intentionnellement conservé pour toute la durée de vie de la page.
|
||||
window.addEventListener("resize", refresh);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
function scan() {
|
||||
document.querySelectorAll(".marches_table").forEach(setup);
|
||||
}
|
||||
|
||||
// Les tableaux apparaissent après le rendu Dash : observer le body.
|
||||
const rootObs = new MutationObserver(() => scan());
|
||||
rootObs.observe(document.body, { childList: true, subtree: true });
|
||||
scan();
|
||||
})();
|
||||
@@ -0,0 +1,304 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from threading import local
|
||||
|
||||
_local = local()
|
||||
|
||||
USERS_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, subject),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
return Path(os.getenv("USERS_DB_PATH", "users.sqlite"))
|
||||
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
conn = getattr(_local, "conn", None)
|
||||
if conn is None:
|
||||
conn = sqlite3.connect(str(_db_path()), isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
_local.conn = conn
|
||||
return conn
|
||||
|
||||
|
||||
def reset_conn_for_tests() -> None:
|
||||
conn = getattr(_local, "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
_local.conn = None
|
||||
|
||||
|
||||
def init_schema() -> None:
|
||||
conn = get_conn()
|
||||
conn.executescript(USERS_SCHEMA)
|
||||
_migrate(conn)
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
|
||||
if "pending_email" not in cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN pending_email TEXT")
|
||||
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
|
||||
if cols.get("password_hash") and cols["password_hash"]["notnull"] == 1:
|
||||
_rebuild_users_password_nullable(conn)
|
||||
|
||||
|
||||
def _rebuild_users_password_nullable(conn: sqlite3.Connection) -> None:
|
||||
# SQLite ne peut pas retirer un NOT NULL via ALTER : on reconstruit la table.
|
||||
# foreign_keys OFF pour éviter le cascade-delete pendant le DROP.
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO users_new (id, email, password_hash, email_verified, "
|
||||
"pending_email, created_at, updated_at) "
|
||||
"SELECT id, email, password_hash, email_verified, pending_email, "
|
||||
"created_at, updated_at FROM users"
|
||||
)
|
||||
conn.execute("DROP TABLE users")
|
||||
conn.execute("ALTER TABLE users_new RENAME TO users")
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def create_user(email: str, password_hash: str) -> int:
|
||||
conn = get_conn()
|
||||
now = _now()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (email, password_hash, email_verified, created_at, updated_at) "
|
||||
"VALUES (?, ?, 0, ?, ?)",
|
||||
(email.lower(), password_hash, now, now),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_user_by_email(email: str) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute("SELECT * FROM users WHERE email = ?", (email.lower(),))
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> sqlite3.Row | None:
|
||||
return get_conn().execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
|
||||
|
||||
def list_users(limit: int = 1000) -> list[sqlite3.Row]:
|
||||
return (
|
||||
get_conn()
|
||||
.execute("SELECT * FROM users ORDER BY created_at DESC LIMIT ?", (limit,))
|
||||
.fetchall()
|
||||
)
|
||||
|
||||
|
||||
def set_email_verified(user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"UPDATE users SET email_verified = 1, updated_at = ? WHERE id = ?",
|
||||
(_now(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def update_password_hash(user_id: int, password_hash: str) -> None:
|
||||
get_conn().execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
|
||||
(password_hash, _now(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def get_siret(user_id: int) -> str | None:
|
||||
row = (
|
||||
get_conn()
|
||||
.execute("SELECT siret FROM users WHERE id = ?", (user_id,))
|
||||
.fetchone()
|
||||
)
|
||||
return row["siret"] if row else None
|
||||
|
||||
|
||||
def set_siret(user_id: int, siret: str) -> None:
|
||||
get_conn().execute(
|
||||
"UPDATE users SET siret = ?, updated_at = ? WHERE id = ?",
|
||||
(siret, _now(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def set_pending_email(user_id: int, email: str) -> None:
|
||||
get_conn().execute(
|
||||
"UPDATE users SET pending_email = ?, updated_at = ? WHERE id = ?",
|
||||
(email.lower(), _now(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def promote_pending_email(user_id: int) -> str | None:
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT pending_email FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["pending_email"]:
|
||||
return None
|
||||
new_email = row["pending_email"]
|
||||
conn.execute(
|
||||
"UPDATE users SET email = ?, pending_email = NULL, "
|
||||
"email_verified = 1, updated_at = ? WHERE id = ?",
|
||||
(new_email, _now(), user_id),
|
||||
)
|
||||
return new_email
|
||||
|
||||
|
||||
def delete_user(user_id: int) -> None:
|
||||
get_conn().execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
|
||||
|
||||
def create_email_verification_token(
|
||||
token_hash: str, user_id: int, expires_at: str
|
||||
) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT INTO email_verification_tokens (token_hash, user_id, expires_at, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(token_hash, user_id, expires_at, _now()),
|
||||
)
|
||||
|
||||
|
||||
def find_email_verification_token(token_hash: str) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM email_verification_tokens "
|
||||
"WHERE token_hash = ? AND expires_at > ?",
|
||||
(token_hash, _now()),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def delete_email_verification_tokens_for_user(user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"DELETE FROM email_verification_tokens WHERE user_id = ?", (user_id,)
|
||||
)
|
||||
|
||||
|
||||
def create_password_reset_token(token_hash: str, user_id: int, expires_at: str) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT INTO password_reset_tokens (token_hash, user_id, expires_at, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(token_hash, user_id, expires_at, _now()),
|
||||
)
|
||||
|
||||
|
||||
def find_password_reset_token(token_hash: str) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM password_reset_tokens "
|
||||
"WHERE token_hash = ? AND expires_at > ?",
|
||||
(token_hash, _now()),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def delete_password_reset_tokens_for_user(user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"DELETE FROM password_reset_tokens WHERE user_id = ?", (user_id,)
|
||||
)
|
||||
|
||||
|
||||
def purge_expired_tokens() -> None:
|
||||
now = _now()
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM email_verification_tokens WHERE expires_at <= ?", (now,))
|
||||
conn.execute("DELETE FROM password_reset_tokens WHERE expires_at <= ?", (now,))
|
||||
|
||||
|
||||
def create_oauth_user(email: str) -> int:
|
||||
conn = get_conn()
|
||||
now = _now()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (email, password_hash, email_verified, created_at, updated_at) "
|
||||
"VALUES (?, NULL, 1, ?, ?)",
|
||||
(email.lower(), now, now),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM oauth_identities WHERE provider = ? AND subject = ?",
|
||||
(provider, subject),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def link_oauth_identity(provider: str, subject: str, user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT INTO oauth_identities (provider, subject, user_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(provider, subject, user_id, _now()),
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
|
||||
from brevo import (
|
||||
Brevo,
|
||||
SendTransacEmailRequestSender,
|
||||
SendTransacEmailRequestToItem,
|
||||
)
|
||||
from brevo.core.api_error import ApiError
|
||||
|
||||
from src.utils import DEVELOPMENT, logger
|
||||
|
||||
_client: Brevo | None = None
|
||||
|
||||
|
||||
def init_mailer() -> None:
|
||||
"""Construit le client Brevo à partir des variables d'environnement."""
|
||||
global _client
|
||||
api_key = os.getenv("BREVO_API_KEY", "")
|
||||
_client = Brevo(api_key=api_key)
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.getenv("APP_BASE_URL", "http://localhost:8050").rstrip("/")
|
||||
|
||||
|
||||
def _sender() -> SendTransacEmailRequestSender:
|
||||
return SendTransacEmailRequestSender(
|
||||
email=os.getenv("MAIL_FROM", "noreply@colibre"),
|
||||
name=os.getenv("MAIL_FROM_NAME", "colibre"),
|
||||
)
|
||||
|
||||
|
||||
def _template_id(env_var: str) -> int:
|
||||
raw = os.getenv(env_var, "")
|
||||
if not raw:
|
||||
raise RuntimeError(f"{env_var} non défini (template Brevo)")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _suppress_send() -> bool:
|
||||
raw = os.getenv("MAIL_SUPPRESS_SEND")
|
||||
if raw is not None:
|
||||
return raw.lower() == "true"
|
||||
return DEVELOPMENT
|
||||
|
||||
|
||||
def _send_template(template_id: int, recipient: str, params: dict) -> None:
|
||||
assert _client is not None, "Mailer non initialisé (init_mailer() non appelé)"
|
||||
suppress = _suppress_send()
|
||||
if suppress:
|
||||
cause = (
|
||||
"MAIL_SUPPRESS_SEND=true"
|
||||
if os.getenv("MAIL_SUPPRESS_SEND") is not None
|
||||
else "DEVELOPMENT=true"
|
||||
)
|
||||
logger.warning(
|
||||
"Email en mode sandbox (%s) — non délivré à %s (template %s)",
|
||||
cause,
|
||||
recipient,
|
||||
template_id,
|
||||
)
|
||||
sandbox_headers = {"X-Sib-Sandbox": "drop"} if suppress else None
|
||||
try:
|
||||
_client.transactional_emails.send_transac_email(
|
||||
template_id=template_id,
|
||||
params=params,
|
||||
sender=_sender(),
|
||||
to=[SendTransacEmailRequestToItem(email=recipient)],
|
||||
headers=sandbox_headers,
|
||||
)
|
||||
except ApiError:
|
||||
logger.exception(
|
||||
"Échec d'envoi Brevo (template %s) à %s", template_id, recipient
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def send_verification_email(email: str, token: str) -> None:
|
||||
link = f"{_base_url()}/auth/verify-email?token={token}"
|
||||
_send_template(_template_id("BREVO_TEMPLATE_VERIFY_ID"), email, {"link": link})
|
||||
|
||||
|
||||
def send_reset_email(email: str, token: str) -> None:
|
||||
link = f"{_base_url()}/reinitialiser-mot-de-passe?token={token}"
|
||||
_send_template(_template_id("BREVO_TEMPLATE_RESET_ID"), email, {"link": link})
|
||||
|
||||
|
||||
def send_email_change_email(email: str, token: str) -> None:
|
||||
link = f"{_base_url()}/auth/confirm-email-change?token={token}"
|
||||
_send_template(_template_id("BREVO_TEMPLATE_VERIFY_ID"), email, {"link": link})
|
||||
@@ -0,0 +1,34 @@
|
||||
import sqlite3
|
||||
|
||||
from src.auth import db
|
||||
|
||||
|
||||
class User:
|
||||
def __init__(self, row: sqlite3.Row):
|
||||
self.id: int = row["id"]
|
||||
self.email: str = row["email"]
|
||||
self.email_verified: bool = bool(row["email_verified"])
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_id(self) -> str:
|
||||
return str(self.id)
|
||||
|
||||
|
||||
def load_user(user_id: str) -> User | None:
|
||||
try:
|
||||
uid = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
row = db.get_user_by_id(uid)
|
||||
return User(row) if row else None
|
||||
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from flask import Flask
|
||||
|
||||
LINKEDIN_DISCOVERY_URL = (
|
||||
"https://www.linkedin.com/oauth/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
oauth = OAuth()
|
||||
|
||||
|
||||
def init_oauth(app: Flask) -> None:
|
||||
oauth.init_app(app)
|
||||
oauth.register(
|
||||
name="linkedin",
|
||||
client_id=os.getenv("LINKEDIN_CLIENT_ID"),
|
||||
client_secret=os.getenv("LINKEDIN_CLIENT_SECRET"),
|
||||
server_metadata_url=LINKEDIN_DISCOVERY_URL,
|
||||
client_kwargs={
|
||||
"scope": "openid profile email",
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,306 @@
|
||||
import os
|
||||
|
||||
from email_validator import EmailNotValidError, validate_email
|
||||
from flask import Blueprint, redirect, request, session
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from src.auth import db, mailer, tokens
|
||||
from src.auth.models import User
|
||||
from src.auth.oauth import oauth
|
||||
from src.auth.setup import safe_next
|
||||
from src.utils import logger
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
def _post_login_url(user_id: int) -> str:
|
||||
try:
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
if sub_db.has_active_subscription(user_id):
|
||||
return "/compte/admin"
|
||||
except Exception:
|
||||
pass
|
||||
return "/compte/abonnement"
|
||||
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
# Hash bidon pré-calculé pour uniformiser le timing login
|
||||
_DUMMY_HASH = generate_password_hash("dummy-password-for-timing")
|
||||
|
||||
|
||||
def resolve_oauth_user(
|
||||
provider: str, subject: str, email: str, email_verified: bool
|
||||
) -> User:
|
||||
identity = db.get_oauth_identity(provider, subject)
|
||||
if identity is not None:
|
||||
return User(db.get_user_by_id(identity["user_id"]))
|
||||
|
||||
row = db.get_user_by_email(email)
|
||||
if row is not None:
|
||||
db.link_oauth_identity(provider, subject, row["id"])
|
||||
if email_verified and not row["email_verified"]:
|
||||
db.set_email_verified(row["id"])
|
||||
return User(db.get_user_by_id(row["id"]))
|
||||
|
||||
user_id = db.create_oauth_user(email)
|
||||
db.link_oauth_identity(provider, subject, user_id)
|
||||
return User(db.get_user_by_id(user_id))
|
||||
|
||||
|
||||
def _redirect_with_error(path: str, error: str, email: str | None = None):
|
||||
url = f"{path}?error={error}"
|
||||
if email:
|
||||
url += f"&email={email}"
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@auth_bp.route("/signup", methods=["POST"])
|
||||
def signup():
|
||||
email = (request.form.get("email") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
password_confirm = request.form.get("password_confirm") or ""
|
||||
|
||||
try:
|
||||
valid = validate_email(email, check_deliverability=False)
|
||||
email = valid.normalized.lower()
|
||||
except EmailNotValidError:
|
||||
return _redirect_with_error("/inscription", "invalid_email", email)
|
||||
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return _redirect_with_error("/inscription", "password_too_short", email)
|
||||
if password != password_confirm:
|
||||
return _redirect_with_error("/inscription", "password_mismatch", email)
|
||||
|
||||
if db.get_user_by_email(email) is not None:
|
||||
return _redirect_with_error("/inscription", "email_taken", email)
|
||||
|
||||
user_id = db.create_user(email, generate_password_hash(password))
|
||||
token = tokens.create_verification_token(user_id)
|
||||
try:
|
||||
mailer.send_verification_email(email, token)
|
||||
except Exception:
|
||||
logger.exception("Échec d'envoi de l'email de vérification")
|
||||
db.delete_user(user_id)
|
||||
return _redirect_with_error("/inscription", "email_send_failed", email)
|
||||
|
||||
return redirect("/connexion?pending_verification=1")
|
||||
|
||||
|
||||
@auth_bp.route("/verify-email", methods=["GET"])
|
||||
def verify_email():
|
||||
token = request.args.get("token") or ""
|
||||
if not token:
|
||||
return redirect("/verification-email?error=invalid_token")
|
||||
user_id = tokens.consume_verification_token(token)
|
||||
if user_id is None:
|
||||
return redirect("/verification-email?error=invalid_token")
|
||||
db.set_email_verified(user_id)
|
||||
login_user(User(db.get_user_by_id(user_id)), remember=True)
|
||||
return redirect("/compte/abonnement/mes-infos")
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
next_url = request.form.get("next")
|
||||
|
||||
row = db.get_user_by_email(email)
|
||||
if row is None:
|
||||
check_password_hash(_DUMMY_HASH, password) # uniformiser le temps
|
||||
return _redirect_with_error("/connexion", "invalid_credentials", email)
|
||||
|
||||
if row["password_hash"] is None or not check_password_hash(
|
||||
row["password_hash"], password
|
||||
):
|
||||
return _redirect_with_error("/connexion", "invalid_credentials", email)
|
||||
|
||||
if not row["email_verified"]:
|
||||
return _redirect_with_error("/connexion", "email_not_verified", email)
|
||||
|
||||
login_user(User(row), remember=True)
|
||||
return redirect(safe_next(next_url, fallback=_post_login_url(row["id"])))
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect("/")
|
||||
|
||||
|
||||
@auth_bp.route("/request-password-reset", methods=["POST"])
|
||||
def request_password_reset():
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
try:
|
||||
valid = validate_email(email, check_deliverability=False)
|
||||
email = valid.normalized.lower()
|
||||
except EmailNotValidError:
|
||||
return redirect("/mot-de-passe-oublie?pending=1")
|
||||
|
||||
row = db.get_user_by_email(email)
|
||||
if row is None:
|
||||
return redirect("/mot-de-passe-oublie?pending=1")
|
||||
|
||||
token = tokens.create_password_reset_token(row["id"])
|
||||
try:
|
||||
mailer.send_reset_email(email, token)
|
||||
except Exception:
|
||||
logger.exception("Échec d'envoi de l'email de réinitialisation")
|
||||
return _redirect_with_error("/mot-de-passe-oublie", "email_send_failed", email)
|
||||
return redirect("/mot-de-passe-oublie?pending=1")
|
||||
|
||||
|
||||
@auth_bp.route("/reset-password", methods=["POST"])
|
||||
def reset_password():
|
||||
token = request.form.get("token") or ""
|
||||
password = request.form.get("password") or ""
|
||||
password_confirm = request.form.get("password_confirm") or ""
|
||||
|
||||
user_id = tokens.validate_password_reset_token(token)
|
||||
if user_id is None:
|
||||
return redirect(
|
||||
f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token"
|
||||
)
|
||||
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return redirect(
|
||||
f"/reinitialiser-mot-de-passe?token={token}&error=password_too_short"
|
||||
)
|
||||
if password != password_confirm:
|
||||
return redirect(
|
||||
f"/reinitialiser-mot-de-passe?token={token}&error=password_mismatch"
|
||||
)
|
||||
|
||||
consumed = tokens.consume_password_reset_token(token)
|
||||
if consumed is None:
|
||||
return redirect(
|
||||
f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token"
|
||||
)
|
||||
|
||||
db.update_password_hash(consumed, generate_password_hash(password))
|
||||
return redirect("/connexion?password_changed=1")
|
||||
|
||||
|
||||
@auth_bp.route("/change-password", methods=["POST"])
|
||||
@login_required
|
||||
def change_password():
|
||||
current_pw = request.form.get("current_password") or ""
|
||||
password = request.form.get("password") or ""
|
||||
password_confirm = request.form.get("password_confirm") or ""
|
||||
|
||||
row = db.get_user_by_id(current_user.id)
|
||||
if row["password_hash"] is None:
|
||||
return _redirect_with_error("/compte/admin", "no_password_set")
|
||||
if not check_password_hash(row["password_hash"], current_pw):
|
||||
return _redirect_with_error("/compte/admin", "invalid_current_password")
|
||||
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return _redirect_with_error("/compte/admin", "password_too_short")
|
||||
if password != password_confirm:
|
||||
return _redirect_with_error("/compte/admin", "password_mismatch")
|
||||
|
||||
db.update_password_hash(current_user.id, generate_password_hash(password))
|
||||
return redirect("/compte/admin?password_changed=1")
|
||||
|
||||
|
||||
@auth_bp.route("/change-email", methods=["POST"])
|
||||
@login_required
|
||||
def change_email():
|
||||
email = (request.form.get("email") or "").strip()
|
||||
try:
|
||||
valid = validate_email(email, check_deliverability=False)
|
||||
email = valid.normalized.lower()
|
||||
except EmailNotValidError:
|
||||
return _redirect_with_error("/compte/admin", "invalid_email")
|
||||
|
||||
if db.get_user_by_email(email) is not None:
|
||||
return _redirect_with_error("/compte/admin", "email_taken")
|
||||
|
||||
db.set_pending_email(current_user.id, email)
|
||||
db.delete_email_verification_tokens_for_user(current_user.id)
|
||||
token = tokens.create_verification_token(current_user.id)
|
||||
try:
|
||||
mailer.send_email_change_email(email, token)
|
||||
except Exception:
|
||||
logger.exception("Échec d'envoi de l'email de changement d'adresse")
|
||||
return _redirect_with_error("/compte/admin", "email_send_failed")
|
||||
|
||||
return redirect("/compte/admin?email_pending=1")
|
||||
|
||||
|
||||
@auth_bp.route("/confirm-email-change", methods=["GET"])
|
||||
def confirm_email_change():
|
||||
token = request.args.get("token") or ""
|
||||
user_id = tokens.consume_verification_token(token)
|
||||
if user_id is None:
|
||||
return redirect("/compte/admin?error=invalid_token")
|
||||
new_email = db.promote_pending_email(user_id)
|
||||
if new_email is None:
|
||||
return redirect("/compte/admin?error=invalid_token")
|
||||
return redirect("/compte/admin?email_changed=1")
|
||||
|
||||
|
||||
@auth_bp.route("/delete-account", methods=["POST"])
|
||||
@login_required
|
||||
def delete_account():
|
||||
current_pw = request.form.get("current_password") or ""
|
||||
row = db.get_user_by_id(current_user.id)
|
||||
if row["password_hash"] is not None and not check_password_hash(
|
||||
row["password_hash"], current_pw
|
||||
):
|
||||
return _redirect_with_error("/compte/admin", "invalid_current_password")
|
||||
|
||||
user_id = current_user.id
|
||||
db.delete_email_verification_tokens_for_user(user_id)
|
||||
db.delete_password_reset_tokens_for_user(user_id)
|
||||
db.delete_user(user_id)
|
||||
logout_user()
|
||||
return redirect("/?account_deleted=1")
|
||||
|
||||
|
||||
@auth_bp.route("/linkedin", methods=["GET"])
|
||||
def linkedin_login():
|
||||
session["oauth_next"] = safe_next(
|
||||
request.args.get("next"), fallback="/compte/admin"
|
||||
)
|
||||
redirect_uri = f"{os.getenv('APP_BASE_URL', '')}/auth/linkedin/callback"
|
||||
return oauth.linkedin.authorize_redirect(redirect_uri)
|
||||
|
||||
|
||||
@auth_bp.route("/linkedin/callback", methods=["GET"])
|
||||
def linkedin_callback():
|
||||
oauth_next = session.pop("oauth_next", None)
|
||||
if request.args.get("error"):
|
||||
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
|
||||
return _redirect_with_error("/connexion", "oauth_cancelled")
|
||||
try:
|
||||
# LinkedIn ne retourne pas le nonce dans l'ID token (non-conformité OIDC) :
|
||||
# authorize_access_token() lève MissingClaimError("nonce") après avoir échangé
|
||||
# le code avec succès. Le token est déjà stocké dans oauth.linkedin.token à ce
|
||||
# moment, donc on capture cette erreur précise et on continue.
|
||||
try:
|
||||
oauth.linkedin.authorize_access_token()
|
||||
except Exception as exc:
|
||||
if "nonce" not in str(exc) or not oauth.linkedin.token:
|
||||
raise
|
||||
resp = oauth.linkedin.get("https://api.linkedin.com/v2/userinfo")
|
||||
resp.raise_for_status()
|
||||
userinfo = resp.json()
|
||||
except Exception:
|
||||
logger.exception("Échec de l'échange de token LinkedIn")
|
||||
return _redirect_with_error("/connexion", "oauth_failed")
|
||||
|
||||
subject = userinfo.get("sub")
|
||||
email = (userinfo.get("email") or "").strip().lower()
|
||||
if not subject or not email:
|
||||
logger.error("Réponse LinkedIn sans sub/email : %s", userinfo)
|
||||
return _redirect_with_error("/connexion", "oauth_failed")
|
||||
|
||||
user = resolve_oauth_user(
|
||||
"linkedin", subject, email, bool(userinfo.get("email_verified"))
|
||||
)
|
||||
login_user(user, remember=True)
|
||||
return redirect(safe_next(oauth_next, fallback=_post_login_url(user.id)))
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
from src.auth import db, mailer
|
||||
from src.auth.models import load_user
|
||||
from src.auth.oauth import init_oauth
|
||||
from src.utils import DEVELOPMENT, logger
|
||||
|
||||
_csrf: CSRFProtect | None = None
|
||||
_login_manager: LoginManager | None = None
|
||||
|
||||
|
||||
def safe_next(url: str | None, fallback: str = "/") -> str:
|
||||
if not url or not url.startswith("/") or url.startswith("//"):
|
||||
return fallback
|
||||
return url
|
||||
|
||||
|
||||
def init_auth(app: Flask) -> None:
|
||||
global _csrf, _login_manager
|
||||
|
||||
secret = os.getenv("SECRET_KEY") or app.config.get("SECRET_KEY")
|
||||
if not secret:
|
||||
raise RuntimeError(
|
||||
"SECRET_KEY est obligatoire pour l'authentification. "
|
||||
"Définissez-la dans .env (voir .template.env)."
|
||||
)
|
||||
app.config["SECRET_KEY"] = secret
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = not DEVELOPMENT
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 jours
|
||||
|
||||
db.init_schema()
|
||||
db.purge_expired_tokens()
|
||||
|
||||
mailer.init_mailer()
|
||||
|
||||
_login_manager = LoginManager()
|
||||
_login_manager.login_view = "/connexion"
|
||||
_login_manager.user_loader(load_user)
|
||||
_login_manager.init_app(app)
|
||||
|
||||
from src.auth.routes import auth_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
init_oauth(app)
|
||||
|
||||
_csrf = CSRFProtect(app)
|
||||
|
||||
if not os.getenv("BREVO_API_KEY"):
|
||||
logger.warning(
|
||||
"BREVO_API_KEY non défini : les emails d'auth échoueront. "
|
||||
"Définissez les variables BREVO_* dans .env pour envoyer des emails."
|
||||
)
|
||||
|
||||
if not os.getenv("LINKEDIN_CLIENT_ID"):
|
||||
logger.warning(
|
||||
"LINKEDIN_CLIENT_ID non défini : la connexion LinkedIn échouera. "
|
||||
"Définissez LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET dans .env."
|
||||
)
|
||||
|
||||
if not os.getenv("APP_BASE_URL"):
|
||||
logger.warning(
|
||||
"APP_BASE_URL non défini : le callback LinkedIn produira une URI relative invalide."
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from src.auth import db
|
||||
|
||||
VERIFICATION_TTL_HOURS = 24
|
||||
RESET_TTL_HOURS = 1
|
||||
|
||||
|
||||
def hash_token(plain: str) -> str:
|
||||
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _expires_at(hours: int) -> str:
|
||||
return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat()
|
||||
|
||||
|
||||
def create_verification_token(
|
||||
user_id: int, expires_in_hours: int = VERIFICATION_TTL_HOURS
|
||||
) -> str:
|
||||
plain = secrets.token_urlsafe(32)
|
||||
db.create_email_verification_token(
|
||||
hash_token(plain), user_id, _expires_at(expires_in_hours)
|
||||
)
|
||||
return plain
|
||||
|
||||
|
||||
def consume_verification_token(plain: str) -> int | None:
|
||||
row = db.find_email_verification_token(hash_token(plain))
|
||||
if row is None:
|
||||
return None
|
||||
user_id = row["user_id"]
|
||||
db.delete_email_verification_tokens_for_user(user_id)
|
||||
return user_id
|
||||
|
||||
|
||||
def create_password_reset_token(
|
||||
user_id: int, expires_in_hours: int = RESET_TTL_HOURS
|
||||
) -> str:
|
||||
db.delete_password_reset_tokens_for_user(user_id)
|
||||
plain = secrets.token_urlsafe(32)
|
||||
db.create_password_reset_token(
|
||||
hash_token(plain), user_id, _expires_at(expires_in_hours)
|
||||
)
|
||||
return plain
|
||||
|
||||
|
||||
def consume_password_reset_token(plain: str) -> int | None:
|
||||
row = db.find_password_reset_token(hash_token(plain))
|
||||
if row is None:
|
||||
return None
|
||||
user_id = row["user_id"]
|
||||
db.delete_password_reset_tokens_for_user(user_id)
|
||||
return user_id
|
||||
|
||||
|
||||
def validate_password_reset_token(plain: str) -> int | None:
|
||||
"""Check token without consuming (used to render the reset form)."""
|
||||
row = db.find_password_reset_token(hash_token(plain))
|
||||
return row["user_id"] if row else None
|
||||
@@ -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 colibre")
|
||||
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 colibre")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||