From e3a7edcf213b779cdda2c6602dd19599d072ed29 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 13 Mar 2026 01:28:06 -0400 Subject: [PATCH] Refonte --- README.md | 2 +- backend/app/main.py | 3 + deploy/apache-groupe-meditation.conf | 28 -- deploy/nginx-groupe-meditation.conf | 64 ++-- docs/DEPLOIEMENT.md | 77 +---- frontend/public/icon-192.png | Bin 0 -> 1251 bytes frontend/public/icon-512.png | Bin 0 -> 5552 bytes .../groupe-meditation/README.md | 67 ---- .../groupe-meditation/REVISION_CORRECTIVE.txt | 33 -- .../groupe-meditation/backend/.env.example | 7 - .../groupe-meditation/backend/app/__init__.py | 0 .../backend/app/core/__init__.py | 0 .../backend/app/core/config.py | 30 -- .../backend/app/core/database.py | 21 -- .../backend/app/core/security.py | 68 ---- .../groupe-meditation/backend/app/main.py | 68 ---- .../backend/app/models/__init__.py | 0 .../backend/app/models/affectation.py | 23 -- .../backend/app/models/collecte.py | 18 -- .../backend/app/models/config_repartition.py | 19 -- .../backend/app/models/decision.py | 27 -- .../backend/app/models/depense.py | 24 -- .../backend/app/models/envoi_contribution.py | 25 -- .../backend/app/models/groupe.py | 23 -- .../backend/app/models/invitation.py | 18 -- .../backend/app/models/jeton.py | 26 -- .../backend/app/models/jumelage.py | 23 -- .../backend/app/models/litterature.py | 21 -- .../backend/app/models/membre.py | 26 -- .../backend/app/models/option_sondage.py | 16 - .../backend/app/models/poste.py | 17 - .../backend/app/models/presence.py | 14 - .../backend/app/models/pv_reunion.py | 18 -- .../backend/app/models/rapport_rsg.py | 19 -- .../backend/app/models/reunion.py | 20 -- .../backend/app/models/rotation.py | 19 -- .../backend/app/models/sondage.py | 29 -- .../backend/app/models/sync_log.py | 21 -- .../backend/app/models/vente_litterature.py | 19 -- .../backend/app/models/vote.py | 16 - .../backend/app/routers/__init__.py | 0 .../backend/app/routers/accueil.py | 112 ------- .../backend/app/routers/anniversaires.py | 89 ----- .../backend/app/routers/auth.py | 107 ------ .../backend/app/routers/calendrier.py | 102 ------ .../backend/app/routers/collectes.py | 80 ----- .../backend/app/routers/commandes_jetons.py | 66 ---- .../backend/app/routers/config_repartition.py | 103 ------ .../backend/app/routers/contributions.py | 151 --------- .../backend/app/routers/decisions.py | 110 ------- .../backend/app/routers/depenses.py | 146 --------- .../backend/app/routers/invitations.py | 31 -- .../backend/app/routers/jetons.py | 109 ------- .../backend/app/routers/jumelages.py | 99 ------ .../backend/app/routers/litterature.py | 124 ------- .../backend/app/routers/membres.py | 127 -------- .../backend/app/routers/parametres.py | 44 --- .../backend/app/routers/postes.py | 116 ------- .../backend/app/routers/presences.py | 128 -------- .../backend/app/routers/pv.py | 157 --------- .../backend/app/routers/rapports.py | 306 ------------------ .../backend/app/routers/rapports_rsg.py | 95 ------ .../backend/app/routers/reunions.py | 113 ------- .../backend/app/routers/rotations.py | 125 ------- .../backend/app/routers/sondages.py | 219 ------------- .../backend/app/routers/ventes_litterature.py | 112 ------- .../backend/app/schemas/__init__.py | 0 .../backend/app/schemas/phase1.py | 74 ----- .../backend/app/schemas/phase2.py | 63 ---- .../backend/app/schemas/phase3.py | 49 --- .../backend/app/schemas/phase4.py | 91 ------ .../backend/app/schemas/phase5.py | 56 ---- .../backend/app/schemas/phase6.py | 61 ---- .../backend/app/schemas/phase7.py | 82 ----- .../backend/app/schemas/phase8.py | 78 ----- .../backend/app/schemas/schemas.py | 134 -------- .../backend/requirements.txt | 9 - .../deploy/apache-groupe-meditation.conf | 37 --- .../groupe-meditation/deploy/backup.sh | 19 -- .../deploy/groupe-meditation.service | 19 -- .../deploy/nginx-groupe-meditation.conf | 50 --- .../groupe-meditation/deploy/seed.py | 182 ----------- .../groupe-meditation/docs/DEPLOIEMENT.md | 211 ------------ .../groupe-meditation/docs/RUNBOOK.md | 164 ---------- .../groupe-meditation/frontend/index.html | 17 - .../groupe-meditation/frontend/package.json | 27 -- .../frontend/postcss.config.js | 6 - .../frontend/public/favicon.svg | 4 - .../groupe-meditation/frontend/src/App.jsx | 88 ----- .../frontend/src/components/PageHeader.jsx | 15 - .../frontend/src/components/SuccessCheck.jsx | 21 -- .../groupe-meditation/frontend/src/index.css | 22 -- .../groupe-meditation/frontend/src/main.jsx | 10 - .../frontend/src/pages/Accueil.jsx | 93 ------ .../frontend/src/pages/AffecterPostes.jsx | 110 ------- .../frontend/src/pages/AjusterInventaire.jsx | 226 ------------- .../frontend/src/pages/Anniversaires.jsx | 128 -------- .../frontend/src/pages/Calendrier.jsx | 133 -------- .../frontend/src/pages/Collecte.jsx | 82 ----- .../frontend/src/pages/CommanderJetons.jsx | 88 ----- .../frontend/src/pages/ConfigRepartition.jsx | 124 ------- .../frontend/src/pages/Decisions.jsx | 112 ------- .../frontend/src/pages/Depense.jsx | 178 ---------- .../frontend/src/pages/EnvoiContributions.jsx | 243 -------------- .../frontend/src/pages/GererMembres.jsx | 145 --------- .../frontend/src/pages/Inscription.jsx | 78 ----- .../frontend/src/pages/Inventaire.jsx | 98 ------ .../frontend/src/pages/InviterMembre.jsx | 86 ----- .../frontend/src/pages/JumelerParrain.jsx | 173 ---------- .../frontend/src/pages/LancerSondage.jsx | 163 ---------- .../frontend/src/pages/Login.jsx | 109 ------- .../frontend/src/pages/Parametres.jsx | 123 ------- .../frontend/src/pages/PlanifierRotations.jsx | 138 -------- .../frontend/src/pages/Presences.jsx | 134 -------- .../frontend/src/pages/Profil.jsx | 151 --------- .../frontend/src/pages/RapportRSG.jsx | 155 --------- .../frontend/src/pages/RapportReunion.jsx | 109 ------- .../frontend/src/pages/Rapports.jsx | 103 ------ .../frontend/src/pages/RedigerPV.jsx | 195 ----------- .../frontend/src/pages/RemiseJeton.jsx | 208 ------------ .../frontend/src/pages/TerminerMandat.jsx | 114 ------- .../frontend/src/pages/VenteLitterature.jsx | 145 --------- .../frontend/src/pages/VotesOuverts.jsx | 176 ---------- .../frontend/src/stores/api.js | 61 ---- .../frontend/src/stores/store.js | 109 ------- .../frontend/tailwind.config.js | 12 - .../groupe-meditation/frontend/vite.config.js | 40 --- 127 files changed, 55 insertions(+), 9796 deletions(-) delete mode 100644 deploy/apache-groupe-meditation.conf create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png delete mode 100644 groupe-meditation-corrige/groupe-meditation/README.md delete mode 100644 groupe-meditation-corrige/groupe-meditation/REVISION_CORRECTIVE.txt delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/.env.example delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/__init__.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/core/__init__.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/core/config.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/core/database.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/core/security.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/main.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/__init__.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/affectation.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/collecte.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/config_repartition.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/decision.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/depense.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/envoi_contribution.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/groupe.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/invitation.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/jeton.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/jumelage.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/litterature.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/membre.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/option_sondage.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/poste.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/presence.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/pv_reunion.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/rapport_rsg.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/reunion.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/rotation.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/sondage.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/sync_log.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/vente_litterature.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/models/vote.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/__init__.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/accueil.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/anniversaires.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/auth.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/calendrier.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/collectes.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/commandes_jetons.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/config_repartition.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/contributions.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/decisions.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/depenses.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/invitations.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/jetons.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/jumelages.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/litterature.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/membres.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/parametres.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/postes.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/presences.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/pv.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports_rsg.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/reunions.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/rotations.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/sondages.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/routers/ventes_litterature.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/__init__.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase1.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase2.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase3.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase4.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase5.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase6.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase7.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase8.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/app/schemas/schemas.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/backend/requirements.txt delete mode 100644 groupe-meditation-corrige/groupe-meditation/deploy/apache-groupe-meditation.conf delete mode 100644 groupe-meditation-corrige/groupe-meditation/deploy/backup.sh delete mode 100644 groupe-meditation-corrige/groupe-meditation/deploy/groupe-meditation.service delete mode 100644 groupe-meditation-corrige/groupe-meditation/deploy/nginx-groupe-meditation.conf delete mode 100644 groupe-meditation-corrige/groupe-meditation/deploy/seed.py delete mode 100644 groupe-meditation-corrige/groupe-meditation/docs/DEPLOIEMENT.md delete mode 100644 groupe-meditation-corrige/groupe-meditation/docs/RUNBOOK.md delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/index.html delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/package.json delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/postcss.config.js delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/public/favicon.svg delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/App.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/components/PageHeader.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/components/SuccessCheck.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/index.css delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/main.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Accueil.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AffecterPostes.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AjusterInventaire.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Anniversaires.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Calendrier.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Collecte.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/CommanderJetons.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/ConfigRepartition.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Decisions.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Depense.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/EnvoiContributions.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/GererMembres.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inscription.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inventaire.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/InviterMembre.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/JumelerParrain.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/LancerSondage.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Login.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Parametres.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/PlanifierRotations.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Presences.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Profil.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/RapportRSG.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/RapportReunion.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Rapports.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/RedigerPV.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/RemiseJeton.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/TerminerMandat.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/VenteLitterature.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/pages/VotesOuverts.jsx delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/stores/api.js delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/src/stores/store.js delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/tailwind.config.js delete mode 100644 groupe-meditation-corrige/groupe-meditation/frontend/vite.config.js diff --git a/README.md b/README.md index 32270ec..140af9d 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. 27 | DB | PostgreSQL 16 + SQLAlchemy async | | Auth | JWT 30 jours + bcrypt 12 rounds | | PDF | WeasyPrint | -| Serveur | Apache (statiques) + NGINX (reverse proxy HTTPS) | +| Serveur | NGINX (statiques + reverse proxy HTTPS) | ## Principes diff --git a/backend/app/main.py b/backend/app/main.py index 9ebe70b..ebba9ef 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,9 @@ from app.core.config import settings from app.core.database import engine, Base from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, jumelages +# Import sync_log pour que create_all crée la table (aucun router ne l'importe) +from app.models.sync_log import SyncLog # noqa: F401 + @asynccontextmanager async def lifespan(app: FastAPI): diff --git a/deploy/apache-groupe-meditation.conf b/deploy/apache-groupe-meditation.conf deleted file mode 100644 index 5afc064..0000000 --- a/deploy/apache-groupe-meditation.conf +++ /dev/null @@ -1,28 +0,0 @@ - - ServerName groupe-meditation.chezlepro.ca - DocumentRoot /var/www/groupe-meditation - - - Options -Indexes - AllowOverride None - Require all granted - - # SPA: rediriger toutes les routes vers index.html - RewriteEngine On - RewriteBase / - RewriteRule ^index\.html$ - [L] - RewriteCond %{REQUEST_FILENAME} !-f - RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule . /index.html [L] - - - # Cache assets statiques - - Header set Cache-Control "public, max-age=31536000, immutable" - - - # Pas de cache pour index.html (PWA) - - Header set Cache-Control "no-cache, no-store, must-revalidate" - - diff --git a/deploy/nginx-groupe-meditation.conf b/deploy/nginx-groupe-meditation.conf index 27dc6b8..8740d2f 100644 --- a/deploy/nginx-groupe-meditation.conf +++ b/deploy/nginx-groupe-meditation.conf @@ -1,32 +1,23 @@ -# Groupe Méditation — Configuration NGINX -# Reverse proxy vers Apache (frontend) et FastAPI (backend) +# Groupe Méditation — NGINX (seul, sans Apache) +# Sert les fichiers statiques + reverse proxy vers FastAPI +# +# Installation : +# sudo cp deploy/nginx-groupe-meditation.conf /etc/nginx/sites-available/groupe-meditation +# sudo ln -s /etc/nginx/sites-available/groupe-meditation /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx +# sudo certbot --nginx -d groupe-meditation.chezlepro.ca server { - listen 443 ssl http2; + listen 80; server_name groupe-meditation.chezlepro.ca; - # Certificat Let's Encrypt - ssl_certificate /etc/letsencrypt/live/groupe-meditation.chezlepro.ca/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/groupe-meditation.chezlepro.ca/privkey.pem; - - # Headers de sécurité - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + # ── Headers de sécurité ── add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy strict-origin-when-cross-origin always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; font-src fonts.gstatic.com; img-src 'self' data:; connect-src 'self'" always; - # Frontend — Apache sert les fichiers statiques - location / { - proxy_pass http://127.0.0.1:8080; - 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; - } - - # Backend API — FastAPI via Uvicorn + # ── Backend API → FastAPI (Uvicorn sur port 8000) ── location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; @@ -35,16 +26,31 @@ server { proxy_set_header X-Forwarded-Proto $scheme; # Rate limiting anti-brute force (PIN) - limit_req zone=api_limit burst=10 nodelay; + # Décommenter après avoir ajouté dans nginx.conf section http : + # limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s; + # limit_req zone=api_limit burst=10 nodelay; + } + + # ── Frontend — fichiers statiques (React build) ── + root /var/www/groupe-meditation; + index index.html; + + # Cache longue durée sur les assets (JS, CSS, images) + location ~* \.(js|css|png|jpg|svg|woff2|ico)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # SPA routing : toute route qui n'est pas un fichier → index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Pas de cache sur index.html (pour que les mises à jour PWA passent) + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; } - # Taille max des uploads client_max_body_size 5M; } - -# Redirection HTTP → HTTPS -server { - listen 80; - server_name groupe-meditation.chezlepro.ca; - return 301 https://$host$request_uri; -} diff --git a/docs/DEPLOIEMENT.md b/docs/DEPLOIEMENT.md index 2b1c664..bc5d4b1 100644 --- a/docs/DEPLOIEMENT.md +++ b/docs/DEPLOIEMENT.md @@ -1,6 +1,6 @@ # Guide de déploiement — Groupe Méditation -## 27 modules · Phases 0 à 9 · Infrastructure Debian + Apache + NGINX + PostgreSQL +## 27 modules · Phases 0 à 9 · Infrastructure Debian + NGINX + PostgreSQL --- @@ -10,8 +10,7 @@ - PostgreSQL 16+ - Python 3.11+ - Node.js 20+ (pour le build frontend) -- Apache 2.4+ avec mod_rewrite et mod_headers -- NGINX 1.24+ (reverse proxy) +- NGINX 1.24+ - Certbot (Let's Encrypt) --- @@ -54,17 +53,6 @@ source venv/bin/activate pip install -r requirements.txt ``` -### Dépendances Python (requirements.txt) - -| Paquet | Rôle | -|--------|------| -| fastapi | API REST async | -| uvicorn | Serveur ASGI | -| sqlalchemy[asyncio] + asyncpg | ORM + driver PostgreSQL | -| pydantic-settings | Configuration .env | -| python-jose + bcrypt | JWT + hachage PIN | -| weasyprint | Génération PDF (rapports C1) | - ## 3. Configurer le backend ```bash @@ -95,10 +83,10 @@ python deploy/seed.py ``` Le script crée **interactivement** : -- Les 23 tables (groupes, membres, postes, affectations, collectes, dépenses, réunions, présences, PV, décisions, rotations, rapports RSG, jetons, littérature, ventes, contributions, config_repartition, sondages, options_sondage, votes, jumelages, sync_log) -- Les 10 postes prédéfinis (RSG, RSG Substitut, Trésorier, Secrétaire, Animateur, Accueil, Café, Littérature, Jetons, Clés) +- Les 23 tables +- Les 10 postes prédéfinis - La répartition par défaut des contributions (30/30/30/10) -- La littérature de base (Gros Livre, 12×12, Vivre sans alcool, brochures) +- La littérature de base (5 titres) - Le premier groupe et le premier membre avec son poste - Un code d'invitation valide 7 jours @@ -115,17 +103,6 @@ sudo systemctl start groupe-meditation curl http://127.0.0.1:8000/api/health ``` -Le service expose 25 routers API sur le port 8000 : -- Auth (login, register) -- Membres, postes, accueil, invitations -- Collectes, réunions, dépenses, décisions -- Calendrier, rapports PDF, rotations, paramètres -- Présences, PV, rapports RSG -- Jetons, anniversaires, commandes jetons -- Littérature, ventes littérature -- Contributions, config répartition -- Sondages (votes), jumelages (parrainage) - ## 6. Construire le frontend ```bash @@ -138,47 +115,27 @@ sudo cp -r dist/* /var/www/groupe-meditation/ sudo chown -R www-data:www-data /var/www/groupe-meditation ``` -Le build produit une PWA installable avec : -- 30 pages React (27 modules + Login + Inscription + Accueil) -- Tailwind CSS mobile-first -- Service Worker pour le mode hors ligne +## 7. Configurer NGINX -## 7. Configurer Apache - -```bash -sudo cp deploy/apache-groupe-meditation.conf /etc/apache2/sites-available/ -sudo a2enmod rewrite headers -sudo a2ensite groupe-meditation -sudo systemctl reload apache2 -``` - -Apache écoute sur 127.0.0.1:8080 et sert : -- Les fichiers statiques du build React -- Le SPA routing (toutes les routes → index.html) -- Le cache immutable sur les assets (JS, CSS, images) - -## 8. Configurer NGINX +NGINX fait tout : servir les fichiers statiques du frontend ET proxy vers FastAPI. Pas besoin d'Apache. ```bash sudo cp deploy/nginx-groupe-meditation.conf /etc/nginx/sites-available/groupe-meditation sudo ln -s /etc/nginx/sites-available/groupe-meditation /etc/nginx/sites-enabled/ -# Ajouter dans /etc/nginx/nginx.conf (section http) : -# limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s; - -# Obtenir le certificat Let's Encrypt -sudo certbot certonly --nginx -d groupe-meditation.chezlepro.ca - sudo nginx -t sudo systemctl reload nginx + +# Certificat TLS +sudo certbot --nginx -d groupe-meditation.chezlepro.ca ``` -NGINX est le point d'entrée unique HTTPS : -- `/` → Apache (fichiers statiques frontend) -- `/api/*` → FastAPI (Uvicorn port 8000) -- HSTS, CSP, X-Frame-Options, rate limiting +NGINX sert : +- `/api/*` → reverse proxy vers FastAPI (Uvicorn port 8000) +- `/*` → fichiers statiques du build React (SPA routing via `try_files`) +- Cache 1 an sur les assets (JS, CSS, images), pas de cache sur index.html -## 9. Sauvegarde automatique +## 8. Sauvegarde automatique ```bash sudo cp deploy/backup.sh /etc/cron.daily/groupe-meditation-backup @@ -187,14 +144,13 @@ sudo chmod +x /etc/cron.daily/groupe-meditation-backup - pg_dump quotidien en format custom (-Fc) - Rétention 30 jours -- Stockage Ceph (réplication triple copie) - **Test de restauration mensuel recommandé** : ```bash pg_restore -U grpmed -d groupe_meditation_test /var/backups/groupe-meditation/grpmed_DERNIER.dump ``` -## 10. Vérification finale +## 9. Vérification finale ```bash # API @@ -220,7 +176,6 @@ curl -s https://groupe-meditation.chezlepro.ca/api/health | python3 -m json.tool | WeasyPrint échoue | `sudo apt install libpango-1.0-0 libpangocairo-1.0-0` | | Mise à jour frontend | `cd frontend && npm run build && sudo cp -r dist/* /var/www/groupe-meditation/` | | Mise à jour backend | `sudo systemctl restart groupe-meditation` | -| Ajouter un groupe | Utiliser l'API : `POST /api/groupes/` ou ajouter via le seed | ## Mise à jour de l'application diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..abeab519594c8594fa7ee2951418173e32238bc5 GIT binary patch literal 1251 zcmYk6Yfw{16vy{olXwfEBvDF$N+Q9Lkw*fU5qU!|jR+EJi=w0^g4)5+0%}1cCLx0% zKmhdv1#L+QqbQWeAT?A*l9Wot(n+yZ2a5_u1%-^JlhP2cq#O9s54(G2&+q)t+1cGw zwLMGV<>BuE0PsrRg71K<(LFECZ9Y4hu!4~_m) zTF^+j)}JjtA9bk=eL6iEaW7!Qi&;+?2Y3~7F6~`zd4|ZZl&jPFrByNC?$((p0gQxK z>v8Rx4}ztAoT|j!;C0eQIMoa54z$@C}XeDRWB2o136}fz&>C~mQsU{&PBJX zWxHnZ`bgY+54At@o~fQ1GDL#@#aJb~MXFEW!(_3sD6=H>{Wa@(qmnz& z^t;~3NDfub3{5Sn>J2^$EavWNy#ZtHYqzn|Xs2@~%*QQ-WHvg#@wrtF&)HJU`lHTu zrW*(@IOpXY6yCqhKm&sQOu)sB%@O>?O+gaxuRnCvyx{sGOV7re4z&Gt#}l=pbRv%^ z#Eqd}@)v!7ThBn`@KrSwa?rpZt&S&{B3vKn3ZVDQxzPi^xO59Y2ZcPRkL9jWVL2r* zaQlP5Epm=h>hE&J#M9Rvp7PV_nJR4lzC*}XCh!+M)e>&4i_(_04ZE6Q0B9TKW&)FsbE^>?XMp<33~Z))8%m_kcX%PN<@^}`ukV}$2|=~ccf9e#WKM)$OD zR2{KN_k^r$>h0YRd&GH0!st0%8}9BMLUm%?;Vt$nxwzJ=I&aR140{2fAQ3B1otE}I Szz-PRO(;Dr3-3ymDE|W$?G`Tp literal 0 HcmV?d00001 diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..22a94bb8167bf0019c9ae5e92b6c85f1958a4725 GIT binary patch literal 5552 zcmb_gd011|w%sHnO2${-XX6)Hw1i{hAoB100DK>}h#unLGWSy3=0;c&T?DqtXz zMARZf!Vp9z6$=RQQHly);eZyQmLOU=;DGk1^kCrafVaJG{^xxk-xtzA*WP=r zb<$_M=lr>*a{&PJ*ZyRPGP*;bJaXv^2(Sgaiz z?Lku$($MB?E4G8&qHvjPa+@`qh{XzQ(o?J=H)DHCE^;p}WTU?-sktMZi`H!P-;`-N zze-t$B#KbXQgM`X(VdNI&{U66IjcybC`2jl6EaqVM6rxRoBti3h~i2_E0`udm3XdP zaZO1@@I8dRmtQ(6zJc?w21|CERMeX=oILq{s<`W(@>PaaG@l@OuQ`&>H~CT4#Fboe z$Jzt|SV5!2UinI9Wfw^7wFnYifBF0gny_6`sH6{$pp@FwZqC3a_PJd|SA`98_PRsM zy$0B9(*Ppn248TiE5g!ifX8Mm*Pd=h~=^j1>mPVBmYKa%%A?)zZu=FQ^)_F5ya zgMLKygbGLU#XN5Fe8~bk?R8;*;4kV^%7MA;v>Z_fYlU{WGLBux6s_RcEXGFXA8sA1^a}hV~d+P~B6MEwUZ-FGFK<;ifScVbo8y%Ng2Ywjlq9 zp)Ap&g7aIjx^1YjwZ-4@rL!MdbDEDzSgR&TUM>Q4Ww>1Qo8sNwCLHrzF#gMPG&aAd z=AkiDTSXG*1`~EUyH@}9RSDfmv)YNPsf7rpjAgV%kL6#Kt zy<2}B^PFpPC;(ftqpvBl?(G*J3et0x13r=M;lZ8wKE3@N46TgAUp@L|F@4DrogA;P zaIt))b{HJW^(CUEvgkYW8|Ni!j?{9RucCf;ug7C;`n@d~Xzb-HTb%6q#3s_aFN*N~ z74GK`-$rS3s!A3s2SS&l!|77dM#L!t?j(1InXmw*&5DzCDF^mPCbqE|j*)ojAUiI%AHaH{t3$n*UE~-^ZJVJ7CH-N9I$NTLb9encCD@25>S;}o5 zM7f!bi!cM)Sl2By_Iuoase>C)NJ$FckF5z7lxEA92yc>_oNDlXdtv{rmWxfjKx`63 zWXf65Jt^KH;YeTxL;HZ+ylN!B#KBQ*Uc_ns9UaJAuffs{eD?Zdbs=}nj#hOW8|9;= zsFUk9v!nUl#)hGP_fvd)YlPT8+p~WMp6Z?#cwH8YUoxVr0hl-m#`dM5b48!#qsG0!}g@&S;qUBRn8?7)3 zP2J4k-eVXB9$$-6%@G%#EH8+m!u@v}0mP6e&kJUhzHwF1)`F07TV_Li?VXk^RxkK) zsRp0dozAoP8=f*tb6Bd3$lX)=1}JDwKn&&D@%>U45wV0a)_4bX+C~fby{Lrd2r40~ zA9s_w93wR3xR5YxaY?M|LqT_%5i$XtSS$2>D!#y~0zGB6%u$>#b%`PJ$v%}ect>}d zO4Ngs^g}PGP$%yx|99zopnqqevqGXhnV@P8^OO-G6DrXyJVh@w4+>R9hc#=$NMKk| zgWE<0-51MQ--3=np1|l@`A8a#AbdU&xeQh~;R-rE-LZ!aTMX%62u;9nQ6c_3Gw}7oZYl{8 zF*x@$2Mp>}kC;#>0qTH82N8ZdUb5Rc09Yfa-!^?oGKN8+5x0+J@d2%*<& zzp*y}V6@DJS(FCNDUw6M_$hr&dQ5Vz4wQQ6(}*Q6=`1cb03lGSJT3lxks^5~7(c16 zsf$U@)=3di>N)ySl!4Rc0&xvUx&=`)HbRo?wbnZI6QwrH25$YtLe#|yY%JH3<0cH? z2?6hR_zF%8jJ%{@oFtJnJR<;Q=U)v5F;h= z%otjz!}Bn&A?<5jD24u z*V#$>$BgJcW6(keO$kpvfh&<$K>(t>YCow8fX>dt(`v-)4$gt3dY>(TKAYA|8yY^V zuO6c!&pw?ov2Mo1%<3Ky$!iLLteUsvxY3YU=umY-5CEZWaQT7K7v!wTF_3lbsg4;9 zZ8j9Gs-5=J0=flF1LQ=X9l&S{Kshw)>c+PNb(2@&+VRSNQF9ln=>%1#f2_H-6U1m& z*um^i`%`RqTz`o+g%k?0)qw-yg^uJGD2);R)OsBXSPbO!X6a7{2934ZL(2xByjExV zKP=QTthp+C9{)tSN5_MhYmhKvxLs*eOi=<}x+O@{mecUr-2gualTo@c z2)K%m)xoqd`6Cwd8(=3HXuK03cAfY})VE&v}! z{{+A9AnAI8p2Ec$T>7$n5atYGS}`pYH6Aj>he^!R!9NYswcX&QJqZ&p0FnF!Qw;`) zH9-FHG8`_M+1!em@P;U^cOJA%Rq&HjpumZjvOH~a1QdF<^IubZp@Et&10b0@DOfWQNI;`A=?ON|~-zdvm@84EK<^Yy>xdHfUdn9LdgR>JeKWF3STYiG{J zu)fAl7vISE;FJuemMCocJo%)5{O);v=KEyJM`P+U%4A1yy&PU#$@LA%afBxYY3Q3j zRR=+gk02#h<1{L$RM{}wLteh5=S{u_8eVkT6dyugUju%&)l$gnT6hVK^*_=AjJ^=$ z-4~5FADvjS8^-p|*H>^%e)PSYB6%xF%o&{0Z8FSGR{X>v9sfa~uOM8W@IF<_QC z!6|lK^PR9cVER#do5f&lCEE0nN_g|$Z>gWyi@Ml@q}H*i;{K3l5SuF9gCIyufRCEz zEM#EuBrSeoMX*jSH0$hL_Sj9C90d}g4QKE2$NQAYaRA6pYWCv6<%d>nO!Q6nW3w)*G z8c&JOvStGuntNyO+nTnOJ_``g#Mkn&$NP$Ddf-{P4fBXx^*FVdrvH&Be_T{dn+2Xh zL=VX?J#Hz6@XinwkB5qBv%xcX)$fyEeqs;;BN_3R=2f=`zRvDW(?^a#k1P28Zh>oPv?O{6KyZ|x5w;kew$RKeytPkpiJeMtmE2*jpzTv-ptAdY80XbCnI6qs-(=NeM_0{z% z&b^Ky)wnO3W`G=pR;8W+Z`yTRIq458QVaflS`O>Le^+}X@m%}~^-PI!t@Qa%^$^kT zU>TGdts-usX*5JvAXNwRTy_(H>=?qsf_iIeLSDgbj`s_KwNJ$ebt~Ni{949fQu+hm zx)sx`z!1a$mQhoS;0poEFqU@5ecCzJ1(30WC-@a}3u_*MxwO_d49hJUP<=SuO*KT; zOO+AdHI=^c+)m#KUc$0&2;U}ki6=TgmUVR1hw5&sG4d01Em}}@ht3otq{KZ?Me(xo z53jqai;*_S3KJCEp=7Z-z{bn4O8cXpjDXtD#snNUL8)f2mXhg;P<#u7=VL{xdgG&@ ztpmv_TV}2Q?x%xZ^ms7I3laoG`bg8Pgc_3Lhqs~1Wlv$$RrTHuD^vD%ys-X*ysGShni`q{@GnYPHZ!t zP_!VCcuf9zr3ydqphZhl^P1G4(u7A;z<#awWV0<87vsf%~lS?V`UzDPT=m;@dNv@y4`}ZZ24!xLDDZ7w%QZiI#a<0`*e`CQrAXG zR}qB@IyqksJJG1JD_zE)YnK2fY*GVYCn`Du(kaV#gx<{0h7KIsAkmJ>=e9 z*llv!orlQ9bkk5fCr)z(YP9Y9Ou0L#KhGn!Nv|Dz(7M&d)%C|qXl&V)u>e7_5W`2!psFy(ME$w!nbqp$+FoIxz%?#U%5Bz{oRf(@h#1VZy+4g8Uo}~2gNc<%3fy(Ca6?Yhs05G1 z+Fo~|sV9y;_akDssGE}B9>Jwq&ihcw83>!1bsfYk-3L;p_7E>Qer&7_kHIW|{GBp& z0}qxe_A2SUr%<=kM`lg(u?@@FJGd_O_m$MW)Dme^s$`L!cDC4->6mcrD{R9ucDqz` zHbQD~c?AY-u~8!>eKZ~`h*JL~)s5?-F=|rUJp3wWU@?3BWxT~+$RlDt4*aG^fBh)M zRm-_}oQ=jRsj2_46JE1sd*@h~_SNAJrB;Sg3!7cd_+qKme0%1BE8DSJ8}?k8#pZK? z#3i=G$eyY24 str: - return bcrypt.hashpw(pin.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") - - -def verify_pin(pin: str, hashed: str) -> bool: - return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8")) - - -def create_token(membre_id: str, groupe_id: str) -> str: - expire = datetime.now(timezone.utc) + timedelta(days=settings.JWT_EXPIRE_DAYS) - payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire} - return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) - - -def decode_token(token: str) -> dict: - try: - return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) - except JWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide ou expiré") - - -async def get_current_membre( - credentials: HTTPAuthorizationCredentials = Depends(security), - db: AsyncSession = Depends(get_db), -): - from app.models.membre import Membre - payload = decode_token(credentials.credentials) - membre_id = payload.get("sub") - if not membre_id: - raise HTTPException(status_code=401, detail="Token invalide") - result = await db.execute(select(Membre).where(Membre.id == membre_id, Membre.actif == True)) - membre = result.scalar_one_or_none() - if not membre: - raise HTTPException(status_code=401, detail="Membre introuvable ou inactif") - return membre - - -async def require_executif( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - from app.models.affectation import Affectation - from app.models.poste import Poste - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.categorie == "executif", - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé à l'exécutif") - return membre diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/main.py b/groupe-meditation-corrige/groupe-meditation/backend/app/main.py deleted file mode 100644 index 9ebe70b..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/main.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Groupe Méditation — API Backend -District 87-16, Région 87 -100% logiciel libre · Hébergé au Québec -""" -from contextlib import asynccontextmanager -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from app.core.config import settings -from app.core.database import engine, Base -from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, jumelages - - -@asynccontextmanager -async def lifespan(app: FastAPI): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield - await engine.dispose() - - -app = FastAPI( - title="Groupe Méditation API", - description="API du Groupe Méditation — District 87-16, Région 87", - version="0.1.0", - lifespan=lifespan, -) - -app.add_middleware( - CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Montage des routes -app.include_router(auth.router, prefix="/api") -app.include_router(membres.router, prefix="/api") -app.include_router(postes.router, prefix="/api") -app.include_router(accueil.router, prefix="/api") -app.include_router(invitations.router, prefix="/api") -app.include_router(collectes.router, prefix="/api") -app.include_router(reunions.router, prefix="/api") -app.include_router(depenses.router, prefix="/api") -app.include_router(decisions.router, prefix="/api") -app.include_router(calendrier.router, prefix="/api") -app.include_router(rapports.router, prefix="/api") -app.include_router(rotations.router, prefix="/api") -app.include_router(parametres.router, prefix="/api") -app.include_router(presences.router, prefix="/api") -app.include_router(pv.router, prefix="/api") -app.include_router(rapports_rsg.router, prefix="/api") -app.include_router(jetons.router, prefix="/api") -app.include_router(anniversaires.router, prefix="/api") -app.include_router(commandes_jetons.router, prefix="/api") -app.include_router(litterature.router, prefix="/api") -app.include_router(ventes_litterature.router, prefix="/api") -app.include_router(contributions.router, prefix="/api") -app.include_router(config_repartition.router, prefix="/api") -app.include_router(sondages.router, prefix="/api") -app.include_router(jumelages.router, prefix="/api") - - -@app.get("/api/health") -async def root_health(): - return {"status": "ok", "app": "Groupe Méditation", "version": "0.1.0"} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/__init__.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/affectation.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/affectation.py deleted file mode 100644 index c18612e..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/affectation.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Table affectations.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import String, Date, Text, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.core.database import Base - - -class Affectation(Base): - __tablename__ = "affectations" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False) - date_debut: Mapped[date] = mapped_column(Date, nullable=False) - date_fin: Mapped[date | None] = mapped_column(Date) - statut: Mapped[str] = mapped_column(SAEnum("actif", "termine", name="statut_affectation"), default="actif") - note_transition: Mapped[str | None] = mapped_column(Text) - termine_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - - membre = relationship("Membre", back_populates="affectations", foreign_keys=[membre_id]) - poste = relationship("Poste") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/collecte.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/collecte.py deleted file mode 100644 index f209660..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/collecte.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Table collectes — 7e Tradition.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import Date, DateTime, ForeignKey, Numeric -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Collecte(Base): - __tablename__ = "collectes" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - date_collecte: Mapped[date] = mapped_column(Date, nullable=False) - montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) - reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id")) - saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/config_repartition.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/config_repartition.py deleted file mode 100644 index db5040e..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/config_repartition.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Table config_repartition — % de répartition des contributions.""" -import uuid -from datetime import date -from sqlalchemy import Date, ForeignKey, Numeric, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class ConfigRepartition(Base): - __tablename__ = "config_repartition" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - destination: Mapped[str] = mapped_column( - SAEnum("district", "region", "bsg", "intergroupe", name="destination_repartition", create_constraint=False), - nullable=False, - ) - pourcentage: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False) - actif_depuis: Mapped[date] = mapped_column(Date, nullable=False) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/decision.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/decision.py deleted file mode 100644 index 0f1ee4a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/decision.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Table decisions — suivi des décisions de réunion d'affaires.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Decision(Base): - __tablename__ = "decisions" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id")) - sujet: Mapped[str] = mapped_column(String(255), nullable=False) - description: Mapped[str | None] = mapped_column(Text) - statut: Mapped[str] = mapped_column( - SAEnum("en_attente", "en_cours", "complete", name="statut_decision"), - default="en_attente", - ) - responsable_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - proposeur: Mapped[str | None] = mapped_column(String(100)) - secondeur: Mapped[str | None] = mapped_column(String(100)) - date_decision: Mapped[date] = mapped_column(Date, nullable=False) - date_completion: Mapped[date | None] = mapped_column(Date) - cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/depense.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/depense.py deleted file mode 100644 index 1dd66b6..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/depense.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Table depenses — 6 champs selon l'architecture v4.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Depense(Base): - __tablename__ = "depenses" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - date_depense: Mapped[date] = mapped_column(Date, nullable=False) - montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) - qui: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - quoi: Mapped[str] = mapped_column(String(255), nullable=False) - paye: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - methode: Mapped[str] = mapped_column( - SAEnum("DU", "VIREMENT", "CHEQUE", "COLLECTE", name="methode_paiement"), - nullable=False, - ) - saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/envoi_contribution.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/envoi_contribution.py deleted file mode 100644 index 792f135..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/envoi_contribution.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Table envois_contributions — Envois aux organismes AA.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import Date, DateTime, ForeignKey, Numeric, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class EnvoiContribution(Base): - __tablename__ = "envois_contributions" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - date_envoi: Mapped[date] = mapped_column(Date, nullable=False) - destination: Mapped[str] = mapped_column( - SAEnum("district", "region", "bsg", "intergroupe", name="destination_contribution"), - nullable=False, - ) - montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) - methode: Mapped[str] = mapped_column( - SAEnum("DU", "VIREMENT", "CHEQUE", name="methode_contribution"), - nullable=False, - ) - saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/groupe.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/groupe.py deleted file mode 100644 index abebad4..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/groupe.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Table groupes.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import String, Text, Time, DateTime -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.core.database import Base - - -class Groupe(Base): - __tablename__ = "groupes" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - nom: Mapped[str] = mapped_column(String(100), nullable=False) - district: Mapped[str] = mapped_column(String(20), default="87-16") - region: Mapped[str] = mapped_column(String(20), default="87") - adresse_local: Mapped[str | None] = mapped_column(Text) - jour_reunion: Mapped[str | None] = mapped_column(String(10)) - heure_reunion: Mapped[datetime | None] = mapped_column(Time) - format_reunion: Mapped[str | None] = mapped_column(Text) - lien_aa87: Mapped[str] = mapped_column(String(255), default="https://aa87.org") - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - - membres = relationship("Membre", back_populates="groupe") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/invitation.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/invitation.py deleted file mode 100644 index a1ac47d..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/invitation.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Table invitations.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Invitation(Base): - __tablename__ = "invitations" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - code: Mapped[str] = mapped_column(String(12), unique=True, nullable=False) - cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - utilise_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - expire_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - utilise: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/jeton.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/jeton.py deleted file mode 100644 index 832682d..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/jeton.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Table jetons — Remise de jetons de sobriété.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import String, Integer, Date, DateTime, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Jeton(Base): - __tablename__ = "jetons" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - type_jeton: Mapped[str] = mapped_column( - SAEnum( - "desir", "24h", "1mois", "2mois", "3mois", - "6mois", "9mois", "1an", "multiple", - name="type_jeton", - ), - nullable=False, - ) - date_remise: Mapped[date] = mapped_column(Date, nullable=False) - annees: Mapped[int | None] = mapped_column(Integer) # pour type_jeton='multiple' - remis_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/jumelage.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/jumelage.py deleted file mode 100644 index 437f635..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/jumelage.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Table jumelages — Parrainage discret.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import String, Date, DateTime, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Jumelage(Base): - __tablename__ = "jumelages" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - parrain_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - filleul_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - date_jumelage: Mapped[date] = mapped_column(Date, nullable=False) - statut: Mapped[str] = mapped_column( - SAEnum("actif", "termine", name="statut_jumelage"), - default="actif", - ) - note: Mapped[str | None] = mapped_column(String(255)) - cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/litterature.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/litterature.py deleted file mode 100644 index e39967b..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/litterature.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Table litterature — Inventaire AA.""" -import uuid -from sqlalchemy import String, Integer, Numeric, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Litterature(Base): - __tablename__ = "litterature" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - titre: Mapped[str] = mapped_column(String(200), nullable=False) - categorie: Mapped[str] = mapped_column( - SAEnum("livre", "brochure", "depliant", "pamphlet", name="categorie_litterature"), - nullable=False, - ) - prix_coutant: Mapped[float | None] = mapped_column(Numeric(8, 2)) - quantite_stock: Mapped[int] = mapped_column(Integer, default=0) - seuil_commande: Mapped[int] = mapped_column(Integer, default=2) - code_bsg: Mapped[str | None] = mapped_column(String(20)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/membre.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/membre.py deleted file mode 100644 index e26345d..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/membre.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Table membres.""" -import uuid -from datetime import datetime, date, timezone -from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, JSON -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.core.database import Base - - -class Membre(Base): - __tablename__ = "membres" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - prenom: Mapped[str] = mapped_column(String(50), nullable=False) - nom: Mapped[str] = mapped_column(String(50), nullable=False) - telephone: Mapped[str | None] = mapped_column(String(20)) - courriel: Mapped[str | None] = mapped_column(String(100)) - date_abstinence: Mapped[date | None] = mapped_column(Date) - pin_hash: Mapped[str] = mapped_column(String(255), nullable=False) - actif: Mapped[bool] = mapped_column(Boolean, default=True) - push_sub: Mapped[dict | None] = mapped_column(JSON) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) - - groupe = relationship("Groupe", back_populates="membres") - affectations = relationship("Affectation", back_populates="membre", foreign_keys="Affectation.membre_id") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/option_sondage.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/option_sondage.py deleted file mode 100644 index 77b9fc1..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/option_sondage.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Table options_sondage — Options de vote.""" -import uuid -from sqlalchemy import String, Integer, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.core.database import Base - - -class OptionSondage(Base): - __tablename__ = "options_sondage" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False) - texte: Mapped[str] = mapped_column(String(200), nullable=False) - ordre: Mapped[int] = mapped_column(Integer, default=0) - - sondage = relationship("Sondage", back_populates="options") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/poste.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/poste.py deleted file mode 100644 index ad09b8b..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/poste.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Table postes.""" -import uuid -from sqlalchemy import String, Text, Integer, Interval, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Poste(Base): - __tablename__ = "postes" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - nom: Mapped[str] = mapped_column(String(100), nullable=False) - categorie: Mapped[str] = mapped_column(SAEnum("executif", "service", "physique", name="categorie_poste"), nullable=False) - description: Mapped[str | None] = mapped_column(Text) - duree_recommandee: Mapped[str | None] = mapped_column(Interval) - prerequis: Mapped[str | None] = mapped_column(Text) - ordre_affichage: Mapped[int | None] = mapped_column(Integer) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/presence.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/presence.py deleted file mode 100644 index 473746a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/presence.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Table presences — réunions d'affaires seulement.""" -import uuid -from sqlalchemy import Boolean, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Presence(Base): - __tablename__ = "presences" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - reunion_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reunions.id"), nullable=False) - membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - present: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/pv_reunion.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/pv_reunion.py deleted file mode 100644 index 87aca74..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/pv_reunion.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Table pv_reunions — procès-verbaux des réunions d'affaires.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import Text, Boolean, DateTime, ForeignKey, JSON -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class PvReunion(Base): - __tablename__ = "pv_reunions" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - reunion_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reunions.id"), nullable=False) - redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - contenu: Mapped[str] = mapped_column(Text, nullable=False) - decisions: Mapped[list | None] = mapped_column(JSON) # [{sujet, decision, proposeur, secondeur, statut}] - approuve: Mapped[bool] = mapped_column(Boolean, default=False) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/rapport_rsg.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/rapport_rsg.py deleted file mode 100644 index 4961370..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/rapport_rsg.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Table rapports_rsg — compte-rendus de réunion de district.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import Date, DateTime, Text, ForeignKey, JSON -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class RapportRsg(Base): - __tablename__ = "rapports_rsg" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - reunion_dist: Mapped[date] = mapped_column(Date, nullable=False) - redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - decisions: Mapped[list | None] = mapped_column(JSON) - infos_region: Mapped[str | None] = mapped_column(Text) - sujets_groupe: Mapped[str | None] = mapped_column(Text) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/reunion.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/reunion.py deleted file mode 100644 index 4ec65bc..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/reunion.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Table reunions.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import String, DateTime, Text, ForeignKey, JSON, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Reunion(Base): - __tablename__ = "reunions" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - type: Mapped[str] = mapped_column(SAEnum("reguliere", "affaires", name="type_reunion"), nullable=False) - date_reunion: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - animateur_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - theme: Mapped[str | None] = mapped_column(String(200)) - annonces: Mapped[dict | None] = mapped_column(JSON) - note: Mapped[str | None] = mapped_column(Text) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/rotation.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/rotation.py deleted file mode 100644 index e6ef3bf..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/rotation.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Table rotations — planification hebdomadaire des serviteurs.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import String, Date, DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Rotation(Base): - __tablename__ = "rotations" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - date_reunion: Mapped[date] = mapped_column(Date, nullable=False) - poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False) - membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - note: Mapped[str | None] = mapped_column(String(255)) - cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/sondage.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/sondage.py deleted file mode 100644 index b89834e..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/sondage.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Table sondages — Votes de conscience de groupe.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import String, Text, DateTime, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.core.database import Base - - -class Sondage(Base): - __tablename__ = "sondages" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - titre: Mapped[str] = mapped_column(String(200), nullable=False) - description: Mapped[str | None] = mapped_column(Text) - type: Mapped[str] = mapped_column( - SAEnum("oui_non", "choix_multiple", "unanimite", name="type_sondage"), - nullable=False, - ) - cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - date_ouverture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - date_fermeture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - statut: Mapped[str] = mapped_column( - SAEnum("brouillon", "ouvert", "ferme", name="statut_sondage"), - default="brouillon", - ) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - - options = relationship("OptionSondage", back_populates="sondage", cascade="all, delete-orphan") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/sync_log.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/sync_log.py deleted file mode 100644 index e17d2fa..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/sync_log.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Table sync_log — Journal de synchronisation.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import String, BigInteger, DateTime, ForeignKey, JSON, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class SyncLog(Base): - __tablename__ = "sync_log" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - groupe_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("groupes.id")) - table_name: Mapped[str] = mapped_column(String(50), nullable=False) - record_id: Mapped[uuid.UUID] = mapped_column(nullable=False) - action: Mapped[str] = mapped_column( - SAEnum("insert", "update", "delete", name="sync_action"), - nullable=False, - ) - timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - data: Mapped[dict | None] = mapped_column(JSON) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/vente_litterature.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/vente_litterature.py deleted file mode 100644 index 4829d68..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/vente_litterature.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Table ventes_litterature — Ventes de littérature AA.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import Integer, Date, DateTime, ForeignKey, Numeric -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class VenteLitterature(Base): - __tablename__ = "ventes_litterature" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - litterature_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("litterature.id"), nullable=False) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - quantite: Mapped[int] = mapped_column(Integer, nullable=False) - montant: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False) - date_vente: Mapped[date] = mapped_column(Date, nullable=False) - vendu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/vote.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/vote.py deleted file mode 100644 index e04d850..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/models/vote.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Table votes — Votes anonymes.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Vote(Base): - __tablename__ = "votes" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False) - option_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("options_sondage.id", ondelete="CASCADE"), nullable=False) - membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - vote_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/__init__.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/accueil.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/accueil.py deleted file mode 100644 index 23ca035..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/accueil.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Route accueil — écran dynamique par rôle.""" -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.schemas import AccueilResponse, ModuleTuile, MembrePublic - -router = APIRouter(prefix="/accueil", tags=["Accueil"]) - -# ── Définition des modules ── -MODULES_CONSULTATION = [ - ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, rapport RSG", icone="📄", couleur="blue"), - ModuleTuile(code="C2", nom="Calendrier district", description="Serviteurs de tous les groupes", icone="📅", couleur="green"), - ModuleTuile(code="C3", nom="Anniversaires", description="Sobriété du district 87-16", icone="🎂", couleur="yellow"), - ModuleTuile(code="C4", nom="Littérature", description="Inventaire et prix", icone="📚", couleur="purple"), - ModuleTuile(code="C5", nom="Décisions en cours", description="Suivi réunion d'affaires", icone="📌", couleur="red"), - ModuleTuile(code="C6", nom="Votes ouverts", description="Conscience de groupe", icone="🗳️", couleur="indigo"), - ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"), -] - -# Modules de saisie par poste -MODULES_SAISIE = { - "Trésorier(ière)": [ - ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"), - ModuleTuile(code="S5", nom="Dépense", description="Enregistrer une dépense", icone="💸", couleur="red"), - ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"), - ], - "Secrétaire": [ - ModuleTuile(code="M1", nom="Rédiger PV", description="Procès-verbal", icone="📝", couleur="blue"), - ModuleTuile(code="M2", nom="Présences", description="Réunion d'affaires", icone="✅", couleur="green"), - ], - "RSG": [ - ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"), - ], - "RSG Substitut(e)": [ - ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"), - ], - "Animateur(trice)": [ - ModuleTuile(code="S4", nom="Rapport réunion", description="Thème et annonces", icone="🎤", couleur="purple"), - ], - "Responsable littérature": [ - ModuleTuile(code="S3", nom="Vente littérature", description="Enregistrer une vente", icone="📖", couleur="purple"), - ModuleTuile(code="M5", nom="Ajuster inventaire", description="Mettre à jour le stock", icone="📦", couleur="orange"), - ], - "Responsable jetons": [ - ModuleTuile(code="S2", nom="Remise jeton", description="Enregistrer un jeton", icone="🏅", couleur="yellow"), - ModuleTuile(code="M6", nom="Commander jetons", description="Commander à l'Intergroupe", icone="📦", couleur="orange"), - ], -} - -MODULES_GESTION = [ - ModuleTuile(code="G1", nom="Inviter un membre", description="Code d'invitation 48h", icone="➕", couleur="green"), - ModuleTuile(code="G2", nom="Gérer les membres", description="Fiche complète, désactiver", icone="👥", couleur="blue"), - ModuleTuile(code="G3", nom="Affecter les postes", description="Assigner un serviteur", icone="🎯", couleur="purple"), - ModuleTuile(code="G4", nom="Terminer un mandat", description="Transition manuelle", icone="🔄", couleur="orange"), - ModuleTuile(code="G5", nom="Planifier rotations", description="Calendrier serviteurs", icone="📅", couleur="green"), - ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"), - ModuleTuile(code="G7", nom="Jumeler parrain", description="Parrain / filleul", icone="🤝", couleur="yellow"), - ModuleTuile(code="G8", nom="Config. répartition", description="% contributions", icone="⚙️", couleur="gray"), - ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"), -] - - -@router.get("/", response_model=AccueilResponse) -async def accueil( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Écran d'accueil dynamique — retourne les modules selon les postes du membre.""" - # Récupérer les affectations actives - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - ) - ) - affectations = result.scalars().all() - - postes_noms = [] - postes_categories = [] - for a in affectations: - poste = await db.get(Poste, a.poste_id) - if poste: - postes_noms.append(poste.nom) - postes_categories.append(poste.categorie) - - # Modules de consultation : toujours visibles - modules_consultation = MODULES_CONSULTATION - - # Modules de saisie : union des modules de tous les postes - modules_saisie = [] - codes_vus = set() - for nom_poste in postes_noms: - for m in MODULES_SAISIE.get(nom_poste, []): - if m.code not in codes_vus: - modules_saisie.append(m) - codes_vus.add(m.code) - - # Modules de gestion : seulement si exécutif - modules_gestion = MODULES_GESTION if "executif" in postes_categories else [] - - return AccueilResponse( - membre=MembrePublic.model_validate(membre), - postes=postes_noms, - modules_consultation=modules_consultation, - modules_saisie=modules_saisie, - modules_gestion=modules_gestion, - ) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/anniversaires.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/anniversaires.py deleted file mode 100644 index a50f8d2..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/anniversaires.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Routes C3 — Anniversaires district 87-16.""" -from datetime import date, timedelta -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.membre import Membre -from app.models.groupe import Groupe -from app.schemas.phase5 import AnniversaireOut - -router = APIRouter(prefix="/anniversaires", tags=["C3 — Anniversaires district"]) - - -def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]: - """Calcule les années de sobriété, le prochain anniversaire et les jours restants.""" - annees = aujourdhui.year - date_abstinence.year - # Anniversaire cette année - try: - anniv_cette_annee = date_abstinence.replace(year=aujourdhui.year) - except ValueError: - # 29 février - anniv_cette_annee = date(aujourdhui.year, 3, 1) - - if anniv_cette_annee < aujourdhui: - # L'anniversaire de cette année est passé - try: - prochain = date_abstinence.replace(year=aujourdhui.year + 1) - except ValueError: - prochain = date(aujourdhui.year + 1, 3, 1) - annees_sobriete = annees - elif anniv_cette_annee == aujourdhui: - prochain = anniv_cette_annee - annees_sobriete = annees - else: - prochain = anniv_cette_annee - annees_sobriete = annees - 1 - - # Minimum 0 - annees_sobriete = max(0, annees_sobriete) - jours_restants = (prochain - aujourdhui).days - - return annees_sobriete, prochain, jours_restants - - -@router.get("/", response_model=list[AnniversaireOut]) -async def anniversaires_district( - prochains_jours: int = 90, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C3 — Anniversaires de sobriété de tous les groupes du district 87-16.""" - aujourdhui = date.today() - - # Récupérer le district du groupe du membre - groupe = await db.get(Groupe, membre.groupe_id) - district = groupe.district if groupe else "87-16" - - # Tous les membres actifs du district ayant une date d'abstinence - result = await db.execute( - select(Membre, Groupe.nom) - .join(Groupe, Membre.groupe_id == Groupe.id) - .where( - Groupe.district == district, - Membre.actif == True, - Membre.date_abstinence.isnot(None), - ) - ) - rows = result.all() - - anniversaires = [] - for m, groupe_nom in rows: - annees_sobriete, prochain, jours_restants = _calcul_anniversaire(m.date_abstinence, aujourdhui) - - # Filtrer : prochains X jours OU anniversaire aujourd'hui - if 0 <= jours_restants <= prochains_jours: - anniversaires.append(AnniversaireOut( - membre_prenom=m.prenom, - groupe_nom=groupe_nom, - date_abstinence=m.date_abstinence, - annees_sobriete=annees_sobriete + 1, # prochain anniversaire = +1 - prochain_anniversaire=prochain, - jours_restants=jours_restants, - )) - - # Trier par jours restants (prochains en premier) - anniversaires.sort(key=lambda a: a.jours_restants) - return anniversaires diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/auth.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/auth.py deleted file mode 100644 index 67bdcce..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/auth.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Routes d'authentification.""" -from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func - -from app.core.database import get_db -from app.core.security import hash_pin, verify_pin, create_token -from app.models.membre import Membre -from app.models.groupe import Groupe -from app.models.invitation import Invitation -from app.schemas.schemas import ConnexionRequest, InscriptionRequest, TokenResponse, MembrePublic - -router = APIRouter(prefix="/auth", tags=["Authentification"]) - - -@router.get("/health") -async def health(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Groupe).limit(1)) - groupe = result.scalar_one_or_none() - return { - "status": "ok", - "groupe": groupe.nom if groupe else "Aucun", - "district": groupe.district if groupe else "-", - } - - -@router.get("/groupes") -async def liste_groupes(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Groupe).order_by(Groupe.nom)) - groupes = result.scalars().all() - return [{"id": str(g.id), "nom": g.nom, "district": g.district} for g in groupes] - - -@router.post("/connexion", response_model=TokenResponse) -async def connexion(req: ConnexionRequest, db: AsyncSession = Depends(get_db)): - # Chercher le membre par prénom + groupe (insensible à la casse) - result = await db.execute( - select(Membre).where( - func.lower(Membre.prenom) == func.lower(req.prenom), - Membre.groupe_id == req.groupe_id, - Membre.actif == True, - ) - ) - membre = result.scalar_one_or_none() - if not membre or not verify_pin(req.pin, membre.pin_hash): - raise HTTPException(status_code=401, detail="Prénom ou PIN incorrect") - - token = create_token(str(membre.id), str(membre.groupe_id)) - return TokenResponse( - access_token=token, - membre=MembrePublic.model_validate(membre), - ) - - -@router.post("/inscription", response_model=TokenResponse) -async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)): - # Valider le code d'invitation - result = await db.execute( - select(Invitation).where( - Invitation.code == req.code_invitation, - Invitation.utilise == False, - ) - ) - invitation = result.scalar_one_or_none() - if not invitation: - raise HTTPException(status_code=400, detail="Code d'invitation invalide") - if invitation.expire_le < datetime.now(timezone.utc): - raise HTTPException(status_code=400, detail="Code d'invitation expiré") - - # Vérifier unicité du prénom dans le groupe - exists = await db.execute( - select(Membre).where( - func.lower(Membre.prenom) == func.lower(req.prenom), - Membre.groupe_id == invitation.groupe_id, - Membre.actif == True, - ) - ) - if exists.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Ce prénom est déjà utilisé dans ce groupe") - - # Valider le PIN (4 chiffres) - if not req.pin.isdigit() or len(req.pin) != 4: - raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres") - - # Créer le membre - membre = Membre( - groupe_id=invitation.groupe_id, - prenom=req.prenom.strip().title(), - nom=req.nom.strip().title(), - telephone=req.telephone, - courriel=req.courriel, - date_abstinence=req.date_abstinence, - pin_hash=hash_pin(req.pin), - ) - db.add(membre) - - # Marquer l'invitation utilisée - invitation.utilise = True - invitation.utilise_par = membre.id - await db.flush() - - token = create_token(str(membre.id), str(membre.groupe_id)) - return TokenResponse( - access_token=token, - membre=MembrePublic.model_validate(membre), - ) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/calendrier.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/calendrier.py deleted file mode 100644 index c4ff35c..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/calendrier.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Routes C2 — Calendrier district 87-16.""" -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.groupe import Groupe -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.models.membre import Membre -from app.schemas.phase2 import CalendrierEntry - -router = APIRouter(prefix="/calendrier", tags=["C2 — Calendrier district"]) - - -@router.get("/", response_model=list[CalendrierEntry]) -async def calendrier_district( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C2 — Calendrier des serviteurs de tous les groupes du district 87-16.""" - # Récupérer le district du membre - groupe = await db.get(Groupe, membre.groupe_id) - district = groupe.district if groupe else "87-16" - - # Tous les groupes du même district - result = await db.execute( - select(Groupe).where(Groupe.district == district).order_by(Groupe.nom) - ) - groupes = {g.id: g for g in result.scalars().all()} - - # Toutes les affectations actives de ces groupes - result = await db.execute( - select(Affectation).where( - Affectation.groupe_id.in_(groupes.keys()), - Affectation.statut == "actif", - ) - ) - affectations = result.scalars().all() - - entries = [] - for a in affectations: - poste = await db.get(Poste, a.poste_id) - mbr = await db.get(Membre, a.membre_id) - grp = groupes.get(a.groupe_id) - if poste and mbr and grp: - entries.append(CalendrierEntry( - groupe_nom=grp.nom, - groupe_id=grp.id, - jour_reunion=grp.jour_reunion, - poste_nom=poste.nom, - membre_prenom=mbr.prenom, - date_debut=a.date_debut, - )) - - # Trier par groupe puis par ordre de poste - entries.sort(key=lambda e: (e.groupe_nom, e.poste_nom)) - return entries - - -@router.get("/groupe/{groupe_id}") -async def calendrier_groupe( - groupe_id: str, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Détail des serviteurs d'un groupe spécifique.""" - groupe = await db.get(Groupe, groupe_id) - if not groupe: - return {"groupe": None, "serviteurs": []} - - result = await db.execute( - select(Affectation).where( - Affectation.groupe_id == groupe_id, - Affectation.statut == "actif", - ) - ) - affectations = result.scalars().all() - - serviteurs = [] - for a in affectations: - poste = await db.get(Poste, a.poste_id) - mbr = await db.get(Membre, a.membre_id) - if poste and mbr: - serviteurs.append({ - "poste": poste.nom, - "categorie": poste.categorie, - "prenom": mbr.prenom, - "depuis": str(a.date_debut), - }) - serviteurs.sort(key=lambda s: s.get("poste", "")) - - return { - "groupe": { - "id": str(groupe.id), - "nom": groupe.nom, - "jour_reunion": groupe.jour_reunion, - "adresse": groupe.adresse_local, - }, - "serviteurs": serviteurs, - } diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/collectes.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/collectes.py deleted file mode 100644 index a849b1e..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/collectes.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Routes S1 — Collecte 7e Tradition.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.collecte import Collecte -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase1 import CollecteCreate, CollecteOut - -router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"]) - - -async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est trésorier.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%trésorier%"), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au trésorier") - return membre - - -@router.post("/", response_model=CollecteOut) -async def saisir_collecte( - req: CollecteCreate, - membre=Depends(require_tresorier), - db: AsyncSession = Depends(get_db), -): - """S1 — Saisir le montant collecté au panier.""" - collecte = Collecte( - groupe_id=membre.groupe_id, - date_collecte=req.date_collecte, - montant=req.montant, - reunion_id=req.reunion_id, - saisi_par=membre.id, - ) - db.add(collecte) - await db.flush() - return CollecteOut( - id=collecte.id, - date_collecte=collecte.date_collecte, - montant=float(collecte.montant), - saisi_par_prenom=membre.prenom, - cree_le=collecte.cree_le, - ) - - -@router.get("/", response_model=list[CollecteOut]) -async def liste_collectes( - limite: int = 20, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les collectes récentes du groupe.""" - result = await db.execute( - select(Collecte) - .where(Collecte.groupe_id == membre.groupe_id) - .order_by(desc(Collecte.date_collecte)) - .limit(limite) - ) - collectes = result.scalars().all() - out = [] - for c in collectes: - saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None - out.append(CollecteOut( - id=c.id, - date_collecte=c.date_collecte, - montant=float(c.montant), - saisi_par_prenom=saisi.prenom if saisi else None, - cree_le=c.cree_le, - )) - return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/commandes_jetons.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/commandes_jetons.py deleted file mode 100644 index 1e82e71..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/commandes_jetons.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Routes M6 — Commander des jetons.""" -from datetime import date, timedelta -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.jeton import Jeton -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase5 import StockJetonsOut, TYPES_JETONS -from fastapi import HTTPException - -router = APIRouter(prefix="/commandes-jetons", tags=["M6 — Commander jetons"]) - - -async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est responsable des jetons.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%jeton%"), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au responsable des jetons") - return membre - - -@router.get("/stock", response_model=list[StockJetonsOut]) -async def etat_stock_jetons( - membre=Depends(require_resp_jetons), - db: AsyncSession = Depends(get_db), -): - """M6 — État du stock de jetons (basé sur l'utilisation du dernier mois).""" - il_y_a_30_jours = date.today() - timedelta(days=30) - - # Compter les jetons remis par type dans les 30 derniers jours - result = await db.execute( - select(Jeton.type_jeton, func.count(Jeton.id)) - .where( - Jeton.groupe_id == membre.groupe_id, - Jeton.date_remise >= il_y_a_30_jours, - ) - .group_by(Jeton.type_jeton) - ) - usage = {row[0]: row[1] for row in result.all()} - - stock = [] - for t in TYPES_JETONS: - remis = usage.get(t["value"], 0) - # Suggestion simple : si plus de 2 remis dans le mois, suggérer commande - suggestion = "Bas — commander" if remis >= 2 else "OK" - # Le jeton de désir est toujours à surveiller (remis chaque semaine) - if t["value"] == "desir" and remis >= 4: - suggestion = "Bas — commander" - - stock.append(StockJetonsOut( - type_jeton=t["value"], - label=f"{t['emoji']} {t['label']}", - remis_dernier_mois=remis, - stock_suggestion=suggestion, - )) - return stock diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/config_repartition.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/config_repartition.py deleted file mode 100644 index 3031289..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/config_repartition.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Routes G8 — Configurer la répartition des contributions.""" -from datetime import date -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import require_executif -from app.models.config_repartition import ConfigRepartition -from app.schemas.phase7 import ( - ConfigRepartitionOut, ConfigRepartitionBatch, - DESTINATIONS, DESTINATIONS_LABELS, -) - -router = APIRouter(prefix="/config-repartition", tags=["G8 — Config répartition"]) - -DEFAUT_REPARTITION = { - "district": 30.0, - "region": 30.0, - "bsg": 30.0, - "intergroupe": 10.0, -} - - -@router.get("/", response_model=list[ConfigRepartitionOut]) -async def voir_repartition( - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G8 — Voir la répartition actuelle des contributions.""" - result = await db.execute( - select(ConfigRepartition) - .where(ConfigRepartition.groupe_id == membre.groupe_id) - .order_by(desc(ConfigRepartition.actif_depuis)) - ) - configs = result.scalars().all() - - # Garder la plus récente par destination - vu = {} - out = [] - for c in configs: - if c.destination not in vu: - vu[c.destination] = True - out.append(ConfigRepartitionOut( - destination=c.destination, - destination_label=DESTINATIONS_LABELS.get(c.destination, c.destination), - pourcentage=float(c.pourcentage), - actif_depuis=c.actif_depuis, - )) - - # Compléter avec les défauts pour les destinations manquantes - for dest in DESTINATIONS: - if dest not in vu: - out.append(ConfigRepartitionOut( - destination=dest, - destination_label=DESTINATIONS_LABELS[dest], - pourcentage=DEFAUT_REPARTITION[dest], - actif_depuis=date.today(), - )) - - # Trier par destination - ordre = {d: i for i, d in enumerate(DESTINATIONS)} - out.sort(key=lambda x: ordre.get(x.destination, 99)) - return out - - -@router.put("/", response_model=list[ConfigRepartitionOut]) -async def modifier_repartition( - req: ConfigRepartitionBatch, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G8 — Modifier la répartition (toutes les destinations d'un coup).""" - # Validation - total = sum(item.pourcentage for item in req.items) - if abs(total - 100.0) > 0.01: - raise HTTPException(status_code=400, detail=f"Le total doit être 100%. Actuellement: {total:.2f}%") - - for item in req.items: - if item.destination not in DESTINATIONS: - raise HTTPException(status_code=400, detail=f"Destination invalide: {item.destination}") - if item.pourcentage < 0: - raise HTTPException(status_code=400, detail="Les pourcentages ne peuvent pas être négatifs") - - aujourdhui = date.today() - out = [] - for item in req.items: - config = ConfigRepartition( - groupe_id=membre.groupe_id, - destination=item.destination, - pourcentage=item.pourcentage, - actif_depuis=aujourdhui, - ) - db.add(config) - await db.flush() - out.append(ConfigRepartitionOut( - destination=config.destination, - destination_label=DESTINATIONS_LABELS.get(config.destination, config.destination), - pourcentage=float(config.pourcentage), - actif_depuis=config.actif_depuis, - )) - - return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/contributions.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/contributions.py deleted file mode 100644 index 338e14a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/contributions.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Routes M4 — Envoi de contributions aux organismes AA.""" -from datetime import date -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.envoi_contribution import EnvoiContribution -from app.models.config_repartition import ConfigRepartition -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase7 import ( - EnvoiContributionCreate, EnvoiContributionOut, - CalculRepartitionIn, CalculRepartitionOut, - DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION, -) - -router = APIRouter(prefix="/contributions", tags=["M4 — Envoi contributions"]) - -# Répartition par défaut selon le Guide du groupe -DEFAUT_REPARTITION = { - "district": 30.0, - "region": 30.0, - "bsg": 30.0, - "intergroupe": 10.0, -} - - -async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%trésorier%"), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au trésorier") - return membre - - -async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]: - """Récupère la répartition active ou utilise les défauts.""" - result = await db.execute( - select(ConfigRepartition) - .where(ConfigRepartition.groupe_id == groupe_id) - .order_by(desc(ConfigRepartition.actif_depuis)) - ) - configs = result.scalars().all() - - # Garder la plus récente par destination - repartition = {} - for c in configs: - if c.destination not in repartition: - repartition[c.destination] = float(c.pourcentage) - - # Compléter avec les défauts - for dest in DESTINATIONS: - if dest not in repartition: - repartition[dest] = DEFAUT_REPARTITION[dest] - - return repartition - - -@router.post("/calculer", response_model=list[CalculRepartitionOut]) -async def calculer_repartition( - req: CalculRepartitionIn, - membre=Depends(require_tresorier), - db: AsyncSession = Depends(get_db), -): - """Calculer la répartition d'un montant selon les % configurés.""" - if req.montant_total <= 0: - raise HTTPException(status_code=400, detail="Le montant doit être positif") - - repartition = await _get_repartition(membre.groupe_id, db) - return [ - CalculRepartitionOut( - destination=dest, - destination_label=DESTINATIONS_LABELS[dest], - pourcentage=pct, - montant=round(req.montant_total * pct / 100, 2), - ) - for dest, pct in repartition.items() - ] - - -@router.post("/", response_model=EnvoiContributionOut) -async def enregistrer_envoi( - req: EnvoiContributionCreate, - membre=Depends(require_tresorier), - db: AsyncSession = Depends(get_db), -): - """M4 — Enregistrer un envoi de contribution à un organisme.""" - if req.destination not in DESTINATIONS: - raise HTTPException(status_code=400, detail=f"Destination invalide. Choix: {DESTINATIONS}") - if req.methode.upper() not in METHODES_CONTRIBUTION: - raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}") - - envoi = EnvoiContribution( - groupe_id=membre.groupe_id, - date_envoi=req.date_envoi, - destination=req.destination, - montant=req.montant, - methode=req.methode.upper(), - saisi_par=membre.id, - ) - db.add(envoi) - await db.flush() - - return EnvoiContributionOut( - id=envoi.id, - date_envoi=envoi.date_envoi, - destination=envoi.destination, - destination_label=DESTINATIONS_LABELS[envoi.destination], - montant=float(envoi.montant), - methode=envoi.methode, - saisi_par_prenom=membre.prenom, - cree_le=envoi.cree_le, - ) - - -@router.get("/", response_model=list[EnvoiContributionOut]) -async def liste_envois( - limite: int = 30, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les envois de contributions récents.""" - result = await db.execute( - select(EnvoiContribution) - .where(EnvoiContribution.groupe_id == membre.groupe_id) - .order_by(desc(EnvoiContribution.date_envoi)) - .limit(limite) - ) - envois = result.scalars().all() - out = [] - for e in envois: - saisi = await db.get(Membre, e.saisi_par) if e.saisi_par else None - out.append(EnvoiContributionOut( - id=e.id, - date_envoi=e.date_envoi, - destination=e.destination, - destination_label=DESTINATIONS_LABELS.get(e.destination, e.destination), - montant=float(e.montant), - methode=e.methode, - saisi_par_prenom=saisi.prenom if saisi else None, - cree_le=e.cree_le, - )) - return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/decisions.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/decisions.py deleted file mode 100644 index 52ade4a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/decisions.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Routes C5 — Décisions en cours.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre, require_executif -from app.models.decision import Decision -from app.models.membre import Membre -from app.schemas.phase2 import DecisionCreate, DecisionOut, DecisionUpdate - -router = APIRouter(prefix="/decisions", tags=["C5 — Décisions en cours"]) - - -async def _to_out(d: Decision, db: AsyncSession) -> DecisionOut: - resp = await db.get(Membre, d.responsable_id) if d.responsable_id else None - return DecisionOut( - id=d.id, - sujet=d.sujet, - description=d.description, - statut=d.statut, - responsable_prenom=resp.prenom if resp else None, - proposeur=d.proposeur, - secondeur=d.secondeur, - date_decision=d.date_decision, - date_completion=d.date_completion, - cree_le=d.cree_le, - ) - - -@router.get("/", response_model=list[DecisionOut]) -async def liste_decisions( - statut: str | None = None, - limite: int = 50, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C5 — Lister les décisions du groupe avec filtre optionnel par statut.""" - query = select(Decision).where(Decision.groupe_id == membre.groupe_id) - if statut: - query = query.where(Decision.statut == statut) - query = query.order_by(desc(Decision.date_decision)).limit(limite) - result = await db.execute(query) - return [await _to_out(d, db) for d in result.scalars().all()] - - -@router.get("/compteur") -async def compteur_decisions( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Nombre de décisions ouvertes (pour le badge sur la tuile).""" - result = await db.execute( - select(Decision).where( - Decision.groupe_id == membre.groupe_id, - Decision.statut.in_(["en_attente", "en_cours"]), - ) - ) - return {"ouvertes": len(result.scalars().all())} - - -@router.post("/", response_model=DecisionOut) -async def creer_decision( - req: DecisionCreate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Créer une décision (exécutif — typiquement via le PV).""" - decision = Decision( - groupe_id=executif.groupe_id, - reunion_id=req.reunion_id, - sujet=req.sujet.strip(), - description=req.description, - statut=req.statut, - responsable_id=req.responsable_id, - proposeur=req.proposeur, - secondeur=req.secondeur, - date_decision=req.date_decision, - cree_par=executif.id, - ) - db.add(decision) - await db.flush() - return await _to_out(decision, db) - - -@router.patch("/{decision_id}", response_model=DecisionOut) -async def modifier_decision( - decision_id: str, - req: DecisionUpdate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Modifier le statut d'une décision (exécutif).""" - result = await db.execute( - select(Decision).where(Decision.id == decision_id, Decision.groupe_id == executif.groupe_id) - ) - decision = result.scalar_one_or_none() - if not decision: - raise HTTPException(status_code=404, detail="Décision introuvable") - - if req.statut is not None: - decision.statut = req.statut - if req.description is not None: - decision.description = req.description - if req.responsable_id is not None: - decision.responsable_id = req.responsable_id - if req.date_completion is not None: - decision.date_completion = req.date_completion - await db.flush() - return await _to_out(decision, db) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/depenses.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/depenses.py deleted file mode 100644 index 25d24ab..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/depenses.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Routes S5 — Dépenses (6 champs).""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.depense import Depense -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate - -router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"]) - -METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"} - - -async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est trésorier.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%trésorier%"), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au trésorier") - return membre - - -@router.post("/", response_model=DepenseOut) -async def saisir_depense( - req: DepenseCreate, - membre=Depends(require_tresorier), - db: AsyncSession = Depends(get_db), -): - """S5 — Saisir une dépense avec les 6 champs.""" - # Valider la méthode - if req.methode.upper() not in METHODES_VALIDES: - raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}") - - # Valider que le membre 'qui' existe - qui_membre = await db.get(Membre, req.qui) - if not qui_membre or qui_membre.groupe_id != membre.groupe_id: - raise HTTPException(status_code=400, detail="Membre introuvable") - - depense = Depense( - groupe_id=membre.groupe_id, - date_depense=req.date_depense, - montant=req.montant, - qui=req.qui, - quoi=req.quoi.strip(), - paye=req.paye, - methode=req.methode.upper(), - saisi_par=membre.id, - ) - db.add(depense) - await db.flush() - - return DepenseOut( - id=depense.id, - date_depense=depense.date_depense, - montant=float(depense.montant), - qui_prenom=qui_membre.prenom, - quoi=depense.quoi, - paye=depense.paye, - methode=depense.methode, - saisi_par_prenom=membre.prenom, - cree_le=depense.cree_le, - ) - - -@router.get("/", response_model=list[DepenseOut]) -async def liste_depenses( - limite: int = 30, - paye: bool | None = None, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les dépenses du groupe avec filtre optionnel sur payé.""" - query = select(Depense).where(Depense.groupe_id == membre.groupe_id) - if paye is not None: - query = query.where(Depense.paye == paye) - query = query.order_by(desc(Depense.date_depense)).limit(limite) - - result = await db.execute(query) - depenses = result.scalars().all() - - out = [] - for d in depenses: - qui = await db.get(Membre, d.qui) - saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None - out.append(DepenseOut( - id=d.id, - date_depense=d.date_depense, - montant=float(d.montant), - qui_prenom=qui.prenom if qui else "?", - quoi=d.quoi, - paye=d.paye, - methode=d.methode, - saisi_par_prenom=saisi.prenom if saisi else None, - cree_le=d.cree_le, - )) - return out - - -@router.patch("/{depense_id}", response_model=DepenseOut) -async def modifier_depense( - depense_id: str, - req: DepenseUpdate, - membre=Depends(require_tresorier), - db: AsyncSession = Depends(get_db), -): - """Modifier une dépense (ex: marquer comme payé).""" - result = await db.execute( - select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) - ) - depense = result.scalar_one_or_none() - if not depense: - raise HTTPException(status_code=404, detail="Dépense introuvable") - - if req.paye is not None: - depense.paye = req.paye - if req.methode is not None: - if req.methode.upper() not in METHODES_VALIDES: - raise HTTPException(status_code=400, detail="Méthode invalide") - depense.methode = req.methode.upper() - if req.quoi is not None: - depense.quoi = req.quoi.strip() - - await db.flush() - - qui = await db.get(Membre, depense.qui) - return DepenseOut( - id=depense.id, - date_depense=depense.date_depense, - montant=float(depense.montant), - qui_prenom=qui.prenom if qui else "?", - quoi=depense.quoi, - paye=depense.paye, - methode=depense.methode, - saisi_par_prenom=membre.prenom, - cree_le=depense.cree_le, - ) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/invitations.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/invitations.py deleted file mode 100644 index a48d8d9..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/invitations.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Routes invitations.""" -import secrets -from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.database import get_db -from app.core.config import settings -from app.core.security import require_executif -from app.models.invitation import Invitation -from app.schemas.schemas import InvitationOut - -router = APIRouter(prefix="/invitations", tags=["Invitations"]) - - -@router.post("/", response_model=InvitationOut) -async def creer_invitation( - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G1 — Générer un code d'invitation (expire après 48h).""" - code = secrets.token_hex(6).upper() # 12 caractères hex - invitation = Invitation( - groupe_id=executif.groupe_id, - code=code, - cree_par=executif.id, - expire_le=datetime.now(timezone.utc) + timedelta(hours=settings.INVITATION_EXPIRE_HOURS), - ) - db.add(invitation) - await db.flush() - return InvitationOut.model_validate(invitation) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jetons.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jetons.py deleted file mode 100644 index ac29bcd..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jetons.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Routes S2 — Remise de jeton.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.jeton import Jeton -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS - -router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"]) - - -async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est responsable des jetons.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%jeton%"), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au responsable des jetons") - return membre - - -@router.get("/types") -async def types_jetons(): - """Liste des types de jetons disponibles.""" - return TYPES_JETONS - - -@router.post("/", response_model=JetonOut) -async def remettre_jeton( - req: JetonCreate, - membre=Depends(require_resp_jetons), - db: AsyncSession = Depends(get_db), -): - """S2 — Enregistrer la remise d'un jeton.""" - # Validation - types_valides = [t["value"] for t in TYPES_JETONS] - if req.type_jeton not in types_valides: - raise HTTPException(status_code=400, detail=f"Type de jeton invalide. Valeurs: {types_valides}") - - if req.type_jeton == "multiple" and (not req.annees or req.annees < 2): - raise HTTPException(status_code=400, detail="Le nombre d'années est requis (≥ 2) pour un jeton multiple") - - # Vérifier que le membre existe si spécifié - membre_prenom = None - if req.membre_id: - cible = await db.get(Membre, req.membre_id) - if not cible or not cible.actif: - raise HTTPException(status_code=404, detail="Membre introuvable") - membre_prenom = cible.prenom - - jeton = Jeton( - groupe_id=membre.groupe_id, - membre_id=req.membre_id, - type_jeton=req.type_jeton, - date_remise=req.date_remise, - annees=req.annees if req.type_jeton == "multiple" else None, - remis_par=membre.id, - ) - db.add(jeton) - await db.flush() - - return JetonOut( - id=jeton.id, - membre_prenom=membre_prenom, - type_jeton=jeton.type_jeton, - date_remise=jeton.date_remise, - annees=jeton.annees, - remis_par_prenom=membre.prenom, - cree_le=jeton.cree_le, - ) - - -@router.get("/", response_model=list[JetonOut]) -async def liste_jetons( - limite: int = 30, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les jetons remis récemment (tout le groupe).""" - result = await db.execute( - select(Jeton) - .where(Jeton.groupe_id == membre.groupe_id) - .order_by(desc(Jeton.date_remise)) - .limit(limite) - ) - jetons = result.scalars().all() - out = [] - for j in jetons: - m = await db.get(Membre, j.membre_id) if j.membre_id else None - r = await db.get(Membre, j.remis_par) if j.remis_par else None - out.append(JetonOut( - id=j.id, - membre_prenom=m.prenom if m else None, - type_jeton=j.type_jeton, - date_remise=j.date_remise, - annees=j.annees, - remis_par_prenom=r.prenom if r else None, - cree_le=j.cree_le, - )) - return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jumelages.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jumelages.py deleted file mode 100644 index ec18d0d..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jumelages.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Routes G7 — Jumelage parrain/filleul (confidentiel).""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import require_executif -from app.models.jumelage import Jumelage -from app.models.membre import Membre -from app.schemas.phase8 import JumelageCreate, JumelageOut, JumelageUpdate - -router = APIRouter(prefix="/jumelages", tags=["G7 — Parrainage"]) - - -async def _to_out(j: Jumelage, db: AsyncSession) -> JumelageOut: - parrain = await db.get(Membre, j.parrain_id) - filleul = await db.get(Membre, j.filleul_id) - cree = await db.get(Membre, j.cree_par) if j.cree_par else None - return JumelageOut( - id=j.id, - parrain_prenom=parrain.prenom if parrain else "?", - filleul_prenom=filleul.prenom if filleul else "?", - date_jumelage=j.date_jumelage, - statut=j.statut, - note=j.note, - cree_par_prenom=cree.prenom if cree else None, - ) - - -@router.post("/", response_model=JumelageOut) -async def creer_jumelage( - req: JumelageCreate, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G7 — Jumeler un parrain et un filleul.""" - if req.parrain_id == req.filleul_id: - raise HTTPException(status_code=400, detail="Le parrain et le filleul doivent être différents") - - parrain = await db.get(Membre, req.parrain_id) - filleul = await db.get(Membre, req.filleul_id) - if not parrain or not parrain.actif: - raise HTTPException(status_code=404, detail="Parrain introuvable ou inactif") - if not filleul or not filleul.actif: - raise HTTPException(status_code=404, detail="Filleul introuvable ou inactif") - - jumelage = Jumelage( - groupe_id=membre.groupe_id, - parrain_id=req.parrain_id, - filleul_id=req.filleul_id, - date_jumelage=req.date_jumelage, - note=req.note, - cree_par=membre.id, - ) - db.add(jumelage) - await db.flush() - - return await _to_out(jumelage, db) - - -@router.get("/", response_model=list[JumelageOut]) -async def liste_jumelages( - statut: str | None = None, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Liste des jumelages (confidentiel — exécutif seulement).""" - query = select(Jumelage).where(Jumelage.groupe_id == membre.groupe_id) - if statut: - query = query.where(Jumelage.statut == statut) - query = query.order_by(desc(Jumelage.date_jumelage)) - - result = await db.execute(query) - jumelages = result.scalars().all() - return [await _to_out(j, db) for j in jumelages] - - -@router.patch("/{jumelage_id}", response_model=JumelageOut) -async def modifier_jumelage( - jumelage_id: str, - req: JumelageUpdate, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Modifier ou terminer un jumelage.""" - result = await db.execute( - select(Jumelage).where(Jumelage.id == jumelage_id, Jumelage.groupe_id == membre.groupe_id) - ) - jumelage = result.scalar_one_or_none() - if not jumelage: - raise HTTPException(status_code=404, detail="Jumelage introuvable") - - if req.statut and req.statut in ("actif", "termine"): - jumelage.statut = req.statut - if req.note is not None: - jumelage.note = req.note - await db.flush() - - return await _to_out(jumelage, db) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/litterature.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/litterature.py deleted file mode 100644 index a865960..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/litterature.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Routes C4 / M5 — Littérature : inventaire, ajustement, gestion catalogue.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.litterature import Litterature -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock - -router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"]) - - -async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est responsable de la littérature.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%littérature%"), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au responsable de la littérature") - return membre - - -def _to_out(item: Litterature) -> LitteratureOut: - return LitteratureOut( - id=item.id, - titre=item.titre, - categorie=item.categorie, - prix_coutant=float(item.prix_coutant) if item.prix_coutant else None, - quantite_stock=item.quantite_stock, - seuil_commande=item.seuil_commande, - code_bsg=item.code_bsg, - stock_bas=item.quantite_stock <= item.seuil_commande, - ) - - -@router.get("/", response_model=list[LitteratureOut]) -async def inventaire( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C4 — Inventaire de la littérature du groupe avec prix.""" - result = await db.execute( - select(Litterature) - .where(Litterature.groupe_id == membre.groupe_id) - .order_by(Litterature.categorie, Litterature.titre) - ) - return [_to_out(item) for item in result.scalars().all()] - - -@router.post("/", response_model=LitteratureOut) -async def ajouter_titre( - req: LitteratureCreate, - membre=Depends(require_resp_litterature), - db: AsyncSession = Depends(get_db), -): - """Ajouter un titre au catalogue du groupe.""" - categories_valides = ["livre", "brochure", "depliant", "pamphlet"] - if req.categorie not in categories_valides: - raise HTTPException(status_code=400, detail=f"Catégorie invalide. Valeurs: {categories_valides}") - - item = Litterature( - groupe_id=membre.groupe_id, - titre=req.titre, - categorie=req.categorie, - prix_coutant=req.prix_coutant, - quantite_stock=req.quantite_stock, - seuil_commande=req.seuil_commande, - code_bsg=req.code_bsg, - ) - db.add(item) - await db.flush() - return _to_out(item) - - -@router.patch("/{item_id}", response_model=LitteratureOut) -async def modifier_titre( - item_id: str, - req: LitteratureUpdate, - membre=Depends(require_resp_litterature), - db: AsyncSession = Depends(get_db), -): - """Modifier les infos d'un titre.""" - result = await db.execute( - select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Titre introuvable") - - for field, value in req.model_dump(exclude_none=True).items(): - setattr(item, field, value) - await db.flush() - return _to_out(item) - - -@router.patch("/{item_id}/stock", response_model=LitteratureOut) -async def ajuster_stock( - item_id: str, - req: AjustementStock, - membre=Depends(require_resp_litterature), - db: AsyncSession = Depends(get_db), -): - """M5 — Ajuster le stock après réception d'une commande ou correction.""" - result = await db.execute( - select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Titre introuvable") - - nouveau_stock = item.quantite_stock + req.quantite - if nouveau_stock < 0: - raise HTTPException(status_code=400, detail="Le stock ne peut pas être négatif") - - item.quantite_stock = nouveau_stock - await db.flush() - return _to_out(item) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/membres.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/membres.py deleted file mode 100644 index 62c0c18..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/membres.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Routes membres — profil et gestion.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select - -from app.core.database import get_db -from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin -from app.models.membre import Membre -from app.schemas.schemas import MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest - -router = APIRouter(prefix="/membres", tags=["Membres"]) - - -@router.get("/moi", response_model=MembreComplet) -async def mon_profil(membre=Depends(get_current_membre)): - """C7 — Mon profil.""" - return MembreComplet.model_validate(membre) - - -@router.patch("/moi", response_model=MembreComplet) -async def modifier_profil( - updates: MembreUpdate, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C7 — Modifier mon profil.""" - if updates.telephone is not None: - membre.telephone = updates.telephone - if updates.courriel is not None: - membre.courriel = updates.courriel - if updates.date_abstinence is not None: - membre.date_abstinence = updates.date_abstinence - await db.flush() - return MembreComplet.model_validate(membre) - - -@router.post("/moi/pin") -async def changer_pin( - req: ChangerPinRequest, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C7 — Changer mon PIN.""" - if not verify_pin(req.ancien_pin, membre.pin_hash): - raise HTTPException(status_code=400, detail="Ancien PIN incorrect") - if not req.nouveau_pin.isdigit() or len(req.nouveau_pin) != 4: - raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres") - membre.pin_hash = hash_pin(req.nouveau_pin) - await db.flush() - return {"message": "PIN modifié avec succès"} - - -@router.get("/actifs", response_model=list[MembrePublic]) -async def liste_membres_actifs( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Liste simplifiée des membres actifs (prénom + id) — pour les dropdowns.""" - result = await db.execute( - select(Membre).where( - Membre.groupe_id == membre.groupe_id, - Membre.actif == True, - ).order_by(Membre.prenom) - ) - return [MembrePublic.model_validate(m) for m in result.scalars().all()] - - -@router.get("/", response_model=list[MembreComplet]) -async def liste_membres( - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G2 — Liste des membres (exécutif seulement).""" - result = await db.execute( - select(Membre).where( - Membre.groupe_id == executif.groupe_id, - Membre.actif == True, - ).order_by(Membre.prenom) - ) - return [MembreComplet.model_validate(m) for m in result.scalars().all()] - - -@router.patch("/{membre_id}/desactiver") -async def desactiver_membre( - membre_id: str, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G2 — Désactiver un membre.""" - result = await db.execute( - select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id) - ) - membre = result.scalar_one_or_none() - if not membre: - raise HTTPException(status_code=404, detail="Membre introuvable") - membre.actif = False - await db.flush() - return {"message": f"{membre.prenom} a été désactivé"} - - -@router.delete("/{membre_id}/oubli") -async def droit_oubli( - membre_id: str, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G2 — Droit à l'oubli : anonymise les données personnelles.""" - result = await db.execute( - select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id) - ) - membre = result.scalar_one_or_none() - if not membre: - raise HTTPException(status_code=404, detail="Membre introuvable") - if str(membre.id) == str(executif.id): - raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte") - - prenom_original = membre.prenom - # Anonymiser - membre.prenom = f"Ancien membre" - membre.nom = "***" - membre.telephone = None - membre.courriel = None - membre.pin_hash = "SUPPRIME" - membre.actif = False - membre.push_sub = None - await db.flush() - return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/parametres.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/parametres.py deleted file mode 100644 index b1e0ef0..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/parametres.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Routes G9 — Paramètres du groupe.""" -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.database import get_db -from app.core.security import require_executif, get_current_membre -from app.models.groupe import Groupe -from app.schemas.phase3 import GroupeUpdate, GroupeDetail - -router = APIRouter(prefix="/groupe", tags=["G9 — Paramètres"]) - - -@router.get("/", response_model=GroupeDetail) -async def detail_groupe( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Détail du groupe du membre connecté.""" - groupe = await db.get(Groupe, membre.groupe_id) - return GroupeDetail.model_validate(groupe) - - -@router.patch("/", response_model=GroupeDetail) -async def modifier_groupe( - req: GroupeUpdate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G9 — Modifier les paramètres du groupe (exécutif).""" - groupe = await db.get(Groupe, executif.groupe_id) - if req.nom is not None: - groupe.nom = req.nom.strip() - if req.adresse_local is not None: - groupe.adresse_local = req.adresse_local.strip() - if req.jour_reunion is not None: - groupe.jour_reunion = req.jour_reunion.strip() - if req.heure_reunion is not None: - groupe.heure_reunion = req.heure_reunion - if req.format_reunion is not None: - groupe.format_reunion = req.format_reunion.strip() - if req.lien_aa87 is not None: - groupe.lien_aa87 = req.lien_aa87.strip() - await db.flush() - return GroupeDetail.model_validate(groupe) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/postes.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/postes.py deleted file mode 100644 index 403167a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/postes.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Routes postes et affectations.""" -from datetime import date -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select - -from app.core.database import get_db -from app.core.security import get_current_membre, require_executif -from app.models.poste import Poste -from app.models.affectation import Affectation -from app.schemas.schemas import PosteOut, AffectationOut, AffectationCreate, TerminerMandatRequest - -router = APIRouter(prefix="/postes", tags=["Postes et affectations"]) - - -@router.get("/", response_model=list[PosteOut]) -async def liste_postes(db: AsyncSession = Depends(get_db), _=Depends(get_current_membre)): - result = await db.execute(select(Poste).order_by(Poste.ordre_affichage)) - return [PosteOut.model_validate(p) for p in result.scalars().all()] - - -@router.get("/affectations") -async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Retourne les affectations actives du membre connecté.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - ) - ) - affectations = result.scalars().all() - out = [] - for a in affectations: - poste = await db.get(Poste, a.poste_id) - out.append({ - "id": str(a.id), - "poste_nom": poste.nom if poste else "?", - "poste_categorie": poste.categorie if poste else "?", - "date_debut": str(a.date_debut), - "statut": a.statut, - }) - return out - - -@router.get("/affectations/groupe") -async def affectations_groupe( - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G3 — Toutes les affectations actives du groupe.""" - result = await db.execute( - select(Affectation).where( - Affectation.groupe_id == executif.groupe_id, - Affectation.statut == "actif", - ) - ) - affectations = result.scalars().all() - out = [] - for a in affectations: - from app.models.membre import Membre - poste = await db.get(Poste, a.poste_id) - mbr = await db.get(Membre, a.membre_id) - out.append({ - "id": str(a.id), - "membre_prenom": mbr.prenom if mbr else "?", - "poste_nom": poste.nom if poste else "?", - "poste_categorie": poste.categorie if poste else "?", - "date_debut": str(a.date_debut), - }) - return out - - -@router.post("/affectations") -async def creer_affectation( - req: AffectationCreate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G3 — Affecter un membre à un poste.""" - affectation = Affectation( - groupe_id=executif.groupe_id, - membre_id=req.membre_id, - poste_id=req.poste_id, - date_debut=req.date_debut, - statut="actif", - ) - db.add(affectation) - await db.flush() - return {"message": "Affectation créée", "id": str(affectation.id)} - - -@router.post("/affectations/{affectation_id}/terminer") -async def terminer_mandat( - affectation_id: str, - req: TerminerMandatRequest, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G4 — Terminer un mandat manuellement.""" - result = await db.execute( - select(Affectation).where( - Affectation.id == affectation_id, - Affectation.groupe_id == executif.groupe_id, - Affectation.statut == "actif", - ) - ) - affectation = result.scalar_one_or_none() - if not affectation: - raise HTTPException(status_code=404, detail="Affectation introuvable") - - affectation.statut = "termine" - affectation.date_fin = date.today() - affectation.note_transition = req.note_transition - affectation.termine_par = executif.id - await db.flush() - return {"message": "Mandat terminé"} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/presences.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/presences.py deleted file mode 100644 index 1cf0df8..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/presences.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Routes M2 — Présences en réunion d'affaires.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.reunion import Reunion -from app.models.presence import Presence -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase4 import ( - PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut, -) - -router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"]) - - -async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)): - """Vérifie que le membre est secrétaire ou exécutif.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif") - return membre - - -@router.post("/reunion", response_model=ReunionAffairesOut) -async def creer_reunion_affaires( - req: ReunionAffairesCreate, - membre=Depends(require_secretaire), - db: AsyncSession = Depends(get_db), -): - """Créer une réunion d'affaires (point de départ pour présences et PV).""" - reunion = Reunion( - groupe_id=membre.groupe_id, - type="affaires", - date_reunion=req.date_reunion, - note=req.note, - ) - db.add(reunion) - await db.flush() - return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note) - - -@router.get("/reunions", response_model=list[ReunionAffairesOut]) -async def liste_reunions_affaires( - limite: int = 12, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les réunions d'affaires récentes avec leurs présences.""" - result = await db.execute( - select(Reunion).where( - Reunion.groupe_id == membre.groupe_id, - Reunion.type == "affaires", - ).order_by(desc(Reunion.date_reunion)).limit(limite) - ) - reunions = result.scalars().all() - - out = [] - for r in reunions: - pres_result = await db.execute( - select(Presence).where(Presence.reunion_id == r.id) - ) - presences = pres_result.scalars().all() - pres_out = [] - for p in presences: - mbr = await db.get(Membre, p.membre_id) - pres_out.append(PresenceOut( - membre_id=p.membre_id, - membre_prenom=mbr.prenom if mbr else "?", - present=p.present, - )) - out.append(ReunionAffairesOut( - id=r.id, date_reunion=r.date_reunion, note=r.note, presences=pres_out, - )) - return out - - -@router.post("/", response_model=list[PresenceOut]) -async def enregistrer_presences( - req: PresencesBatch, - membre=Depends(require_secretaire), - db: AsyncSession = Depends(get_db), -): - """M2 — Enregistrer les présences pour une réunion d'affaires.""" - # Vérifier que la réunion existe et est de type affaires - result = await db.execute( - select(Reunion).where( - Reunion.id == req.reunion_id, - Reunion.groupe_id == membre.groupe_id, - ) - ) - reunion = result.scalar_one_or_none() - if not reunion: - raise HTTPException(status_code=404, detail="Réunion introuvable") - - # Supprimer les présences existantes pour cette réunion (pour pouvoir re-saisir) - existing = await db.execute( - select(Presence).where(Presence.reunion_id == req.reunion_id) - ) - for p in existing.scalars().all(): - await db.delete(p) - - # Insérer les nouvelles - out = [] - for item in req.presences: - pres = Presence( - reunion_id=req.reunion_id, - membre_id=item.membre_id, - present=item.present, - ) - db.add(pres) - mbr = await db.get(Membre, item.membre_id) - out.append(PresenceOut( - membre_id=item.membre_id, - membre_prenom=mbr.prenom if mbr else "?", - present=item.present, - )) - await db.flush() - return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/pv.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/pv.py deleted file mode 100644 index b4e0fa8..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/pv.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Routes M1 — Rédiger un PV de réunion d'affaires.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.pv_reunion import PvReunion -from app.models.reunion import Reunion -from app.models.membre import Membre -from app.models.decision import Decision -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase4 import PvCreate, PvOut, PvUpdate - -router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"]) - - -async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)): - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif") - return membre - - -async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut: - redacteur = await db.get(Membre, pv.redacteur_id) - reunion = await db.get(Reunion, pv.reunion_id) if pv.reunion_id else None - return PvOut( - id=pv.id, - reunion_id=pv.reunion_id, - redacteur_prenom=redacteur.prenom if redacteur else "?", - contenu=pv.contenu, - decisions=pv.decisions, - approuve=pv.approuve, - cree_le=pv.cree_le, - date_reunion=reunion.date_reunion if reunion else None, - ) - - -@router.get("/", response_model=list[PvOut]) -async def liste_pv( - limite: int = 12, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les PV des réunions d'affaires.""" - # Récupérer les PV via les réunions du groupe - result = await db.execute( - select(PvReunion) - .join(Reunion, PvReunion.reunion_id == Reunion.id) - .where(Reunion.groupe_id == membre.groupe_id) - .order_by(desc(PvReunion.cree_le)) - .limit(limite) - ) - return [await _to_out(pv, db) for pv in result.scalars().all()] - - -@router.get("/{pv_id}", response_model=PvOut) -async def detail_pv( - pv_id: str, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(PvReunion) - .join(Reunion, PvReunion.reunion_id == Reunion.id) - .where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id) - ) - pv = result.scalar_one_or_none() - if not pv: - raise HTTPException(status_code=404, detail="PV introuvable") - return await _to_out(pv, db) - - -@router.post("/", response_model=PvOut) -async def creer_pv( - req: PvCreate, - membre=Depends(require_secretaire), - db: AsyncSession = Depends(get_db), -): - """M1 — Rédiger un procès-verbal.""" - # Vérifier la réunion - result = await db.execute( - select(Reunion).where( - Reunion.id == req.reunion_id, - Reunion.groupe_id == membre.groupe_id, - ) - ) - reunion = result.scalar_one_or_none() - if not reunion: - raise HTTPException(status_code=404, detail="Réunion introuvable") - - decisions_data = None - if req.decisions: - decisions_data = [d.model_dump() for d in req.decisions] - - pv = PvReunion( - reunion_id=req.reunion_id, - redacteur_id=membre.id, - contenu=req.contenu.strip(), - decisions=decisions_data, - ) - db.add(pv) - await db.flush() - - # Créer les décisions dans la table decisions (pour C5) - if req.decisions: - for d in req.decisions: - decision = Decision( - groupe_id=membre.groupe_id, - reunion_id=req.reunion_id, - sujet=d.sujet, - description=d.decision, - statut=d.statut, - proposeur=d.proposeur, - secondeur=d.secondeur, - date_decision=reunion.date_reunion.date() if reunion.date_reunion else None, - cree_par=membre.id, - ) - db.add(decision) - await db.flush() - - return await _to_out(pv, db) - - -@router.patch("/{pv_id}", response_model=PvOut) -async def modifier_pv( - pv_id: str, - req: PvUpdate, - membre=Depends(require_secretaire), - db: AsyncSession = Depends(get_db), -): - """Modifier un PV existant.""" - result = await db.execute( - select(PvReunion) - .join(Reunion, PvReunion.reunion_id == Reunion.id) - .where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id) - ) - pv = result.scalar_one_or_none() - if not pv: - raise HTTPException(status_code=404, detail="PV introuvable") - - if req.contenu is not None: - pv.contenu = req.contenu.strip() - if req.decisions is not None: - pv.decisions = [d.model_dump() for d in req.decisions] - if req.approuve is not None: - pv.approuve = req.approuve - await db.flush() - return await _to_out(pv, db) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports.py deleted file mode 100644 index 60fe2be..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Routes C1 — Rapports PDF (finances, PV, rapport RSG).""" -import io -from datetime import date, datetime -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import StreamingResponse -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, extract - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.groupe import Groupe -from app.models.collecte import Collecte -from app.models.depense import Depense -from app.models.membre import Membre -from app.models.reunion import Reunion -from app.models.envoi_contribution import EnvoiContribution - -router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"]) - -MOIS_FR = [ - "", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", - "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", -] - - -def _css(): - return """ - body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; } - h1 { color: #1a5276; font-size: 16pt; margin-bottom: 4pt; } - h2 { color: #1a5276; font-size: 13pt; border-bottom: 2px solid #1a5276; padding-bottom: 4pt; margin-top: 20pt; } - .header { text-align: center; margin-bottom: 20pt; } - .header .groupe { font-size: 18pt; font-weight: bold; color: #1a5276; } - .header .sub { font-size: 9pt; color: #888; } - table { width: 100%; border-collapse: collapse; margin-top: 8pt; } - th { background: #1a5276; color: white; padding: 6pt 8pt; text-align: left; font-size: 9pt; } - td { padding: 5pt 8pt; border-bottom: 1px solid #ddd; font-size: 10pt; } - tr:nth-child(even) { background: #f8f9fa; } - .total { font-weight: bold; font-size: 12pt; margin-top: 10pt; } - .footer { text-align: center; font-size: 8pt; color: #aaa; margin-top: 30pt; } - .badge-paye { color: #27ae60; } - .badge-impaye { color: #e74c3c; } - """ - - -@router.get("/finances/{annee}/{mois}") -async def rapport_finances( - annee: int, - mois: int, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C1 — Rapport financier mensuel en PDF.""" - try: - from weasyprint import HTML - except ImportError: - raise HTTPException(status_code=500, detail="WeasyPrint non installé. Installer avec: pip install weasyprint") - - groupe = await db.get(Groupe, membre.groupe_id) - - # Collectes du mois - result = await db.execute( - select(Collecte).where( - Collecte.groupe_id == membre.groupe_id, - extract("year", Collecte.date_collecte) == annee, - extract("month", Collecte.date_collecte) == mois, - ).order_by(Collecte.date_collecte) - ) - collectes = result.scalars().all() - - # Dépenses du mois - result = await db.execute( - select(Depense).where( - Depense.groupe_id == membre.groupe_id, - extract("year", Depense.date_depense) == annee, - extract("month", Depense.date_depense) == mois, - ).order_by(Depense.date_depense) - ) - depenses = result.scalars().all() - - total_collectes = sum(float(c.montant) for c in collectes) - total_depenses = sum(float(d.montant) for d in depenses) - solde = total_collectes - total_depenses - - # Construire le HTML - rows_collectes = "" - for c in collectes: - saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None - rows_collectes += f"{c.date_collecte}{float(c.montant):.2f} ${saisi.prenom if saisi else '-'}" - - rows_depenses = "" - for d in depenses: - qui = await db.get(Membre, d.qui) if d.qui else None - badge = '✓' if d.paye else '✗ DÛ' - rows_depenses += f"{d.date_depense}{float(d.montant):.2f} ${qui.prenom if qui else '-'}{d.quoi}{d.methode}{badge}" - - html_content = f""" -
-
{groupe.nom if groupe else 'Groupe'}
-
District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}
-
-

Rapport financier — {MOIS_FR[mois]} {annee}

- -

Collectes (7e Tradition)

- - {rows_collectes if rows_collectes else ''} -
DateMontantSaisi par
Aucune collecte ce mois
-

Total collectes : {total_collectes:.2f} $

- -

Dépenses

- - {rows_depenses if rows_depenses else ''} -
DateMontantQuiQuoiMéthodePayé
Aucune dépense ce mois
-

Total dépenses : {total_depenses:.2f} $

- -

Sommaire

- - - - -
Collectes{total_collectes:.2f} $
Dépenses−{total_depenses:.2f} $
Solde du mois{'+'if solde>=0 else ''}{solde:.2f} $
- - - """ - - pdf_bytes = HTML(string=html_content).write_pdf() - filename = f"rapport_finances_{groupe.nom.replace(' ','_')}_{annee}_{mois:02d}.pdf" - - return StreamingResponse( - io.BytesIO(pdf_bytes), - media_type="application/pdf", - headers={"Content-Disposition": f'inline; filename="{filename}"'}, - ) - - -@router.get("/reunions/{annee}/{mois}") -async def rapport_reunions( - annee: int, - mois: int, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C1 — Résumé des réunions du mois en PDF.""" - try: - from weasyprint import HTML - except ImportError: - raise HTTPException(status_code=500, detail="WeasyPrint non installé") - - groupe = await db.get(Groupe, membre.groupe_id) - - result = await db.execute( - select(Reunion).where( - Reunion.groupe_id == membre.groupe_id, - extract("year", Reunion.date_reunion) == annee, - extract("month", Reunion.date_reunion) == mois, - ).order_by(Reunion.date_reunion) - ) - reunions = result.scalars().all() - - rows = "" - for r in reunions: - anim = await db.get(Membre, r.animateur_id) if r.animateur_id else None - annonces_str = ", ".join(r.annonces) if r.annonces else "—" - rows += f"{r.date_reunion.strftime('%Y-%m-%d')}{r.type}{anim.prenom if anim else '-'}{r.theme or '-'}{annonces_str}" - - html_content = f""" -
-
{groupe.nom if groupe else 'Groupe'}
-
District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}
-
-

Réunions — {MOIS_FR[mois]} {annee}

- - {rows if rows else ''} -
DateTypeAnimateurThèmeAnnonces
Aucune réunion enregistrée ce mois
-

{len(reunions)} réunion(s) enregistrée(s)

- - """ - - pdf_bytes = HTML(string=html_content).write_pdf() - filename = f"rapport_reunions_{annee}_{mois:02d}.pdf" - - return StreamingResponse( - io.BytesIO(pdf_bytes), - media_type="application/pdf", - headers={"Content-Disposition": f'inline; filename="{filename}"'}, - ) - - -@router.get("/disponibles") -async def rapports_disponibles( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Liste les mois pour lesquels des rapports sont disponibles.""" - # Mois avec collectes - result = await db.execute( - select( - extract("year", Collecte.date_collecte).label("annee"), - extract("month", Collecte.date_collecte).label("mois"), - ).where(Collecte.groupe_id == membre.groupe_id) - .group_by("annee", "mois") - .order_by(extract("year", Collecte.date_collecte).desc(), extract("month", Collecte.date_collecte).desc()) - ) - mois_finances = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()] - - # Mois avec réunions - result = await db.execute( - select( - extract("year", Reunion.date_reunion).label("annee"), - extract("month", Reunion.date_reunion).label("mois"), - ).where(Reunion.groupe_id == membre.groupe_id) - .group_by("annee", "mois") - .order_by(extract("year", Reunion.date_reunion).desc(), extract("month", Reunion.date_reunion).desc()) - ) - mois_reunions = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()] - - # Mois avec contributions - result = await db.execute( - select( - extract("year", EnvoiContribution.date_envoi).label("annee"), - extract("month", EnvoiContribution.date_envoi).label("mois"), - ).where(EnvoiContribution.groupe_id == membre.groupe_id) - .group_by("annee", "mois") - ) - mois_contributions = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()] - - # Combiner et dédupliquer - all_mois = {} - for m in mois_finances: - key = f"{m['annee']}-{m['mois']:02d}" - all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": True, "reunions": False, "contributions": False} - for m in mois_reunions: - key = f"{m['annee']}-{m['mois']:02d}" - if key in all_mois: - all_mois[key]["reunions"] = True - else: - all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": False, "reunions": True, "contributions": False} - for m in mois_contributions: - key = f"{m['annee']}-{m['mois']:02d}" - if key in all_mois: - all_mois[key]["contributions"] = True - else: - all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": False, "reunions": False, "contributions": True} - - return sorted(all_mois.values(), key=lambda x: (x["annee"], x["mois"]), reverse=True) - - -@router.get("/financier-complet/{annee}/{mois}") -async def rapport_financier_complet( - annee: int, - mois: int, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Rapport financier complet incluant collectes, dépenses et contributions.""" - gid = membre.groupe_id - - # Collectes - result = await db.execute( - select(Collecte).where( - Collecte.groupe_id == gid, - extract("year", Collecte.date_collecte) == annee, - extract("month", Collecte.date_collecte) == mois, - ) - ) - collectes = result.scalars().all() - total_collectes = sum(float(c.montant) for c in collectes) - - # Dépenses - result = await db.execute( - select(Depense).where( - Depense.groupe_id == gid, - extract("year", Depense.date_depense) == annee, - extract("month", Depense.date_depense) == mois, - ) - ) - depenses = result.scalars().all() - total_depenses = sum(float(d.montant) for d in depenses) - depenses_impayees = sum(float(d.montant) for d in depenses if not d.paye) - - # Contributions - result = await db.execute( - select(EnvoiContribution).where( - EnvoiContribution.groupe_id == gid, - extract("year", EnvoiContribution.date_envoi) == annee, - extract("month", EnvoiContribution.date_envoi) == mois, - ) - ) - contributions = result.scalars().all() - total_contributions = sum(float(c.montant) for c in contributions) - - solde = total_collectes - total_depenses - total_contributions - - return { - "mois": mois, - "annee": annee, - "total_collectes": round(total_collectes, 2), - "total_depenses": round(total_depenses, 2), - "total_contributions": round(total_contributions, 2), - "solde_mois": round(solde, 2), - "nb_collectes": len(collectes), - "nb_depenses": len(depenses), - "nb_contributions": len(contributions), - "depenses_impayees": round(depenses_impayees, 2), - } diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports_rsg.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports_rsg.py deleted file mode 100644 index 9a206ba..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports_rsg.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Routes M3 — Rapport du RSG.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.rapport_rsg import RapportRsg -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase4 import RapportRsgCreate, RapportRsgOut - -router = APIRouter(prefix="/rapports-rsg", tags=["M3 — Rapport RSG"]) - - -async def require_rsg(membre=Depends(get_current_membre), db=Depends(get_db)): - """Vérifie que le membre est RSG, RSG substitut ou exécutif.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%RSG%") | (Poste.categorie == "executif")), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au RSG ou à l'exécutif") - return membre - - -async def _to_out(r: RapportRsg, db: AsyncSession) -> RapportRsgOut: - redacteur = await db.get(Membre, r.redacteur_id) - return RapportRsgOut( - id=r.id, - reunion_dist=r.reunion_dist, - redacteur_prenom=redacteur.prenom if redacteur else "?", - decisions=r.decisions, - infos_region=r.infos_region, - sujets_groupe=r.sujets_groupe, - cree_le=r.cree_le, - ) - - -@router.get("/", response_model=list[RapportRsgOut]) -async def liste_rapports_rsg( - limite: int = 12, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les rapports RSG du groupe.""" - result = await db.execute( - select(RapportRsg) - .where(RapportRsg.groupe_id == membre.groupe_id) - .order_by(desc(RapportRsg.reunion_dist)) - .limit(limite) - ) - return [await _to_out(r, db) for r in result.scalars().all()] - - -@router.post("/", response_model=RapportRsgOut) -async def creer_rapport_rsg( - req: RapportRsgCreate, - membre=Depends(require_rsg), - db: AsyncSession = Depends(get_db), -): - """M3 — Rédiger un rapport de réunion de district.""" - rapport = RapportRsg( - groupe_id=membre.groupe_id, - reunion_dist=req.reunion_dist, - redacteur_id=membre.id, - decisions=req.decisions, - infos_region=req.infos_region, - sujets_groupe=req.sujets_groupe, - ) - db.add(rapport) - await db.flush() - return await _to_out(rapport, db) - - -@router.get("/{rapport_id}", response_model=RapportRsgOut) -async def detail_rapport_rsg( - rapport_id: str, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(RapportRsg).where( - RapportRsg.id == rapport_id, - RapportRsg.groupe_id == membre.groupe_id, - ) - ) - rapport = result.scalar_one_or_none() - if not rapport: - raise HTTPException(status_code=404, detail="Rapport introuvable") - return await _to_out(rapport, db) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/reunions.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/reunions.py deleted file mode 100644 index da37a1d..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/reunions.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Routes S4 — Rapport de réunion.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.reunion import Reunion -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase1 import ReunionCreate, ReunionOut - -router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"]) - - -async def require_animateur(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est animateur ou exécutif.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%animateur%") | (Poste.categorie == "executif")), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé à l'animateur ou à l'exécutif") - return membre - - -@router.post("/", response_model=ReunionOut) -async def creer_rapport_reunion( - req: ReunionCreate, - membre=Depends(require_animateur), - db: AsyncSession = Depends(get_db), -): - """S4 — L'animateur note le thème et les annonces.""" - reunion = Reunion( - groupe_id=membre.groupe_id, - type=req.type, - date_reunion=req.date_reunion, - animateur_id=membre.id, - theme=req.theme, - annonces=req.annonces, - note=req.note, - ) - db.add(reunion) - await db.flush() - return ReunionOut( - id=reunion.id, - type=reunion.type, - date_reunion=reunion.date_reunion, - animateur_prenom=membre.prenom, - theme=reunion.theme, - annonces=reunion.annonces, - note=reunion.note, - cree_le=reunion.cree_le, - ) - - -@router.get("/", response_model=list[ReunionOut]) -async def liste_reunions( - limite: int = 20, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les réunions récentes du groupe.""" - result = await db.execute( - select(Reunion) - .where(Reunion.groupe_id == membre.groupe_id) - .order_by(desc(Reunion.date_reunion)) - .limit(limite) - ) - reunions = result.scalars().all() - out = [] - for r in reunions: - anim = await db.get(Membre, r.animateur_id) if r.animateur_id else None - out.append(ReunionOut( - id=r.id, - type=r.type, - date_reunion=r.date_reunion, - animateur_prenom=anim.prenom if anim else None, - theme=r.theme, - annonces=r.annonces, - note=r.note, - cree_le=r.cree_le, - )) - return out - - -@router.get("/{reunion_id}", response_model=ReunionOut) -async def detail_reunion( - reunion_id: str, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Reunion).where(Reunion.id == reunion_id, Reunion.groupe_id == membre.groupe_id) - ) - reunion = result.scalar_one_or_none() - if not reunion: - raise HTTPException(status_code=404, detail="Réunion introuvable") - anim = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None - return ReunionOut( - id=reunion.id, - type=reunion.type, - date_reunion=reunion.date_reunion, - animateur_prenom=anim.prenom if anim else None, - theme=reunion.theme, - annonces=reunion.annonces, - note=reunion.note, - cree_le=reunion.cree_le, - ) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rotations.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rotations.py deleted file mode 100644 index 6492958..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rotations.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Routes G5 — Planifier les rotations hebdomadaires.""" -from datetime import date, timedelta -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete - -from app.core.database import get_db -from app.core.security import require_executif, get_current_membre -from app.models.rotation import Rotation -from app.models.poste import Poste -from app.models.membre import Membre -from app.schemas.phase3 import RotationCreate, RotationOut - -router = APIRouter(prefix="/rotations", tags=["G5 — Rotations"]) - - -async def _to_out(r: Rotation, db: AsyncSession) -> RotationOut: - poste = await db.get(Poste, r.poste_id) - mbr = await db.get(Membre, r.membre_id) - return RotationOut( - id=r.id, - date_reunion=r.date_reunion, - poste_nom=poste.nom if poste else "?", - membre_prenom=mbr.prenom if mbr else "?", - note=r.note, - cree_le=r.cree_le, - ) - - -@router.get("/", response_model=list[RotationOut]) -async def liste_rotations( - semaines: int = 4, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Rotations des prochaines semaines.""" - aujourdhui = date.today() - fin = aujourdhui + timedelta(weeks=semaines) - result = await db.execute( - select(Rotation).where( - Rotation.groupe_id == membre.groupe_id, - Rotation.date_reunion >= aujourdhui, - Rotation.date_reunion <= fin, - ).order_by(Rotation.date_reunion, Rotation.poste_id) - ) - return [await _to_out(r, db) for r in result.scalars().all()] - - -@router.get("/historique", response_model=list[RotationOut]) -async def historique_rotations( - semaines: int = 4, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Rotations passées.""" - aujourdhui = date.today() - debut = aujourdhui - timedelta(weeks=semaines) - result = await db.execute( - select(Rotation).where( - Rotation.groupe_id == membre.groupe_id, - Rotation.date_reunion >= debut, - Rotation.date_reunion < aujourdhui, - ).order_by(Rotation.date_reunion.desc()) - ) - return [await _to_out(r, db) for r in result.scalars().all()] - - -@router.post("/", response_model=RotationOut) -async def creer_rotation( - req: RotationCreate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G5 — Assigner un serviteur pour une date de réunion.""" - rotation = Rotation( - groupe_id=executif.groupe_id, - date_reunion=req.date_reunion, - poste_id=req.poste_id, - membre_id=req.membre_id, - note=req.note, - cree_par=executif.id, - ) - db.add(rotation) - await db.flush() - return await _to_out(rotation, db) - - -@router.post("/batch", response_model=list[RotationOut]) -async def creer_rotations_batch( - rotations: list[RotationCreate], - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G5 — Assigner plusieurs serviteurs d'un coup pour une semaine.""" - created = [] - for req in rotations: - rotation = Rotation( - groupe_id=executif.groupe_id, - date_reunion=req.date_reunion, - poste_id=req.poste_id, - membre_id=req.membre_id, - note=req.note, - cree_par=executif.id, - ) - db.add(rotation) - await db.flush() - created.append(await _to_out(rotation, db)) - return created - - -@router.delete("/{rotation_id}") -async def supprimer_rotation( - rotation_id: str, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Supprimer une rotation planifiée.""" - result = await db.execute( - select(Rotation).where(Rotation.id == rotation_id, Rotation.groupe_id == executif.groupe_id) - ) - rotation = result.scalar_one_or_none() - if not rotation: - raise HTTPException(status_code=404, detail="Rotation introuvable") - await db.delete(rotation) - return {"message": "Rotation supprimée"} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/sondages.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/sondages.py deleted file mode 100644 index 2bd81c2..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/sondages.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Routes G6/C6 — Sondages et votes de conscience de groupe.""" -from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, func - -from app.core.database import get_db -from app.core.security import get_current_membre, require_executif -from app.models.sondage import Sondage -from app.models.option_sondage import OptionSondage -from app.models.vote import Vote -from app.models.membre import Membre -from app.schemas.phase8 import SondageCreate, SondageOut, SondageUpdate, OptionOut, VoteCreate - -router = APIRouter(prefix="/sondages", tags=["G6/C6 — Sondages et votes"]) - -TYPES_VALIDES = {"oui_non", "choix_multiple", "unanimite"} - - -async def _build_sondage_out(sondage: Sondage, membre_id, db: AsyncSession) -> SondageOut: - """Construit la sortie complète d'un sondage avec comptage des votes.""" - cree = await db.get(Membre, sondage.cree_par) if sondage.cree_par else None - - # Options avec comptage - result = await db.execute( - select(OptionSondage) - .where(OptionSondage.sondage_id == sondage.id) - .order_by(OptionSondage.ordre) - ) - options = result.scalars().all() - - options_out = [] - total = 0 - for opt in options: - count_result = await db.execute( - select(func.count(Vote.id)).where(Vote.option_id == opt.id) - ) - nb = count_result.scalar() or 0 - total += nb - options_out.append(OptionOut( - id=opt.id, texte=opt.texte, ordre=opt.ordre, nb_votes=nb - )) - - # Le membre a-t-il voté? - vote_result = await db.execute( - select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre_id) - ) - a_vote = vote_result.scalar_one_or_none() is not None - - return SondageOut( - id=sondage.id, - titre=sondage.titre, - description=sondage.description, - type=sondage.type, - cree_par_prenom=cree.prenom if cree else None, - date_ouverture=sondage.date_ouverture, - date_fermeture=sondage.date_fermeture, - statut=sondage.statut, - options=options_out, - total_votes=total, - a_vote=a_vote, - ) - - -@router.post("/", response_model=SondageOut) -async def creer_sondage( - req: SondageCreate, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G6 — Créer un sondage de conscience de groupe.""" - if req.type not in TYPES_VALIDES: - raise HTTPException(status_code=400, detail=f"Type invalide. Choix: {list(TYPES_VALIDES)}") - - if req.date_fermeture <= req.date_ouverture: - raise HTTPException(status_code=400, detail="La date de fermeture doit être après l'ouverture") - - # Pour oui_non, créer automatiquement les options - options_data = req.options - if req.type == "oui_non" and len(options_data) == 0: - options_data = [ - SondageCreate.model_fields["options"].default.__class__(texte="Oui"), - SondageCreate.model_fields["options"].default.__class__(texte="Non"), - ] - # Workaround: créer manuellement - from app.schemas.phase8 import OptionCreate - options_data = [OptionCreate(texte="Oui"), OptionCreate(texte="Non")] - - if len(options_data) < 2: - raise HTTPException(status_code=400, detail="Au moins 2 options sont requises") - - sondage = Sondage( - groupe_id=membre.groupe_id, - titre=req.titre.strip(), - description=req.description, - type=req.type, - cree_par=membre.id, - date_ouverture=req.date_ouverture, - date_fermeture=req.date_fermeture, - statut="brouillon", - ) - db.add(sondage) - await db.flush() - - for i, opt in enumerate(options_data): - option = OptionSondage( - sondage_id=sondage.id, - texte=opt.texte.strip(), - ordre=i, - ) - db.add(option) - await db.flush() - - return await _build_sondage_out(sondage, membre.id, db) - - -@router.get("/", response_model=list[SondageOut]) -async def liste_sondages( - statut: str | None = None, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les sondages du groupe.""" - query = select(Sondage).where(Sondage.groupe_id == membre.groupe_id) - if statut: - query = query.where(Sondage.statut == statut) - query = query.order_by(desc(Sondage.date_ouverture)) - - result = await db.execute(query) - sondages = result.scalars().all() - - return [await _build_sondage_out(s, membre.id, db) for s in sondages] - - -@router.get("/{sondage_id}", response_model=SondageOut) -async def detail_sondage( - sondage_id: str, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id) - ) - sondage = result.scalar_one_or_none() - if not sondage: - raise HTTPException(status_code=404, detail="Sondage introuvable") - return await _build_sondage_out(sondage, membre.id, db) - - -@router.patch("/{sondage_id}", response_model=SondageOut) -async def modifier_statut_sondage( - sondage_id: str, - req: SondageUpdate, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Ouvrir ou fermer un sondage.""" - result = await db.execute( - select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id) - ) - sondage = result.scalar_one_or_none() - if not sondage: - raise HTTPException(status_code=404, detail="Sondage introuvable") - - if req.statut and req.statut in ("brouillon", "ouvert", "ferme"): - sondage.statut = req.statut - await db.flush() - - return await _build_sondage_out(sondage, membre.id, db) - - -@router.post("/{sondage_id}/voter") -async def voter( - sondage_id: str, - req: VoteCreate, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C6 — Voter dans un sondage ouvert. Un membre = un vote.""" - result = await db.execute( - select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id) - ) - sondage = result.scalar_one_or_none() - if not sondage: - raise HTTPException(status_code=404, detail="Sondage introuvable") - - if sondage.statut != "ouvert": - raise HTTPException(status_code=400, detail="Ce sondage n'est pas ouvert au vote") - - now = datetime.now(timezone.utc) - if now < sondage.date_ouverture or now > sondage.date_fermeture: - raise HTTPException(status_code=400, detail="La période de vote est terminée ou pas encore ouverte") - - # Vérifier qu'il n'a pas déjà voté - existing = await db.execute( - select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre.id) - ) - if existing.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Vous avez déjà voté dans ce sondage") - - # Vérifier que l'option appartient au sondage - opt_result = await db.execute( - select(OptionSondage).where( - OptionSondage.id == req.option_id, - OptionSondage.sondage_id == sondage.id, - ) - ) - if not opt_result.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Option invalide") - - vote = Vote( - sondage_id=sondage.id, - option_id=req.option_id, - membre_id=membre.id, - ) - db.add(vote) - await db.flush() - - return {"message": "Vote enregistré", "option_id": str(req.option_id)} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/ventes_litterature.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/ventes_litterature.py deleted file mode 100644 index 960845f..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/ventes_litterature.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Routes S3 — Vente de littérature.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre -from app.models.litterature import Litterature -from app.models.vente_litterature import VenteLitterature -from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase6 import VenteCreate, VenteOut - -router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"]) - - -async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est responsable de la littérature.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%littérature%"), - ) - ) - if not result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="Réservé au responsable de la littérature") - return membre - - -@router.post("/", response_model=VenteOut) -async def enregistrer_vente( - req: VenteCreate, - membre=Depends(require_resp_litterature), - db: AsyncSession = Depends(get_db), -): - """S3 — Enregistrer une vente de littérature. Le stock est décrémenté automatiquement.""" - # Vérifier le titre - result = await db.execute( - select(Litterature).where( - Litterature.id == req.litterature_id, - Litterature.groupe_id == membre.groupe_id, - ) - ) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Titre introuvable") - - if req.quantite <= 0: - raise HTTPException(status_code=400, detail="La quantité doit être positive") - - if item.quantite_stock < req.quantite: - raise HTTPException( - status_code=400, - detail=f"Stock insuffisant ({item.quantite_stock} disponible, {req.quantite} demandé)" - ) - - # Décrémenter le stock - item.quantite_stock -= req.quantite - - # Créer la vente - vente = VenteLitterature( - litterature_id=req.litterature_id, - groupe_id=membre.groupe_id, - quantite=req.quantite, - montant=req.montant, - date_vente=req.date_vente, - vendu_par=membre.id, - ) - db.add(vente) - await db.flush() - - return VenteOut( - id=vente.id, - litterature_titre=item.titre, - quantite=vente.quantite, - montant=float(vente.montant), - date_vente=vente.date_vente, - vendu_par_prenom=membre.prenom, - cree_le=vente.cree_le, - ) - - -@router.get("/", response_model=list[VenteOut]) -async def liste_ventes( - limite: int = 20, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les ventes récentes.""" - result = await db.execute( - select(VenteLitterature) - .where(VenteLitterature.groupe_id == membre.groupe_id) - .order_by(desc(VenteLitterature.date_vente)) - .limit(limite) - ) - ventes = result.scalars().all() - out = [] - for v in ventes: - item = await db.get(Litterature, v.litterature_id) - vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None - out.append(VenteOut( - id=v.id, - litterature_titre=item.titre if item else "?", - quantite=v.quantite, - montant=float(v.montant), - date_vente=v.date_vente, - vendu_par_prenom=vendeur.prenom if vendeur else None, - cree_le=v.cree_le, - )) - return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/__init__.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase1.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase1.py deleted file mode 100644 index 508929f..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase1.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Schémas Phase 1 — Saisies hebdomadaires.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── S1 Collecte ── -class CollecteCreate(BaseModel): - date_collecte: date - montant: float - reunion_id: Optional[UUID] = None - - -class CollecteOut(BaseModel): - id: UUID - date_collecte: date - montant: float - saisi_par_prenom: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} - - -# ── S4 Rapport de réunion ── -class ReunionCreate(BaseModel): - type: str = "reguliere" - date_reunion: datetime - theme: Optional[str] = None - annonces: Optional[list[str]] = None - note: Optional[str] = None - - -class ReunionOut(BaseModel): - id: UUID - type: str - date_reunion: datetime - animateur_prenom: Optional[str] = None - theme: Optional[str] = None - annonces: Optional[list[str]] = None - note: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} - - -# ── S5 Dépense (6 champs) ── -class DepenseCreate(BaseModel): - date_depense: date - montant: float - qui: UUID # membre_id - quoi: str - paye: bool = False - methode: str # DU, VIREMENT, CHEQUE, COLLECTE - - -class DepenseOut(BaseModel): - id: UUID - date_depense: date - montant: float - qui_prenom: Optional[str] = None - quoi: str - paye: bool - methode: str - saisi_par_prenom: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} - - -class DepenseUpdate(BaseModel): - paye: Optional[bool] = None - methode: Optional[str] = None - quoi: Optional[str] = None diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase2.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase2.py deleted file mode 100644 index cbff8c6..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase2.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Schémas Phase 2 — Consultation.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── C5 Décisions ── -class DecisionCreate(BaseModel): - sujet: str - description: Optional[str] = None - statut: str = "en_attente" - responsable_id: Optional[UUID] = None - proposeur: Optional[str] = None - secondeur: Optional[str] = None - date_decision: date - reunion_id: Optional[UUID] = None - - -class DecisionOut(BaseModel): - id: UUID - sujet: str - description: Optional[str] = None - statut: str - responsable_prenom: Optional[str] = None - proposeur: Optional[str] = None - secondeur: Optional[str] = None - date_decision: date - date_completion: Optional[date] = None - cree_le: datetime - - model_config = {"from_attributes": True} - - -class DecisionUpdate(BaseModel): - statut: Optional[str] = None - description: Optional[str] = None - responsable_id: Optional[UUID] = None - date_completion: Optional[date] = None - - -# ── C2 Calendrier district ── -class CalendrierEntry(BaseModel): - groupe_nom: str - groupe_id: UUID - jour_reunion: Optional[str] = None - poste_nom: str - membre_prenom: str - date_debut: date - - -# ── C1 Rapports PDF ── -class RapportRequest(BaseModel): - type: str # finances, pv, rsg - mois: int - annee: int - - -class RapportOut(BaseModel): - type: str - mois: int - annee: int - url: str diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase3.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase3.py deleted file mode 100644 index 34be61b..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase3.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Schémas Phase 3 — Gestion.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── G5 Rotations ── -class RotationCreate(BaseModel): - date_reunion: date - poste_id: UUID - membre_id: UUID - note: Optional[str] = None - - -class RotationOut(BaseModel): - id: UUID - date_reunion: date - poste_nom: str - membre_prenom: str - note: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} - - -# ── G9 Paramètres du groupe ── -class GroupeUpdate(BaseModel): - nom: Optional[str] = None - adresse_local: Optional[str] = None - jour_reunion: Optional[str] = None - heure_reunion: Optional[str] = None - format_reunion: Optional[str] = None - lien_aa87: Optional[str] = None - - -class GroupeDetail(BaseModel): - id: UUID - nom: str - district: str - region: str - adresse_local: Optional[str] = None - jour_reunion: Optional[str] = None - heure_reunion: Optional[str] = None - format_reunion: Optional[str] = None - lien_aa87: str - cree_le: datetime - - model_config = {"from_attributes": True} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase4.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase4.py deleted file mode 100644 index c4e2be3..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase4.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Schémas Phase 4 — PV, Présences, Rapport RSG.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── M2 Présences (réunion d'affaires) ── -class PresenceItem(BaseModel): - membre_id: UUID - present: bool = True - - -class PresencesBatch(BaseModel): - reunion_id: UUID - presences: list[PresenceItem] - - -class PresenceOut(BaseModel): - membre_id: UUID - membre_prenom: str - present: bool - - -class ReunionAffairesCreate(BaseModel): - """Créer une réunion d'affaires (pour les présences et le PV).""" - date_reunion: datetime - note: Optional[str] = None - - -class ReunionAffairesOut(BaseModel): - id: UUID - date_reunion: datetime - note: Optional[str] = None - presences: list[PresenceOut] = [] - - model_config = {"from_attributes": True} - - -# ── M1 Procès-verbal ── -class DecisionPV(BaseModel): - sujet: str - decision: str - proposeur: Optional[str] = None - secondeur: Optional[str] = None - statut: str = "en_attente" - - -class PvCreate(BaseModel): - reunion_id: UUID - contenu: str - decisions: Optional[list[DecisionPV]] = None - - -class PvOut(BaseModel): - id: UUID - reunion_id: UUID - redacteur_prenom: str - contenu: str - decisions: Optional[list[dict]] = None - approuve: bool - cree_le: datetime - date_reunion: Optional[datetime] = None - - model_config = {"from_attributes": True} - - -class PvUpdate(BaseModel): - contenu: Optional[str] = None - decisions: Optional[list[DecisionPV]] = None - approuve: Optional[bool] = None - - -# ── M3 Rapport RSG ── -class RapportRsgCreate(BaseModel): - reunion_dist: date - decisions: Optional[list[str]] = None - infos_region: Optional[str] = None - sujets_groupe: Optional[str] = None - - -class RapportRsgOut(BaseModel): - id: UUID - reunion_dist: date - redacteur_prenom: str - decisions: Optional[list[str]] = None - infos_region: Optional[str] = None - sujets_groupe: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase5.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase5.py deleted file mode 100644 index 845f3b5..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase5.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Schémas Phase 5 — Jetons, Anniversaires, Commande jetons.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── S2 Remise de jeton ── -class JetonCreate(BaseModel): - membre_id: Optional[UUID] = None # None pour jeton de désir anonyme - type_jeton: str # desir, 24h, 1mois, 2mois, 3mois, 6mois, 9mois, 1an, multiple - date_remise: date - annees: Optional[int] = None # requis si type_jeton='multiple' - - -class JetonOut(BaseModel): - id: UUID - membre_prenom: Optional[str] = None - type_jeton: str - date_remise: date - annees: Optional[int] = None - remis_par_prenom: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} - - -# ── C3 Anniversaires district ── -class AnniversaireOut(BaseModel): - membre_prenom: str - groupe_nom: str - date_abstinence: date - annees_sobriete: int - prochain_anniversaire: date - jours_restants: int - - -# ── M6 Commande de jetons ── -class StockJetonsOut(BaseModel): - type_jeton: str - label: str - remis_dernier_mois: int - stock_suggestion: str # "OK" ou "Bas — commander" - - -TYPES_JETONS = [ - {"value": "desir", "label": "Désir", "emoji": "💫", "ordre": 1}, - {"value": "24h", "label": "24 heures", "emoji": "🌅", "ordre": 2}, - {"value": "1mois", "label": "1 mois", "emoji": "🥉", "ordre": 3}, - {"value": "2mois", "label": "2 mois", "emoji": "🥉", "ordre": 4}, - {"value": "3mois", "label": "3 mois", "emoji": "🥈", "ordre": 5}, - {"value": "6mois", "label": "6 mois", "emoji": "🥈", "ordre": 6}, - {"value": "9mois", "label": "9 mois", "emoji": "🥇", "ordre": 7}, - {"value": "1an", "label": "1 an", "emoji": "⭐", "ordre": 8}, - {"value": "multiple", "label": "Multiple", "emoji": "🏆", "ordre": 9}, -] diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase6.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase6.py deleted file mode 100644 index fcef551..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase6.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Schémas Phase 6 — Littérature : inventaire, vente, ajustement.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── Littérature (inventaire) ── -class LitteratureCreate(BaseModel): - titre: str - categorie: str # livre, brochure, depliant, pamphlet - prix_coutant: Optional[float] = None - quantite_stock: int = 0 - seuil_commande: int = 2 - code_bsg: Optional[str] = None - - -class LitteratureOut(BaseModel): - id: UUID - titre: str - categorie: str - prix_coutant: Optional[float] = None - quantite_stock: int - seuil_commande: int - code_bsg: Optional[str] = None - stock_bas: bool = False - - model_config = {"from_attributes": True} - - -class LitteratureUpdate(BaseModel): - titre: Optional[str] = None - categorie: Optional[str] = None - prix_coutant: Optional[float] = None - seuil_commande: Optional[int] = None - code_bsg: Optional[str] = None - - -# ── M5 Ajuster inventaire ── -class AjustementStock(BaseModel): - quantite: int # positif = ajout, négatif = retrait - - -# ── S3 Vente littérature ── -class VenteCreate(BaseModel): - litterature_id: UUID - quantite: int - montant: float - date_vente: date - - -class VenteOut(BaseModel): - id: UUID - litterature_titre: str - quantite: int - montant: float - date_vente: date - vendu_par_prenom: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase7.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase7.py deleted file mode 100644 index ba6df1b..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase7.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Schémas Phase 7 — Finances avancé : contributions, répartition, rapport complet.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -DESTINATIONS = ["district", "region", "bsg", "intergroupe"] -DESTINATIONS_LABELS = { - "district": "District 87-16", - "region": "Région 87", - "bsg": "BSG", - "intergroupe": "Intergroupe", -} -METHODES_CONTRIBUTION = ["DU", "VIREMENT", "CHEQUE"] - - -# ── M4 Envoi de contributions ── -class EnvoiContributionCreate(BaseModel): - date_envoi: date - destination: str # district, region, bsg, intergroupe - montant: float - methode: str # DU, VIREMENT, CHEQUE - - -class EnvoiContributionOut(BaseModel): - id: UUID - date_envoi: date - destination: str - destination_label: str - montant: float - methode: str - saisi_par_prenom: Optional[str] = None - cree_le: datetime - - model_config = {"from_attributes": True} - - -class CalculRepartitionIn(BaseModel): - """Montant à répartir — le backend calcule les montants par destination.""" - montant_total: float - - -class CalculRepartitionOut(BaseModel): - destination: str - destination_label: str - pourcentage: float - montant: float - - -# ── G8 Config répartition ── -class ConfigRepartitionItem(BaseModel): - destination: str - pourcentage: float - - -class ConfigRepartitionOut(BaseModel): - destination: str - destination_label: str - pourcentage: float - actif_depuis: date - - model_config = {"from_attributes": True} - - -class ConfigRepartitionBatch(BaseModel): - """Mettre à jour toutes les répartitions d'un coup.""" - items: list[ConfigRepartitionItem] - - -# ── Rapport financier complet ── -class RapportFinancierComplet(BaseModel): - mois: int - annee: int - total_collectes: float - total_depenses: float - total_contributions: float - solde_mois: float - nb_collectes: int - nb_depenses: int - nb_contributions: int - depenses_impayees: float diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase8.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase8.py deleted file mode 100644 index 86ef565..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/phase8.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Schémas Phase 8 — Votes (sondages) et parrainage (jumelages).""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── G6 Lancer un sondage ── -class OptionCreate(BaseModel): - texte: str - - -class SondageCreate(BaseModel): - titre: str - description: Optional[str] = None - type: str = "oui_non" # oui_non, choix_multiple, unanimite - date_ouverture: datetime - date_fermeture: datetime - options: list[OptionCreate] = [] - - -class OptionOut(BaseModel): - id: UUID - texte: str - ordre: int - nb_votes: int = 0 - - model_config = {"from_attributes": True} - - -class SondageOut(BaseModel): - id: UUID - titre: str - description: Optional[str] = None - type: str - cree_par_prenom: Optional[str] = None - date_ouverture: datetime - date_fermeture: datetime - statut: str - options: list[OptionOut] = [] - total_votes: int = 0 - a_vote: bool = False # le membre courant a-t-il voté? - - model_config = {"from_attributes": True} - - -class SondageUpdate(BaseModel): - statut: Optional[str] = None # ouvert, ferme - - -# ── C6 Voter ── -class VoteCreate(BaseModel): - option_id: UUID - - -# ── G7 Jumelage parrain/filleul ── -class JumelageCreate(BaseModel): - parrain_id: UUID - filleul_id: UUID - date_jumelage: date - note: Optional[str] = None - - -class JumelageOut(BaseModel): - id: UUID - parrain_prenom: str - filleul_prenom: str - date_jumelage: date - statut: str - note: Optional[str] = None - cree_par_prenom: Optional[str] = None - - model_config = {"from_attributes": True} - - -class JumelageUpdate(BaseModel): - statut: Optional[str] = None # actif, termine - note: Optional[str] = None diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/schemas.py b/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/schemas.py deleted file mode 100644 index 32c7145..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/app/schemas/schemas.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Schémas Pydantic pour validation et sérialisation.""" -from pydantic import BaseModel -from datetime import date, datetime -from uuid import UUID -from typing import Optional - - -# ── Auth ── -class ConnexionRequest(BaseModel): - prenom: str - pin: str - groupe_id: UUID - - -class InscriptionRequest(BaseModel): - prenom: str - nom: str - pin: str - code_invitation: str - telephone: Optional[str] = None - courriel: Optional[str] = None - date_abstinence: Optional[date] = None - - -class TokenResponse(BaseModel): - access_token: str - token_type: str = "bearer" - membre: "MembrePublic" - - -# ── Membre ── -class MembrePublic(BaseModel): - id: UUID - prenom: str - groupe_id: UUID - date_abstinence: Optional[date] = None - - model_config = {"from_attributes": True} - - -class MembreComplet(MembrePublic): - nom: str - telephone: Optional[str] = None - courriel: Optional[str] = None - actif: bool - cree_le: datetime - - model_config = {"from_attributes": True} - - -class MembreUpdate(BaseModel): - telephone: Optional[str] = None - courriel: Optional[str] = None - date_abstinence: Optional[date] = None - - -class ChangerPinRequest(BaseModel): - ancien_pin: str - nouveau_pin: str - - -# ── Poste / Affectation ── -class PosteOut(BaseModel): - id: UUID - nom: str - categorie: str - description: Optional[str] = None - ordre_affichage: Optional[int] = None - - model_config = {"from_attributes": True} - - -class AffectationOut(BaseModel): - id: UUID - membre_id: UUID - poste_id: UUID - poste_nom: Optional[str] = None - poste_categorie: Optional[str] = None - date_debut: date - date_fin: Optional[date] = None - statut: str - - model_config = {"from_attributes": True} - - -class AffectationCreate(BaseModel): - membre_id: UUID - poste_id: UUID - date_debut: date - - -class TerminerMandatRequest(BaseModel): - note_transition: Optional[str] = None - successeur_id: Optional[UUID] = None - - -# ── Accueil ── -class ModuleTuile(BaseModel): - code: str - nom: str - description: str - icone: str - couleur: str - - -class AccueilResponse(BaseModel): - membre: MembrePublic - postes: list[str] - modules_consultation: list[ModuleTuile] - modules_saisie: list[ModuleTuile] - modules_gestion: list[ModuleTuile] - - -# ── Invitation ── -class InvitationCreate(BaseModel): - pass # groupe_id vient du token - - -class InvitationOut(BaseModel): - code: str - expire_le: datetime - - model_config = {"from_attributes": True} - - -# ── Groupe ── -class GroupeOut(BaseModel): - id: UUID - nom: str - district: str - region: str - jour_reunion: Optional[str] = None - - model_config = {"from_attributes": True} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/requirements.txt b/groupe-meditation-corrige/groupe-meditation/backend/requirements.txt deleted file mode 100644 index 8479f9e..0000000 --- a/groupe-meditation-corrige/groupe-meditation/backend/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi==0.115.6 -uvicorn[standard]==0.34.0 -sqlalchemy[asyncio]==2.0.36 -asyncpg==0.30.0 -pydantic-settings==2.7.1 -python-jose[cryptography]==3.3.0 -bcrypt==4.2.1 -weasyprint==62.3 -pywebpush==2.0.1 diff --git a/groupe-meditation-corrige/groupe-meditation/deploy/apache-groupe-meditation.conf b/groupe-meditation-corrige/groupe-meditation/deploy/apache-groupe-meditation.conf deleted file mode 100644 index 5322c4a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/deploy/apache-groupe-meditation.conf +++ /dev/null @@ -1,37 +0,0 @@ - - ServerName groupe-meditation.87-16.org - DocumentRoot /var/www/groupe-meditation - - - Options -Indexes - AllowOverride None - Require all granted - - RewriteEngine On - RewriteBase / - RewriteRule ^index\.html$ - [L] - - # Ne jamais réécrire les appels API - RewriteCond %{REQUEST_URI} !^/api/ - RewriteCond %{REQUEST_FILENAME} !-f - RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule . /index.html [L] - - - ProxyPreserveHost On - ProxyRequests Off - - ProxyPass /api http://127.0.0.1:8000/api - ProxyPassReverse /api http://127.0.0.1:8000/api - - - Header set Cache-Control "public, max-age=31536000, immutable" - - - - Header set Cache-Control "no-cache, no-store, must-revalidate" - - - ErrorLog ${APACHE_LOG_DIR}/groupe-meditation-error.log - CustomLog ${APACHE_LOG_DIR}/groupe-meditation-access.log combined - diff --git a/groupe-meditation-corrige/groupe-meditation/deploy/backup.sh b/groupe-meditation-corrige/groupe-meditation/deploy/backup.sh deleted file mode 100644 index ff9a6c8..0000000 --- a/groupe-meditation-corrige/groupe-meditation/deploy/backup.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# Sauvegarde quotidienne PostgreSQL — Groupe Méditation -# À placer dans /etc/cron.daily/ ou via crontab - -BACKUP_DIR="/var/backups/groupe-meditation" -RETENTION_DAYS=30 -DB_NAME="groupe_meditation" -DB_USER="grpmed" -DATE=$(date +%Y-%m-%d_%H%M) - -mkdir -p "$BACKUP_DIR" - -# Dump -pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump" - -# Nettoyage des anciennes sauvegardes -find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +$RETENTION_DAYS -delete - -echo "[$(date)] Sauvegarde terminée: grpmed_${DATE}.dump" diff --git a/groupe-meditation-corrige/groupe-meditation/deploy/groupe-meditation.service b/groupe-meditation-corrige/groupe-meditation/deploy/groupe-meditation.service deleted file mode 100644 index 6a76ccf..0000000 --- a/groupe-meditation-corrige/groupe-meditation/deploy/groupe-meditation.service +++ /dev/null @@ -1,19 +0,0 @@ -[Unit] -Description=Groupe Méditation API -After=network.target postgresql.service -Requires=postgresql.service - -[Service] -Type=simple -User=www-data -Group=www-data -WorkingDirectory=/opt/groupe-meditation/backend -Environment=PYTHONUNBUFFERED=1 -Environment=PATH=/opt/groupe-meditation/backend/venv/bin -EnvironmentFile=-/opt/groupe-meditation/backend/.env -ExecStart=/opt/groupe-meditation/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/groupe-meditation-corrige/groupe-meditation/deploy/nginx-groupe-meditation.conf b/groupe-meditation-corrige/groupe-meditation/deploy/nginx-groupe-meditation.conf deleted file mode 100644 index 27dc6b8..0000000 --- a/groupe-meditation-corrige/groupe-meditation/deploy/nginx-groupe-meditation.conf +++ /dev/null @@ -1,50 +0,0 @@ -# Groupe Méditation — Configuration NGINX -# Reverse proxy vers Apache (frontend) et FastAPI (backend) - -server { - listen 443 ssl http2; - server_name groupe-meditation.chezlepro.ca; - - # Certificat Let's Encrypt - ssl_certificate /etc/letsencrypt/live/groupe-meditation.chezlepro.ca/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/groupe-meditation.chezlepro.ca/privkey.pem; - - # Headers de sécurité - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; - add_header X-Content-Type-Options nosniff always; - add_header X-Frame-Options DENY always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy strict-origin-when-cross-origin always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; font-src fonts.gstatic.com; img-src 'self' data:; connect-src 'self'" always; - - # Frontend — Apache sert les fichiers statiques - location / { - proxy_pass http://127.0.0.1:8080; - 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; - } - - # Backend API — FastAPI via Uvicorn - location /api/ { - 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; - - # Rate limiting anti-brute force (PIN) - limit_req zone=api_limit burst=10 nodelay; - } - - # Taille max des uploads - client_max_body_size 5M; -} - -# Redirection HTTP → HTTPS -server { - listen 80; - server_name groupe-meditation.chezlepro.ca; - return 301 https://$host$request_uri; -} diff --git a/groupe-meditation-corrige/groupe-meditation/deploy/seed.py b/groupe-meditation-corrige/groupe-meditation/deploy/seed.py deleted file mode 100644 index 73b128f..0000000 --- a/groupe-meditation-corrige/groupe-meditation/deploy/seed.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -Script d'initialisation — Groupe Méditation -Crée les postes, le groupe initial et le premier membre. -Usage: python deploy/seed.py -""" -import asyncio -import sys -import os -import uuid -import secrets -from datetime import date, datetime, timedelta, timezone - -# Ajouter le backend au path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) - -from app.core.database import engine, Base, async_session -from app.core.security import hash_pin -# Importer TOUS les modèles pour que create_all crée toutes les tables -from app.models.groupe import Groupe -from app.models.membre import Membre -from app.models.poste import Poste -from app.models.affectation import Affectation -from app.models.invitation import Invitation -from app.models.collecte import Collecte -from app.models.depense import Depense -from app.models.reunion import Reunion -from app.models.presence import Presence -from app.models.pv_reunion import PvReunion -from app.models.decision import Decision -from app.models.rotation import Rotation -from app.models.rapport_rsg import RapportRsg -from app.models.jeton import Jeton -from app.models.litterature import Litterature -from app.models.vente_litterature import VenteLitterature -from app.models.envoi_contribution import EnvoiContribution -from app.models.config_repartition import ConfigRepartition -from app.models.sondage import Sondage -from app.models.option_sondage import OptionSondage -from app.models.vote import Vote -from app.models.jumelage import Jumelage -from app.models.sync_log import SyncLog - - -POSTES = [ - ("RSG", "executif", "Représentant aux Services du Groupe", "730 days", "2 ans sobriété", 1), - ("RSG Substitut(e)", "executif", "Remplace le RSG, se prépare à assumer le rôle", "730 days", "1 an sobriété", 2), - ("Trésorier(ière)", "executif", "Gestion financière, 7e Tradition", "548 days", "1 an sobriété", 3), - ("Secrétaire", "executif", "PV, correspondance, mémoire du groupe", "548 days", "6 mois sobriété", 4), - ("Animateur(trice)", "service", "Anime les réunions régulières", None, "6 mois sobriété", 5), - ("Responsable accueil", "physique", "Accueil des membres et nouveaux", "365 days", "Sobriété stable", 6), - ("Responsable café", "physique", "Café, entretien de la salle", "365 days", "Aucun", 7), - ("Responsable littérature", "service", "Inventaire et vente de littérature AA", "365 days", "Connaissance littérature AA", 8), - ("Responsable jetons", "service", "Jetons et anniversaires de sobriété", "365 days", "Aucun", 9), - ("Responsable clés", "physique", "Ouverture et fermeture du local", None, "Fiabilité", 10), -] - - -async def seed(): - # Créer les tables - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - async with async_session() as db: - # ── Créer le groupe ── - print("\n═══ Initialisation du Groupe Méditation ═══\n") - groupe = Groupe( - nom="Groupe Méditation", - district="87-16", - region="87", - adresse_local="", - jour_reunion="lundi", - heure_reunion=None, - format_reunion="Discussion ouverte", - lien_aa87="https://aa87.org", - ) - db.add(groupe) - await db.flush() - print(f"✓ Groupe créé: {groupe.nom} (district {groupe.district})") - - # ── Créer les postes ── - postes_map = {} - for nom, cat, desc, duree, prereq, ordre in POSTES: - poste = Poste( - nom=nom, - categorie=cat, - description=desc, - prerequis=prereq, - ordre_affichage=ordre, - ) - db.add(poste) - await db.flush() - postes_map[nom] = poste - print(f" ✓ Poste: {nom} ({cat})") - - # ── Premier membre ── - print("\n── Premier membre (vous) ──") - prenom = input(" Prénom: ").strip().title() - nom = input(" Nom: ").strip().title() - pin = input(" PIN (4 chiffres): ").strip() - while not pin.isdigit() or len(pin) != 4: - pin = input(" PIN invalide. 4 chiffres: ").strip() - - print("\n Postes disponibles:") - for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1): - print(f" {i}. {nom_poste}") - choix = input(" Votre poste (1-4): ").strip() - poste_idx = int(choix) - 1 if choix.isdigit() and 1 <= int(choix) <= 4 else 0 - poste_choisi = POSTES[poste_idx][0] - - membre = Membre( - groupe_id=groupe.id, - prenom=prenom, - nom=nom, - pin_hash=hash_pin(pin), - ) - db.add(membre) - await db.flush() - - # ── Affectation ── - affectation = Affectation( - groupe_id=groupe.id, - membre_id=membre.id, - poste_id=postes_map[poste_choisi].id, - date_debut=date.today(), - statut="actif", - ) - db.add(affectation) - - # ── Première invitation ── - code = secrets.token_hex(6).upper() - invitation = Invitation( - groupe_id=groupe.id, - code=code, - cree_par=membre.id, - expire_le=datetime.now(timezone.utc) + timedelta(days=7), - ) - db.add(invitation) - - # ── Répartition par défaut (30/30/30/10) ── - for dest, pct in [("district", 30), ("region", 30), ("bsg", 30), ("intergroupe", 10)]: - db.add(ConfigRepartition( - groupe_id=groupe.id, - destination=dest, - pourcentage=pct, - actif_depuis=date.today(), - )) - print(" ✓ Répartition contributions : 30/30/30/10") - - # ── Littérature de base ── - for titre, cat, prix, code in [ - ("Les Alcooliques anonymes (Gros Livre)", "livre", 12.00, "B-1"), - ("Les Douze Étapes et les Douze Traditions", "livre", 11.00, "B-2"), - ("Vivre sans alcool", "livre", 9.00, "B-7"), - ("Les AA sont-ils pour vous?", "brochure", 0.00, "P-3"), - ("Un nouveau arrive", "depliant", 0.00, "P-16"), - ]: - db.add(Litterature( - groupe_id=groupe.id, - titre=titre, - categorie=cat, - prix_coutant=prix, - quantite_stock=0, - seuil_commande=2, - code_bsg=code, - )) - print(" ✓ Littérature de base : 5 titres") - - await db.commit() - - print(f"\n═══ Initialisation terminée ═══") - print(f" Membre: {prenom} ({nom})") - print(f" Groupe: Groupe Méditation") - print(f" Poste: {poste_choisi}") - print(f"\n Code d'invitation pour les prochains membres: {code}") - print(f" (Expire dans 7 jours)") - print(f"\n Connectez-vous sur l'app avec:") - print(f" Prénom: {prenom}") - print(f" PIN: {pin}") - - -if __name__ == "__main__": - asyncio.run(seed()) diff --git a/groupe-meditation-corrige/groupe-meditation/docs/DEPLOIEMENT.md b/groupe-meditation-corrige/groupe-meditation/docs/DEPLOIEMENT.md deleted file mode 100644 index ab26515..0000000 --- a/groupe-meditation-corrige/groupe-meditation/docs/DEPLOIEMENT.md +++ /dev/null @@ -1,211 +0,0 @@ -# Guide de déploiement — Groupe Méditation - -## Infrastructure cible - -Déploiement validé pour une machine applicative Debian/Ubuntu avec : - -- PostgreSQL local -- FastAPI/Uvicorn local sur `127.0.0.1:8000` -- Apache local sur `*:80` -- un proxy inverse externe sur une autre machine -- FQDN public : `groupe-meditation.87-16.org` - -Le principe est simple : - -- Apache sert le frontend React sur `/` -- Apache relaie `/api` vers FastAPI -- le proxy externe publie ensuite `groupe-meditation.87-16.org` - ---- - -## Prérequis - -- Debian 12+ ou Ubuntu 24.04+ -- PostgreSQL -- Python 3.11+ -- Node.js 20+ pour le build frontend -- Apache 2.4+ avec `rewrite`, `headers`, `proxy`, `proxy_http` - ---- - -## 1. Installer PostgreSQL - -```bash -sudo apt update -sudo apt install -y postgresql postgresql-client - -sudo systemctl enable postgresql -sudo systemctl start postgresql - -sudo -u postgres psql << 'SQL' -CREATE USER grpmed WITH PASSWORD 'CHANGEZ_CE_MOT_DE_PASSE'; -CREATE DATABASE groupe_meditation OWNER grpmed; -GRANT ALL PRIVILEGES ON DATABASE groupe_meditation TO grpmed; -\c groupe_meditation -GRANT ALL ON SCHEMA public TO grpmed; -SQL -``` - ---- - -## 2. Installer Python et les dépendances - -```bash -sudo apt install -y python3 python3-pip python3-venv -sudo apt install -y libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev libcairo2 - -sudo mkdir -p /opt/groupe-meditation -sudo chown $USER:$USER /opt/groupe-meditation - -cp -r backend/ /opt/groupe-meditation/backend/ -cp -r deploy/ /opt/groupe-meditation/deploy/ - -cd /opt/groupe-meditation/backend -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -``` - ---- - -## 3. Configurer le backend - -```bash -cd /opt/groupe-meditation/backend -cp .env.example .env -nano .env -``` - -Exemple minimal : - -```env -DATABASE_URL=postgresql+asyncpg://grpmed:VOTRE_MOT_DE_PASSE@localhost:5432/groupe_meditation -JWT_SECRET=GENEREZ_AVEC_openssl_rand_base64_48 -CORS_ORIGINS=["http://groupe-meditation.87-16.org","https://groupe-meditation.87-16.org"] -INVITATION_EXPIRE_HOURS=48 -``` - -Générer le secret JWT : - -```bash -openssl rand -base64 48 -``` - ---- - -## 4. Initialiser la base - -```bash -cd /opt/groupe-meditation -source backend/venv/bin/activate -python deploy/seed.py -``` - ---- - -## 5. Créer le service systemd - -```bash -sudo cp deploy/groupe-meditation.service /etc/systemd/system/ -sudo chown -R www-data:www-data /opt/groupe-meditation -sudo systemctl daemon-reload -sudo systemctl enable groupe-meditation -sudo systemctl start groupe-meditation -sudo systemctl status groupe-meditation --no-pager -``` - -Vérifier l'API locale : - -```bash -curl -s http://127.0.0.1:8000/api/health | python3 -m json.tool -``` - ---- - -## 6. Construire le frontend - -```bash -cd frontend/ -npm install -npm run build - -sudo mkdir -p /var/www/groupe-meditation -sudo cp -r dist/* /var/www/groupe-meditation/ -sudo chown -R www-data:www-data /var/www/groupe-meditation -``` - ---- - -## 7. Configurer Apache - -Activer les modules requis : - -```bash -sudo a2enmod rewrite headers proxy proxy_http -``` - -Installer le vhost : - -```bash -sudo cp deploy/apache-groupe-meditation.conf /etc/apache2/sites-available/groupe-meditation.conf -sudo a2ensite groupe-meditation.conf -sudo apachectl -t -sudo systemctl reload apache2 -``` - -Apache doit : - -- servir le frontend statique depuis `/var/www/groupe-meditation` -- relayer `/api` vers `127.0.0.1:8000` -- laisser le proxy externe publier le site - ---- - -## 8. Vérifications locales - -Tester directement FastAPI : - -```bash -curl -s http://127.0.0.1:8000/api/health | python3 -m json.tool -``` - -Tester via Apache local : - -```bash -curl -s -H "Host: groupe-meditation.87-16.org" http://127.0.0.1/api/health | python3 -m json.tool -curl -I -H "Host: groupe-meditation.87-16.org" http://127.0.0.1/ -``` - -Ensuite seulement, tester via le proxy externe : - -- `http://groupe-meditation.87-16.org` -- ou `https://groupe-meditation.87-16.org` si le TLS est terminé sur le proxy externe - ---- - -## 9. Sauvegarde - -```bash -sudo cp deploy/backup.sh /etc/cron.daily/groupe-meditation-backup -sudo chmod +x /etc/cron.daily/groupe-meditation-backup -``` - ---- - -## Dépannage rapide - -| Problème | Commande | -|----------|----------| -| API ne démarre pas | `sudo journalctl -u groupe-meditation -f` | -| Apache mal configuré | `sudo apachectl -t && sudo apachectl -S` | -| API locale KO | `curl -v http://127.0.0.1:8000/api/health` | -| API via Apache KO | `curl -v -H "Host: groupe-meditation.87-16.org" http://127.0.0.1/api/health` | -| Frontend à reconstruire | `cd frontend && npm run build && sudo cp -r dist/* /var/www/groupe-meditation/` | - ---- - -## Notes importantes - -- pas de NGINX local dans cette variante -- pas de Certbot local dans cette variante -- le fichier `deploy/nginx-groupe-meditation.conf` peut servir d'exemple pour le proxy inverse externe, mais il n'est pas requis sur la machine applicative diff --git a/groupe-meditation-corrige/groupe-meditation/docs/RUNBOOK.md b/groupe-meditation-corrige/groupe-meditation/docs/RUNBOOK.md deleted file mode 100644 index 75c1274..0000000 --- a/groupe-meditation-corrige/groupe-meditation/docs/RUNBOOK.md +++ /dev/null @@ -1,164 +0,0 @@ -# Runbook opérationnel — Groupe Méditation - -## Opérations courantes - -### Inviter un nouveau membre -1. Un membre de l'exécutif ouvre le module **G1 — Inviter** -2. Un code à 12 caractères est généré (expire 48h) -3. Le code est partagé en personne ou par SMS -4. Le nouveau membre ouvre l'app → Inscription → entre le code + prénom + nom + PIN - -### Affecter un poste -1. Exécutif ouvre **G3 — Affecter les postes** -2. Sélectionne le membre et le poste -3. Le membre voit immédiatement ses nouvelles tuiles sur l'accueil - -### Terminer un mandat -1. Exécutif ouvre **G4 — Terminer un mandat** -2. Sélectionne l'affectation active -3. Saisit une note de transition -4. Les permissions sont retirées instantanément -5. **Rappel** : les mandats ne se terminent jamais automatiquement - -### Saisie hebdomadaire typique (pendant/après la réunion) -1. **Trésorier** : S1 (collecte) + S5 (dépenses si applicable) -2. **Animateur** : S4 (thème + annonces) -3. **Resp. jetons** : S2 (si anniversaire ou nouveau) -4. **Resp. littérature** : S3 (si vente) - -### Réunion d'affaires mensuelle -1. **Secrétaire** : M2 (présences) → M1 (PV avec décisions) -2. **RSG** : M3 (rapport du district) -3. **Trésorier** : présente le rapport financier (C1) -4. Si vote nécessaire : **G6** (créer sondage) → **C6** (voter) - -### Envoi trimestriel des contributions -1. **Trésorier** ouvre **M4 — Envoi contributions** -2. Mode « Calculer » : entre le surplus disponible → répartition auto -3. Mode « Enregistrer » : enregistre chaque envoi individuel -4. La config des % se fait dans **G8** (défaut 30/30/30/10) - ---- - -## Gestion des rapports PDF - -Les rapports sont disponibles dans **C1 — Rapports PDF** : - -| Rapport | URL API | Contenu | -|---------|---------|---------| -| Financier mensuel | `/api/rapports/finances/{annee}/{mois}` | Collectes + dépenses + solde | -| Réunions du mois | `/api/rapports/reunions/{annee}/{mois}` | Liste des réunions + thèmes | -| Financier complet | `/api/rapports/financier-complet/{annee}/{mois}` | Collectes + dépenses + contributions + impayés | - ---- - -## Surveillance - -### Vérifier que l'application tourne - -```bash -# Service FastAPI -sudo systemctl status groupe-meditation - -# Test santé API -curl -s https://groupe-meditation.chezlepro.ca/api/health - -# Logs temps réel -sudo journalctl -u groupe-meditation -f --no-pager - -# NGINX -sudo systemctl status nginx -sudo tail -f /var/log/nginx/error.log -``` - -### Vérifier les sauvegardes - -```bash -# Dernière sauvegarde -ls -la /var/backups/groupe-meditation/ | tail -5 - -# Tester une restauration (sur une base test) -createdb -U grpmed groupe_meditation_test -pg_restore -U grpmed -d groupe_meditation_test /var/backups/groupe-meditation/grpmed_DERNIER.dump -dropdb -U grpmed groupe_meditation_test -``` - -### Statistiques base de données - -```bash -sudo -u postgres psql -d groupe_meditation -c " -SELECT schemaname, tablename, n_live_tup AS lignes -FROM pg_stat_user_tables -ORDER BY n_live_tup DESC; -" -``` - ---- - -## Opérations d'urgence - -### L'API ne répond plus - -```bash -sudo systemctl restart groupe-meditation -sudo journalctl -u groupe-meditation --since "5 minutes ago" -``` - -### Un membre a oublié son PIN - -Pas de récupération en libre-service (par design — sécurité). L'exécutif doit : -1. Utiliser **G2 — Gérer les membres** pour désactiver le membre -2. Recréer une invitation **G1** -3. Le membre se réinscrit avec un nouveau PIN - -### Droit à l'oubli (RGPD/Loi 25) - -1. Exécutif ouvre **G2 — Gérer les membres** -2. Sélectionne le membre → **Droit à l'oubli** -3. Le système supprime les données personnelles -4. Les données historiques (collectes, PV) sont anonymisées (prénom remplacé) - -### Restaurer la base après incident - -```bash -sudo systemctl stop groupe-meditation - -# Restaurer la dernière sauvegarde -sudo -u postgres dropdb groupe_meditation -sudo -u postgres createdb -O grpmed groupe_meditation -pg_restore -U grpmed -d groupe_meditation /var/backups/groupe-meditation/grpmed_DERNIER.dump - -sudo systemctl start groupe-meditation -``` - ---- - -## Matrice des 27 modules par rôle - -| Module | Trésorier | Secrétaire | RSG | Animateur | Litt. | Jetons | Tous | -|--------|:---------:|:----------:|:---:|:---------:|:-----:|:------:|:----:| -| C1-C7 Consultation | | | | | | | ✓ | -| S1 Collecte | ✓ | | | | | | | -| S2 Remise jeton | | | | | | ✓ | | -| S3 Vente litt. | | | | | ✓ | | | -| S4 Rapport réunion | | | | ✓ | | | | -| S5 Dépense | ✓ | | | | | | | -| M1 PV | | ✓ | | | | | | -| M2 Présences | | ✓ | | | | | | -| M3 Rapport RSG | | | ✓ | | | | | -| M4 Contributions | ✓ | | | | | | | -| M5 Inventaire | | | | | ✓ | | | -| M6 Commander jetons | | | | | | ✓ | | -| G1-G9 Gestion | ✓ | ✓ | ✓ | | | | | - ---- - -## Contacts et références - -- **aa87.org** — Site de la Région 87 -- **BSG** — Bureau des Services généraux (aa.org) -- Documentation API : `https://groupe-meditation.chezlepro.ca/api/docs` - ---- - -*«Nos leaders sont des serviteurs de confiance ; ils ne gouvernent pas.» — 2e Tradition* diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/index.html b/groupe-meditation-corrige/groupe-meditation/frontend/index.html deleted file mode 100644 index d92966f..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - Groupe Méditation - - -
- - - diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/package.json b/groupe-meditation-corrige/groupe-meditation/frontend/package.json deleted file mode 100644 index f9d8a0b..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "groupe-meditation", - "private": true, - "version": "0.1.0", - "description": "Application du Groupe Méditation — District 87-16", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "zustand": "^5.0.0" - }, - "devDependencies": { - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.0", - "tailwindcss": "^3.4.0", - "autoprefixer": "^10.4.0", - "postcss": "^8.4.0", - "vite": "^5.4.0", - "vite-plugin-pwa": "^0.20.0" - } -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/postcss.config.js b/groupe-meditation-corrige/groupe-meditation/frontend/postcss.config.js deleted file mode 100644 index 2aa7205..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/public/favicon.svg b/groupe-meditation-corrige/groupe-meditation/frontend/public/favicon.svg deleted file mode 100644 index 7523594..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/public/favicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - ☮ - diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/App.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/App.jsx deleted file mode 100644 index 43b5c4b..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/App.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useEffect } from 'react'; -import { useStore } from './stores/store'; -import Login from './pages/Login'; -import Inscription from './pages/Inscription'; -import Accueil from './pages/Accueil'; -import Profil from './pages/Profil'; -import Collecte from './pages/Collecte'; -import RapportReunion from './pages/RapportReunion'; -import Depense from './pages/Depense'; -import Decisions from './pages/Decisions'; -import Calendrier from './pages/Calendrier'; -import Rapports from './pages/Rapports'; -import InviterMembre from './pages/InviterMembre'; -import GererMembres from './pages/GererMembres'; -import AffecterPostes from './pages/AffecterPostes'; -import TerminerMandat from './pages/TerminerMandat'; -import PlanifierRotations from './pages/PlanifierRotations'; -import Parametres from './pages/Parametres'; -import Presences from './pages/Presences'; -import RedigerPV from './pages/RedigerPV'; -import RapportRSG from './pages/RapportRSG'; -import RemiseJeton from './pages/RemiseJeton'; -import Anniversaires from './pages/Anniversaires'; -import CommanderJetons from './pages/CommanderJetons'; -import VenteLitterature from './pages/VenteLitterature'; -import Inventaire from './pages/Inventaire'; -import AjusterInventaire from './pages/AjusterInventaire'; -import EnvoiContributions from './pages/EnvoiContributions'; -import ConfigRepartition from './pages/ConfigRepartition'; -import LancerSondage from './pages/LancerSondage'; -import VotesOuverts from './pages/VotesOuverts'; -import JumelerParrain from './pages/JumelerParrain'; - -export default function App() { - const { token, page, loadAccueil, loadGroupes } = useStore(); - - useEffect(() => { - loadGroupes(); - if (token) { - loadAccueil().then(() => { - useStore.setState({ page: 'accueil' }); - }).catch(() => { - useStore.setState({ page: 'login' }); - }); - } - }, []); - - // Pages protégées - if (token) { - switch (page) { - case 'accueil': return ; - case 'profil': return ; - case 'S1': return ; - case 'S4': return ; - case 'S5': return ; - case 'C1': return ; - case 'C2': return ; - case 'C5': return ; - case 'G1': return ; - case 'G2': return ; - case 'G3': return ; - case 'G4': return ; - case 'G5': return ; - case 'G9': return ; - case 'M1': return ; - case 'M2': return ; - case 'M3': return ; - case 'S2': return ; - case 'C3': return ; - case 'M6': return ; - case 'S3': return ; - case 'C4': return ; - case 'M5': return ; - case 'M4': return ; - case 'G8': return ; - case 'G6': return ; - case 'C6': return ; - case 'G7': return ; - default: return ; - } - } - - // Pages publiques - switch (page) { - case 'inscription': return ; - default: return ; - } -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/components/PageHeader.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/components/PageHeader.jsx deleted file mode 100644 index 5edef2a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/components/PageHeader.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { useStore } from '../stores/store'; - -export default function PageHeader({ titre, sousTitre }) { - const { setPage } = useStore(); - return ( -
- -
-

{titre}

- {sousTitre &&

{sousTitre}

} -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/components/SuccessCheck.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/components/SuccessCheck.jsx deleted file mode 100644 index 822c03e..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/components/SuccessCheck.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { useEffect } from 'react'; -import { useStore } from '../stores/store'; - -export default function SuccessCheck({ message, delai = 1500 }) { - const { setPage } = useStore(); - - useEffect(() => { - const t = setTimeout(() => setPage('accueil'), delai); - return () => clearTimeout(t); - }, []); - - return ( -
-
- ✓ -
-

{message}

-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/index.css b/groupe-meditation-corrige/groupe-meditation/frontend/src/index.css deleted file mode 100644 index 46e5715..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/index.css +++ /dev/null @@ -1,22 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -* { - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; -} - -body { - font-family: 'DM Sans', sans-serif; - -webkit-font-smoothing: antialiased; -} - -button:active { - transform: scale(0.97); -} - -@keyframes scaleIn { - from { transform: scale(0.5); opacity: 0; } - to { transform: scale(1); opacity: 1; } -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/main.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/main.jsx deleted file mode 100644 index 47742c1..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './index.css'; - -ReactDOM.createRoot(document.getElementById('root')).render( - - - -); diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Accueil.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Accueil.jsx deleted file mode 100644 index 807cf7a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Accueil.jsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { useStore } from '../stores/store'; - -const COULEURS = { - blue: 'bg-blue-50 border-blue-200 text-blue-700', - green: 'bg-green-50 border-green-200 text-green-700', - yellow: 'bg-yellow-50 border-yellow-200 text-yellow-700', - red: 'bg-red-50 border-red-200 text-red-700', - purple: 'bg-purple-50 border-purple-200 text-purple-700', - indigo: 'bg-indigo-50 border-indigo-200 text-indigo-700', - orange: 'bg-orange-50 border-orange-200 text-orange-700', - gray: 'bg-gray-50 border-gray-200 text-gray-700', -}; - -function Tuile({ module, onClick }) { - const cls = COULEURS[module.couleur] || COULEURS.gray; - return ( - - ); -} - -function Section({ titre, modules }) { - if (!modules || modules.length === 0) return null; - return ( -
-

{titre}

-
- {modules.map((m) => ( - { - const navigable = ['C7', 'S1', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G8', 'G9', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6']; - if (m.code === 'C7') useStore.setState({ page: 'profil' }); - else if (navigable.includes(m.code)) useStore.setState({ page: m.code }); - else alert(`Module ${m.code} — ${m.nom}\n\n(Sera disponible dans les prochaines phases)`); - }} /> - ))} -
-
- ); -} - -export default function Accueil() { - const { membre, postes, modulesConsultation, modulesSaisie, modulesGestion, logout } = useStore(); - - return ( -
- {/* Header */} -
-
-
-

Bonjour

-

{membre?.prenom}

-
- -
- {postes.length > 0 && ( -
- {postes.map((p) => ( - - {p} - - ))} -
- )} - {/* Lien aa87.org */} - - 📅 Événements du district → - -
- - {/* Modules */} -
-
-
-
-
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AffecterPostes.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AffecterPostes.jsx deleted file mode 100644 index 6669e4e..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AffecterPostes.jsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -export default function AffecterPostes() { - const [postes, setPostes] = useState([]); - const [membres, setMembres] = useState([]); - const [affectations, setAffectations] = useState([]); - const [form, setForm] = useState({ membre_id: '', poste_id: '', date_debut: new Date().toISOString().slice(0, 10) }); - const [loading, setLoading] = useState(true); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - - useEffect(() => { - Promise.all([ - api.get('/postes/'), - api.get('/membres/actifs'), - api.get('/postes/affectations/groupe'), - ]).then(([p, m, a]) => { - setPostes(p); - setMembres(m); - setAffectations(a); - setLoading(false); - }).catch(() => setLoading(false)); - }, []); - - const handleSubmit = async () => { - if (!form.membre_id || !form.poste_id) { - setError('Sélectionnez un membre et un poste'); - return; - } - setError(''); - try { - await api.post('/postes/affectations', form); - setSuccess(true); - } catch (e) { - setError(e.message); - } - }; - - if (success) return ; - - const CATEGORIES = { executif: '🔵', service: '🟣', physique: '🟢' }; - - return ( -
- - -
- {/* Affectations actuelles */} - {affectations.length > 0 && ( -
-

Affectations actives

-
- {affectations.map((a) => ( -
-
-

{CATEGORIES[a.poste_categorie] || '⚪'} {a.poste_nom}

-

{a.membre_prenom} · depuis {a.date_debut}

-
-
- ))} -
-
- )} - - {/* Nouvelle affectation */} -
-

Nouvelle affectation

- -
- - -
- -
- - -
- -
- - setForm({ ...form, date_debut: e.target.value })} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> -
-
- - {error &&

{error}

} - - -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AjusterInventaire.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AjusterInventaire.jsx deleted file mode 100644 index 2c5c17c..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/AjusterInventaire.jsx +++ /dev/null @@ -1,226 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -const CATEGORIES = [ - { value: 'livre', label: 'Livre', emoji: '📕' }, - { value: 'brochure', label: 'Brochure', emoji: '📄' }, - { value: 'depliant', label: 'Dépliant', emoji: '📃' }, - { value: 'pamphlet', label: 'Pamphlet', emoji: '📑' }, -]; - -export default function AjusterInventaire() { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [success, setSuccess] = useState(''); - const [error, setError] = useState(''); - const [showAjout, setShowAjout] = useState(false); - - const charger = async () => { - try { - const data = await api.get('/litterature/'); - setItems(data); - } catch { setItems([]); } - setLoading(false); - }; - - useEffect(() => { charger(); }, []); - - const ajusterStock = async (itemId, quantite) => { - setError(''); - try { - await api.patch(`/litterature/${itemId}/stock`, { quantite }); - setSuccess('Stock ajusté ✓'); - setTimeout(() => { setSuccess(''); charger(); }, 1000); - } catch (e) { - setError(e.message); - } - }; - - if (success) return ; - - return ( -
- - -
- {/* Bouton ajouter un titre */} - - - {showAjout && { setShowAjout(false); charger(); }} />} - - {error &&

{error}

} - - {loading &&

Chargement...

} - - {/* Liste des titres avec ajustement */} - {items.map((item) => ( - - ))} - - {!loading && items.length === 0 && ( -
- 📦 -

Aucun titre dans le catalogue

-
- )} -
-
- ); -} - -function ItemStock({ item, onAjuster }) { - const [ajustement, setAjustement] = useState(''); - - return ( -
-
-
-

{item.titre}

-

{item.categorie}

-
-
-

- {item.quantite_stock} -

-

en stock

-
-
- - {/* Ajustement rapide */} -
- -
- setAjustement(e.target.value)} - className="flex-1 p-2 bg-gray-50 rounded-xl text-center border-0 focus:ring-2 focus:ring-purple-400" - /> - -
- -
-
- ); -} - -function FormAjoutTitre({ onSuccess }) { - const [form, setForm] = useState({ - titre: '', categorie: 'livre', prix_coutant: '', quantite_stock: '0', code_bsg: '', - }); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - const update = (k, v) => setForm({ ...form, [k]: v }); - - const handleSubmit = async () => { - if (!form.titre.trim()) { setError('Le titre est obligatoire'); return; } - setLoading(true); - setError(''); - try { - await api.post('/litterature/', { - titre: form.titre.trim(), - categorie: form.categorie, - prix_coutant: form.prix_coutant ? parseFloat(form.prix_coutant) : null, - quantite_stock: parseInt(form.quantite_stock) || 0, - code_bsg: form.code_bsg.trim() || null, - }); - onSuccess(); - } catch (e) { - setError(e.message); - setLoading(false); - } - }; - - return ( -
- update('titre', e.target.value)} - className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400" - autoFocus - /> -
- {CATEGORIES.map((c) => ( - - ))} -
-
-
- - update('prix_coutant', e.target.value)} - className="w-full p-2 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400 text-center" - /> -
-
- - update('quantite_stock', e.target.value)} - className="w-full p-2 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400 text-center" - /> -
-
- update('code_bsg', e.target.value)} - className="w-full p-2 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400" - /> - {error &&

{error}

} - -
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Anniversaires.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Anniversaires.jsx deleted file mode 100644 index 2e2b499..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Anniversaires.jsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; - -export default function Anniversaires() { - const [anniversaires, setAnniversaires] = useState([]); - const [loading, setLoading] = useState(true); - const [filtre, setFiltre] = useState(90); // jours - - useEffect(() => { - charger(); - }, [filtre]); - - const charger = async () => { - setLoading(true); - try { - const data = await api.get(`/anniversaires/?prochains_jours=${filtre}`); - setAnniversaires(data); - } catch { - setAnniversaires([]); - } - setLoading(false); - }; - - const emojiAnnees = (annees) => { - if (annees >= 20) return '💎'; - if (annees >= 10) return '🏆'; - if (annees >= 5) return '⭐'; - if (annees >= 1) return '🥇'; - return '🌟'; - }; - - const aujourdhui = anniversaires.filter((a) => a.jours_restants === 0); - const prochains = anniversaires.filter((a) => a.jours_restants > 0); - - return ( -
- - -
- {/* Filtres */} -
- {[30, 90, 365].map((j) => ( - - ))} -
- - {loading &&

Chargement...

} - - {/* Aujourd'hui */} - {aujourdhui.length > 0 && ( -
-

- 🎉 Aujourd'hui -

- {aujourdhui.map((a, i) => ( - - ))} -
- )} - - {/* Prochains */} - {prochains.length > 0 && ( -
-

- Prochains anniversaires -

- {prochains.map((a, i) => ( - - ))} -
- )} - - {!loading && anniversaires.length === 0 && ( -
- 🎂 -

Aucun anniversaire dans les prochains {filtre} jours

-
- )} -
-
- ); -} - -function AnniversaireCard({ anniversaire: a, emoji, highlight }) { - return ( -
-
- {emoji} -
-

- {a.membre_prenom} - — {a.annees_sobriete} an{a.annees_sobriete > 1 ? 's' : ''} -

-

{a.groupe_nom}

-
-
- {a.jours_restants === 0 ? ( - - Aujourd'hui! - - ) : ( - - dans {a.jours_restants} j - - )} -
-
-

- {a.prochain_anniversaire} -

-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Calendrier.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Calendrier.jsx deleted file mode 100644 index 80384d6..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Calendrier.jsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import { useStore } from '../stores/store'; -import PageHeader from '../components/PageHeader'; - -const CATEGORIES = { - executif: { label: 'Exécutif', bg: 'bg-blue-100', text: 'text-blue-700' }, - service: { label: 'Service', bg: 'bg-purple-100', text: 'text-purple-700' }, - physique: { label: 'Physique', bg: 'bg-green-100', text: 'text-green-700' }, -}; - -export default function Calendrier() { - const { groupeId } = useStore(); - const [entries, setEntries] = useState([]); - const [detailGroupe, setDetailGroupe] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - api.get('/calendrier/').then((data) => { - setEntries(data); - setLoading(false); - }).catch(() => setLoading(false)); - }, []); - - // Grouper par groupe - const parGroupe = {}; - entries.forEach((e) => { - if (!parGroupe[e.groupe_nom]) { - parGroupe[e.groupe_nom] = { nom: e.groupe_nom, id: e.groupe_id, jour: e.jour_reunion, serviteurs: [] }; - } - parGroupe[e.groupe_nom].serviteurs.push(e); - }); - const groupes = Object.values(parGroupe); - - const voirDetail = async (gId) => { - try { - const data = await api.get(`/calendrier/groupe/${gId}`); - setDetailGroupe(data); - } catch {} - }; - - if (detailGroupe) { - return ( -
-
- -
-

{detailGroupe.groupe?.nom}

-

- {detailGroupe.groupe?.jour_reunion ? `Réunion le ${detailGroupe.groupe.jour_reunion}` : 'Calendrier'} -

-
-
-
- {detailGroupe.groupe?.adresse && ( -
-

Adresse

-

{detailGroupe.groupe.adresse}

-
- )} - {detailGroupe.serviteurs?.map((s, i) => { - const cat = CATEGORIES[s.categorie] || CATEGORIES.physique; - return ( -
-
-

{s.poste}

-

{s.prenom}

-
- - {cat.label} - -
- ); - })} - {(!detailGroupe.serviteurs || detailGroupe.serviteurs.length === 0) && ( -

Aucun serviteur assigné

- )} -
-
- ); - } - - return ( -
- - -
- {loading ? ( -

Chargement...

- ) : groupes.length === 0 ? ( -
-

📅

-

Aucune donnée de calendrier

-
- ) : ( - groupes.map((g) => { - const isMon = g.id === groupeId; - return ( - - ); - }) - )} -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Collecte.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Collecte.jsx deleted file mode 100644 index 39878a2..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Collecte.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { useState } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -export default function Collecte() { - const [montant, setMontant] = useState(''); - const [dateCollecte, setDateCollecte] = useState(new Date().toISOString().slice(0, 10)); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - - const handleSubmit = async () => { - if (!montant || parseFloat(montant) <= 0) { - setError('Entrez un montant valide'); - return; - } - setLoading(true); - setError(''); - try { - await api.post('/collectes/', { - date_collecte: dateCollecte, - montant: parseFloat(montant), - }); - setSuccess(true); - } catch (e) { - setError(e.message); - setLoading(false); - } - }; - - if (success) return ; - - return ( -
- - -
- {/* Date pré-remplie */} -
- - setDateCollecte(e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" - /> -
- - {/* Montant — gros clavier numérique */} -
- -
- $ - setMontant(e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-3xl font-bold text-center border-0 focus:ring-2 focus:ring-green-500" - autoFocus - /> -
-
- - {error &&

{error}

} - - {/* Bouton enregistrer */} - -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/CommanderJetons.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/CommanderJetons.jsx deleted file mode 100644 index 0d30a6a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/CommanderJetons.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; - -export default function CommanderJetons() { - const [stock, setStock] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - api.get('/commandes-jetons/stock') - .then(setStock) - .catch(() => setStock([])) - .finally(() => setLoading(false)); - }, []); - - const aCommander = stock.filter((s) => s.stock_suggestion !== 'OK'); - - return ( -
- - -
- {loading &&

Chargement...

} - - {/* Alerte stock bas */} - {aCommander.length > 0 && ( -
-

- ⚠️ Jetons à commander ({aCommander.length}) -

-
- {aCommander.map((s) => ( -

- {s.label} — {s.remis_dernier_mois} remis ce mois -

- ))} -
-
- )} - - {/* Vue complète du stock */} - {!loading && ( -
-

- Utilisation (30 derniers jours) -

- {stock.map((s) => ( -
- {s.label.split(' ')[0]} -
-

{s.label.split(' ').slice(1).join(' ')}

-

{s.remis_dernier_mois} remis

-
- - {s.stock_suggestion} - -
- ))} -
- )} - - {!loading && stock.length === 0 && ( -
- 🏅 -

Aucune donnée de jetons pour le moment

-
- )} - - {/* Info */} -
-

- 💡 Les suggestions sont basées sur le nombre de jetons remis dans les 30 derniers jours. - Commandez auprès de l'Intergroupe lorsque le stock est bas. -

-
-
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/ConfigRepartition.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/ConfigRepartition.jsx deleted file mode 100644 index 5c590ca..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/ConfigRepartition.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -export default function ConfigRepartition() { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - - useEffect(() => { - api.get('/config-repartition/') - .then(setItems) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - const total = items.reduce((sum, i) => sum + (parseFloat(i.pourcentage) || 0), 0); - - const updatePct = (idx, val) => { - const updated = [...items]; - updated[idx] = { ...updated[idx], pourcentage: val }; - setItems(updated); - }; - - const handleSave = async () => { - if (Math.abs(total - 100) > 0.01) { - setError(`Le total doit être 100%. Actuellement: ${total.toFixed(2)}%`); - return; - } - setSaving(true); - setError(''); - try { - await api.request('PUT', '/config-repartition/', { - items: items.map((i) => ({ - destination: i.destination, - pourcentage: parseFloat(i.pourcentage), - })), - }); - setSuccess(true); - } catch (e) { - setError(e.message); - setSaving(false); - } - }; - - if (success) return ; - - return ( -
- - -
- {loading &&

Chargement...

} - -
-

- 💡 Répartition recommandée : District 30%, Région 30%, BSG 30%, Intergroupe 10%. - Le total doit toujours être 100%. -

-
- - {items.map((item, idx) => ( -
-
-

{item.destination_label}

-

depuis {item.actif_depuis}

-
-
- updatePct(idx, parseFloat(e.target.value))} - className="flex-1 accent-aa-blue" - /> -
- updatePct(idx, e.target.value)} - className="w-full p-2 bg-gray-50 rounded-xl text-center font-bold border-0 focus:ring-2 focus:ring-aa-blue" - /> - % -
-
-
- ))} - - {/* Total */} - {items.length > 0 && ( -
- Total : {total.toFixed(1)}% - {Math.abs(total - 100) >= 0.01 && ( -

Le total doit être exactement 100%

- )} -
- )} - - {error &&

{error}

} - - -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Decisions.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Decisions.jsx deleted file mode 100644 index 93300b2..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Decisions.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; - -const STATUTS = { - en_attente: { label: 'En attente', bg: 'bg-yellow-100', text: 'text-yellow-800', emoji: '⏳' }, - en_cours: { label: 'En cours', bg: 'bg-blue-100', text: 'text-blue-800', emoji: '🔄' }, - complete: { label: 'Complété', bg: 'bg-green-100', text: 'text-green-800', emoji: '✓' }, -}; - -function Badge({ statut }) { - const s = STATUTS[statut] || STATUTS.en_attente; - return ( - - {s.emoji} {s.label} - - ); -} - -export default function Decisions() { - const [decisions, setDecisions] = useState([]); - const [filtre, setFiltre] = useState('ouvertes'); // ouvertes | toutes - const [loading, setLoading] = useState(true); - - const charger = async () => { - setLoading(true); - try { - let url = '/decisions/'; - if (filtre === 'ouvertes') { - // Charger en_attente et en_cours - const [att, enc] = await Promise.all([ - api.get('/decisions/?statut=en_attente'), - api.get('/decisions/?statut=en_cours'), - ]); - setDecisions([...enc, ...att]); - } else { - const data = await api.get(url); - setDecisions(data); - } - } catch (e) { - console.error(e); - } - setLoading(false); - }; - - useEffect(() => { charger(); }, [filtre]); - - return ( -
- - - {/* Filtres */} -
-
- {[ - { key: 'ouvertes', label: 'Ouvertes' }, - { key: 'toutes', label: 'Toutes' }, - ].map((f) => ( - - ))} -
-
- - {/* Liste */} -
- {loading ? ( -

Chargement...

- ) : decisions.length === 0 ? ( -
-

📌

-

Aucune décision {filtre === 'ouvertes' ? 'ouverte' : ''}

-
- ) : ( - decisions.map((d) => ( -
-
-
-

{d.sujet}

- {d.description && ( -

{d.description}

- )} -
- - {d.responsable_prenom && ( - → {d.responsable_prenom} - )} - {d.date_decision} -
- {d.proposeur && ( -

- Proposé par {d.proposeur}{d.secondeur ? `, secondé par ${d.secondeur}` : ''} -

- )} -
-
-
- )) - )} -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Depense.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Depense.jsx deleted file mode 100644 index 84919b8..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Depense.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -const METHODES = [ - { value: 'DU', label: 'DÛ', emoji: '📋' }, - { value: 'VIREMENT', label: 'Virement', emoji: '🏦' }, - { value: 'CHEQUE', label: 'Chèque', emoji: '📝' }, - { value: 'COLLECTE', label: 'Collecte', emoji: '💰' }, -]; - -export default function Depense() { - const [form, setForm] = useState({ - date_depense: new Date().toISOString().slice(0, 10), - montant: '', - qui: '', - quoi: '', - paye: true, - methode: '', - }); - const [membres, setMembres] = useState([]); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - - useEffect(() => { - // Charger la liste des membres pour le dropdown "Qui" - api.get('/membres/actifs').then(setMembres).catch(() => setMembres([])); - }, []); - - const update = (k, v) => setForm({ ...form, [k]: v }); - - const handleSubmit = async () => { - if (!form.montant || !form.qui || !form.quoi.trim() || !form.methode) { - setError('Tous les champs sont obligatoires'); - return; - } - setLoading(true); - setError(''); - try { - await api.post('/depenses/', { - date_depense: form.date_depense, - montant: parseFloat(form.montant), - qui: form.qui, - quoi: form.quoi.trim(), - paye: form.paye, - methode: form.methode, - }); - setSuccess(true); - } catch (e) { - setError(e.message); - setLoading(false); - } - }; - - if (success) return ; - - return ( -
- - -
- {/* Date */} -
- - update('date_depense', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" - /> -
- - {/* Montant */} -
- -
- $ - update('montant', e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-red-400" - /> -
-
- - {/* Qui (dropdown membres) */} -
- - -
- - {/* Quoi (texte libre) */} -
- - update('quoi', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" - /> -
- - {/* Méthode (4 boutons radio) */} -
- -
- {METHODES.map((m) => ( - - ))} -
-
- - {/* Payé (Oui/Non) */} -
- -
- - -
-
- - {error &&

{error}

} - - -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/EnvoiContributions.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/EnvoiContributions.jsx deleted file mode 100644 index 72c7749..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/EnvoiContributions.jsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -const METHODES = [ - { value: 'VIREMENT', label: 'Virement', emoji: '🏦' }, - { value: 'CHEQUE', label: 'Chèque', emoji: '📝' }, - { value: 'DU', label: 'Dû', emoji: '📋' }, -]; - -export default function EnvoiContributions() { - const [mode, setMode] = useState('calculer'); // calculer | enregistrer - const [montantTotal, setMontantTotal] = useState(''); - const [repartition, setRepartition] = useState([]); - const [envois, setEnvois] = useState([]); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - - // Charger les envois récents - useEffect(() => { - api.get('/contributions/?limite=10').then(setEnvois).catch(() => {}); - }, []); - - const calculer = async () => { - if (!montantTotal || parseFloat(montantTotal) <= 0) { - setError('Entrez un montant valide'); - return; - } - setError(''); - try { - const data = await api.post('/contributions/calculer', { - montant_total: parseFloat(montantTotal), - }); - setRepartition(data); - } catch (e) { - setError(e.message); - } - }; - - if (success) return ; - - return ( -
- - -
- {/* Tabs */} -
- - -
- - {mode === 'calculer' && ( - <> - {/* Montant à répartir */} -
- -
- $ - setMontantTotal(e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-aa-blue" - /> -
-
- - - - {/* Résultat */} - {repartition.length > 0 && ( -
- {repartition.map((r) => ( -
-
-

{r.destination_label}

-

{r.pourcentage}%

-
-

{r.montant.toFixed(2)} $

-
- ))} -
- )} - - )} - - {mode === 'enregistrer' && ( - setSuccess(true)} - onError={setError} - /> - )} - - {error &&

{error}

} - - {/* Historique */} - {envois.length > 0 && ( -
-

- Derniers envois -

- {envois.map((e) => ( -
-
-

{e.destination_label}

-

{e.date_envoi} · {e.methode}

-
-

{e.montant.toFixed(2)} $

-
- ))} -
- )} -
-
- ); -} - -function FormEnvoi({ onSuccess, onError }) { - const [form, setForm] = useState({ - date_envoi: new Date().toISOString().slice(0, 10), - destination: '', - montant: '', - methode: '', - }); - const [loading, setLoading] = useState(false); - - const DESTINATIONS = [ - { value: 'district', label: 'District 87-16' }, - { value: 'region', label: 'Région 87' }, - { value: 'bsg', label: 'BSG' }, - { value: 'intergroupe', label: 'Intergroupe' }, - ]; - - const update = (k, v) => setForm({ ...form, [k]: v }); - - const handleSubmit = async () => { - if (!form.destination || !form.montant || !form.methode) { - onError('Tous les champs sont obligatoires'); - return; - } - setLoading(true); - onError(''); - try { - await api.post('/contributions/', { - date_envoi: form.date_envoi, - destination: form.destination, - montant: parseFloat(form.montant), - methode: form.methode, - }); - onSuccess(); - } catch (e) { - onError(e.message); - setLoading(false); - } - }; - - return ( -
-
- - update('date_envoi', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> -
- -
- -
- {DESTINATIONS.map((d) => ( - - ))} -
-
- -
- -
- $ - update('montant', e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-blue-400" /> -
-
- -
- -
- {METHODES.map((m) => ( - - ))} -
-
- - -
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/GererMembres.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/GererMembres.jsx deleted file mode 100644 index 038f289..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/GererMembres.jsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; - -export default function GererMembres() { - const [membres, setMembres] = useState([]); - const [loading, setLoading] = useState(true); - const [detail, setDetail] = useState(null); - const [confirmAction, setConfirmAction] = useState(null); - const [msg, setMsg] = useState(''); - - const charger = async () => { - setLoading(true); - try { - const data = await api.get('/membres/'); - setMembres(data); - } catch {} - setLoading(false); - }; - - useEffect(() => { charger(); }, []); - - const desactiver = async (id) => { - try { - const res = await api.patch(`/membres/${id}/desactiver`); - setMsg(res.message); - setConfirmAction(null); - setDetail(null); - charger(); - setTimeout(() => setMsg(''), 3000); - } catch (e) { alert(e.message); } - }; - - const droitOubli = async (id) => { - try { - const res = await api.request('DELETE', `/membres/${id}/oubli`); - setMsg(res.message); - setConfirmAction(null); - setDetail(null); - charger(); - setTimeout(() => setMsg(''), 3000); - } catch (e) { alert(e.message); } - }; - - // Vue détail d'un membre - if (detail) { - return ( -
-
- -

{detail.prenom} {detail.nom}

-
-
- {msg &&
{msg}
} - -
-

Prénom

{detail.prenom}

-

Nom

{detail.nom}

-

Téléphone

{detail.telephone || '—'}

-

Courriel

{detail.courriel || '—'}

-

Date d'abstinence

{detail.date_abstinence || '—'}

-

Statut

- - {detail.actif ? 'Actif' : 'Inactif'} - -
-
- - {/* Contact rapide */} -
- {detail.telephone && ( - - 📞 Appeler - - )} - {detail.courriel && ( - - ✉️ Courriel - - )} -
- - {/* Actions */} - {detail.actif && !confirmAction && ( - - )} - {!confirmAction && ( - - )} - - {/* Confirmations */} - {confirmAction === 'desactiver' && ( -
-

Désactiver {detail.prenom} ? Le membre ne pourra plus se connecter.

-
- - -
-
- )} - {confirmAction === 'oubli' && ( -
-

⚠️ Cette action est irréversible. Toutes les données personnelles de {detail.prenom} seront anonymisées.

-
- - -
-
- )} -
-
- ); - } - - return ( -
- -
- {msg &&
{msg}
} - {loading ? ( -

Chargement...

- ) : ( - membres.map((m) => ( - - )) - )} -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inscription.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inscription.jsx deleted file mode 100644 index c4591a6..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inscription.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useState } from 'react'; -import { useStore } from '../stores/store'; - -export default function Inscription() { - const { inscription, loading, error, setPage } = useStore(); - const [form, setForm] = useState({ - prenom: '', nom: '', pin: '', code_invitation: '', - telephone: '', courriel: '', date_abstinence: '', - }); - - const update = (k, v) => setForm({ ...form, [k]: v }); - - const handleSubmit = async (e) => { - e.preventDefault(); - try { - await inscription(form); - } catch {} - }; - - return ( -
-
-

Nouveau membre

-

Groupe Méditation

-
- -
- update('code_invitation', e.target.value.toUpperCase())} - className="w-full p-4 bg-gray-50 rounded-xl text-lg text-center tracking-wider border-0 focus:ring-2 focus:ring-aa-blue" - /> - -
- update('prenom', e.target.value)} - className="p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> - update('nom', e.target.value)} - className="p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> -
- - update('pin', e.target.value.replace(/\D/g, '').slice(0, 4))} - className="w-full p-4 bg-gray-50 rounded-xl text-lg text-center tracking-[1em] border-0 focus:ring-2 focus:ring-aa-blue" /> - - update('telephone', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> - - update('courriel', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> - -
- - update('date_abstinence', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> -
- - {error &&

{error}

} - - - - -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inventaire.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inventaire.jsx deleted file mode 100644 index 2ff0d58..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/Inventaire.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; - -const CATEGORIES = { - livre: { label: 'Livres', emoji: '📕' }, - brochure: { label: 'Brochures', emoji: '📄' }, - depliant: { label: 'Dépliants', emoji: '📃' }, - pamphlet: { label: 'Pamphlets', emoji: '📑' }, -}; - -export default function Inventaire() { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - api.get('/litterature/') - .then(setItems) - .catch(() => setItems([])) - .finally(() => setLoading(false)); - }, []); - - // Grouper par catégorie - const parCategorie = {}; - for (const item of items) { - if (!parCategorie[item.categorie]) parCategorie[item.categorie] = []; - parCategorie[item.categorie].push(item); - } - - const stockBas = items.filter((i) => i.stock_bas); - - return ( -
- - -
- {loading &&

Chargement...

} - - {/* Alerte stock bas */} - {stockBas.length > 0 && ( -
-

- ⚠️ Stock bas ({stockBas.length} titre{stockBas.length > 1 ? 's' : ''}) -

-

- {stockBas.map((i) => i.titre).join(', ')} -

-
- )} - - {/* Par catégorie */} - {Object.entries(parCategorie).map(([cat, liste]) => { - const info = CATEGORIES[cat] || { label: cat, emoji: '📚' }; - return ( -
-

- {info.emoji} {info.label} -

- {liste.map((item) => ( -
-
-
-

{item.titre}

- {item.code_bsg && ( -

BSG: {item.code_bsg}

- )} -
-
- {item.prix_coutant != null && ( -

{item.prix_coutant.toFixed(2)} $

- )} -

- {item.quantite_stock} en stock -

-
-
-
- ))} -
- ); - })} - - {!loading && items.length === 0 && ( -
- 📚 -

Aucune littérature dans l'inventaire

-

Le responsable de la littérature peut ajouter des titres

-
- )} -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/InviterMembre.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/InviterMembre.jsx deleted file mode 100644 index a7585a4..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/InviterMembre.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { useState } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; - -export default function InviterMembre() { - const [code, setCode] = useState(null); - const [expiration, setExpiration] = useState(null); - const [loading, setLoading] = useState(false); - const [copied, setCopied] = useState(false); - - const generer = async () => { - setLoading(true); - try { - const data = await api.post('/invitations/'); - setCode(data.code); - setExpiration(new Date(data.expire_le).toLocaleString('fr-CA')); - } catch (e) { - alert(e.message); - } - setLoading(false); - }; - - const copier = () => { - navigator.clipboard.writeText(code).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }; - - const partagerSMS = () => { - const msg = `Rejoins le Groupe Méditation sur l'app ! Ton code d'invitation : ${code} (expire dans 48h)`; - window.open(`sms:?body=${encodeURIComponent(msg)}`); - }; - - return ( -
- - -
- {!code ? ( -
-

➕

-

Générer un code d'invitation

-

Le code expire après 48 heures et ne peut être utilisé qu'une fois

- -
- ) : ( -
-
-

Code d'invitation

-

{code}

-

Expire le {expiration}

-
- - - - - - -
- )} -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/JumelerParrain.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/JumelerParrain.jsx deleted file mode 100644 index d61748a..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/JumelerParrain.jsx +++ /dev/null @@ -1,173 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -export default function JumelerParrain() { - const [membres, setMembres] = useState([]); - const [jumelages, setJumelages] = useState([]); - const [form, setForm] = useState({ - parrain_id: '', filleul_id: '', - date_jumelage: new Date().toISOString().slice(0, 10), - note: '', - }); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - const [showForm, setShowForm] = useState(false); - - useEffect(() => { - api.get('/membres/actifs').then(setMembres).catch(() => []); - chargerJumelages(); - }, []); - - const chargerJumelages = async () => { - try { - const data = await api.get('/jumelages/'); - setJumelages(data); - } catch { setJumelages([]); } - }; - - const update = (k, v) => setForm({ ...form, [k]: v }); - - const handleSubmit = async () => { - if (!form.parrain_id || !form.filleul_id) { - setError('Sélectionnez le parrain et le filleul'); - return; - } - if (form.parrain_id === form.filleul_id) { - setError('Le parrain et le filleul doivent être différents'); - return; - } - setLoading(true); - setError(''); - try { - await api.post('/jumelages/', { - parrain_id: form.parrain_id, - filleul_id: form.filleul_id, - date_jumelage: form.date_jumelage, - note: form.note.trim() || null, - }); - setSuccess(true); - } catch (e) { - setError(e.message); - setLoading(false); - } - }; - - const terminer = async (id) => { - try { - await api.patch(`/jumelages/${id}`, { statut: 'termine' }); - chargerJumelages(); - } catch (e) { - setError(e.message); - } - }; - - if (success) return ; - - const actifs = jumelages.filter((j) => j.statut === 'actif'); - const termines = jumelages.filter((j) => j.statut === 'termine'); - - return ( -
- - -
-
-

- 🔒 Ces informations sont confidentielles et accessibles uniquement à l'exécutif. -

-
- - - - {showForm && ( -
-
- - -
-
- - -
-
- - update('date_jumelage', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-yellow-400" /> -
-
- - update('note', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-yellow-400" /> -
- - {error &&

{error}

} - - -
- )} - - {/* Jumelages actifs */} - {actifs.length > 0 && ( -
-

Actifs

- {actifs.map((j) => ( -
-
- 🤝 -
-

{j.parrain_prenom} → {j.filleul_prenom}

-

Depuis {j.date_jumelage}

- {j.note &&

{j.note}

} -
- -
-
- ))} -
- )} - - {/* Jumelages terminés */} - {termines.length > 0 && ( -
-

Terminés

- {termines.map((j) => ( -
-

{j.parrain_prenom} → {j.filleul_prenom}

-

{j.date_jumelage}

-
- ))} -
- )} - - {jumelages.length === 0 && !showForm && ( -
- 🤝 -

Aucun jumelage enregistré

-
- )} -
-
- ); -} diff --git a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/LancerSondage.jsx b/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/LancerSondage.jsx deleted file mode 100644 index 7472cb8..0000000 --- a/groupe-meditation-corrige/groupe-meditation/frontend/src/pages/LancerSondage.jsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useState } from 'react'; -import { api } from '../stores/api'; -import PageHeader from '../components/PageHeader'; -import SuccessCheck from '../components/SuccessCheck'; - -const TYPES = [ - { value: 'oui_non', label: 'Oui / Non', emoji: '👍' }, - { value: 'choix_multiple', label: 'Choix multiple', emoji: '📋' }, - { value: 'unanimite', label: 'Unanimité', emoji: '🤝' }, -]; - -export default function LancerSondage() { - const [form, setForm] = useState({ - titre: '', - description: '', - type: 'oui_non', - date_ouverture: new Date().toISOString().slice(0, 16), - date_fermeture: '', - options: ['', ''], - }); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - - const update = (k, v) => setForm({ ...form, [k]: v }); - - const updateOption = (idx, val) => { - const opts = [...form.options]; - opts[idx] = val; - setForm({ ...form, options: opts }); - }; - - const addOption = () => setForm({ ...form, options: [...form.options, ''] }); - - const removeOption = (idx) => { - if (form.options.length <= 2) return; - setForm({ ...form, options: form.options.filter((_, i) => i !== idx) }); - }; - - const handleSubmit = async () => { - if (!form.titre.trim()) { setError('Le titre est obligatoire'); return; } - if (!form.date_fermeture) { setError('La date de fermeture est obligatoire'); return; } - - const options = form.type === 'oui_non' - ? [] // auto-générées par le backend - : form.options.filter((o) => o.trim()).map((o) => ({ texte: o.trim() })); - - if (form.type !== 'oui_non' && options.length < 2) { - setError('Au moins 2 options sont requises'); - return; - } - - setLoading(true); - setError(''); - try { - await api.post('/sondages/', { - titre: form.titre.trim(), - description: form.description.trim() || null, - type: form.type, - date_ouverture: new Date(form.date_ouverture).toISOString(), - date_fermeture: new Date(form.date_fermeture).toISOString(), - options, - }); - setSuccess(true); - } catch (e) { - setError(e.message); - setLoading(false); - } - }; - - if (success) return ; - - return ( -
- - -
-
- - update('titre', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-indigo-400" autoFocus /> -
- -
- -