This commit is contained in:
Daniel Allaire 2026-03-13 01:28:06 -04:00
parent 26bf69d279
commit e3a7edcf21
127 changed files with 55 additions and 9796 deletions

View file

@ -52,7 +52,7 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. 27
| DB | PostgreSQL 16 + SQLAlchemy async | | DB | PostgreSQL 16 + SQLAlchemy async |
| Auth | JWT 30 jours + bcrypt 12 rounds | | Auth | JWT 30 jours + bcrypt 12 rounds |
| PDF | WeasyPrint | | PDF | WeasyPrint |
| Serveur | Apache (statiques) + NGINX (reverse proxy HTTPS) | | Serveur | NGINX (statiques + reverse proxy HTTPS) |
## Principes ## Principes

View file

@ -11,6 +11,9 @@ from app.core.config import settings
from app.core.database import engine, Base 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 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):

View file

@ -1,28 +0,0 @@
<VirtualHost 127.0.0.1:8080>
ServerName groupe-meditation.chezlepro.ca
DocumentRoot /var/www/groupe-meditation
<Directory /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]
</Directory>
# Cache assets statiques
<LocationMatch "\.(js|css|png|jpg|svg|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
# Pas de cache pour index.html (PWA)
<LocationMatch "^/index\.html$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
</LocationMatch>
</VirtualHost>

View file

@ -1,32 +1,23 @@
# Groupe Méditation — Configuration NGINX # Groupe Méditation — NGINX (seul, sans Apache)
# Reverse proxy vers Apache (frontend) et FastAPI (backend) # 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 { server {
listen 443 ssl http2; listen 80;
server_name groupe-meditation.chezlepro.ca; server_name groupe-meditation.chezlepro.ca;
# Certificat Let's Encrypt # ── Headers de sécurité ──
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-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin 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 # ── Backend API → FastAPI (Uvicorn sur port 8000) ──
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/ { location /api/ {
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
@ -35,16 +26,31 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Rate limiting anti-brute force (PIN) # 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; client_max_body_size 5M;
} }
# Redirection HTTP → HTTPS
server {
listen 80;
server_name groupe-meditation.chezlepro.ca;
return 301 https://$host$request_uri;
}

View file

@ -1,6 +1,6 @@
# Guide de déploiement — Groupe Méditation # 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+ - PostgreSQL 16+
- Python 3.11+ - Python 3.11+
- Node.js 20+ (pour le build frontend) - Node.js 20+ (pour le build frontend)
- Apache 2.4+ avec mod_rewrite et mod_headers - NGINX 1.24+
- NGINX 1.24+ (reverse proxy)
- Certbot (Let's Encrypt) - Certbot (Let's Encrypt)
--- ---
@ -54,17 +53,6 @@ source venv/bin/activate
pip install -r requirements.txt 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 ## 3. Configurer le backend
```bash ```bash
@ -95,10 +83,10 @@ python deploy/seed.py
``` ```
Le script crée **interactivement** : 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 23 tables
- Les 10 postes prédéfinis (RSG, RSG Substitut, Trésorier, Secrétaire, Animateur, Accueil, Café, Littérature, Jetons, Clés) - Les 10 postes prédéfinis
- La répartition par défaut des contributions (30/30/30/10) - 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 - Le premier groupe et le premier membre avec son poste
- Un code d'invitation valide 7 jours - 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 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 ## 6. Construire le frontend
```bash ```bash
@ -138,47 +115,27 @@ sudo cp -r dist/* /var/www/groupe-meditation/
sudo chown -R www-data:www-data /var/www/groupe-meditation sudo chown -R www-data:www-data /var/www/groupe-meditation
``` ```
Le build produit une PWA installable avec : ## 7. Configurer NGINX
- 30 pages React (27 modules + Login + Inscription + Accueil)
- Tailwind CSS mobile-first
- Service Worker pour le mode hors ligne
## 7. Configurer Apache NGINX fait tout : servir les fichiers statiques du frontend ET proxy vers FastAPI. Pas besoin d'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
```bash ```bash
sudo cp deploy/nginx-groupe-meditation.conf /etc/nginx/sites-available/groupe-meditation 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 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 nginx -t
sudo systemctl reload nginx sudo systemctl reload nginx
# Certificat TLS
sudo certbot --nginx -d groupe-meditation.chezlepro.ca
``` ```
NGINX est le point d'entrée unique HTTPS : NGINX sert :
- `/` → Apache (fichiers statiques frontend) - `/api/*` → reverse proxy vers FastAPI (Uvicorn port 8000)
- `/api/*` → FastAPI (Uvicorn port 8000) - `/*` → fichiers statiques du build React (SPA routing via `try_files`)
- HSTS, CSP, X-Frame-Options, rate limiting - Cache 1 an sur les assets (JS, CSS, images), pas de cache sur index.html
## 9. Sauvegarde automatique ## 8. Sauvegarde automatique
```bash ```bash
sudo cp deploy/backup.sh /etc/cron.daily/groupe-meditation-backup 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) - pg_dump quotidien en format custom (-Fc)
- Rétention 30 jours - Rétention 30 jours
- Stockage Ceph (réplication triple copie)
- **Test de restauration mensuel recommandé** : - **Test de restauration mensuel recommandé** :
```bash ```bash
pg_restore -U grpmed -d groupe_meditation_test /var/backups/groupe-meditation/grpmed_DERNIER.dump pg_restore -U grpmed -d groupe_meditation_test /var/backups/groupe-meditation/grpmed_DERNIER.dump
``` ```
## 10. Vérification finale ## 9. Vérification finale
```bash ```bash
# API # 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` | | 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 frontend | `cd frontend && npm run build && sudo cp -r dist/* /var/www/groupe-meditation/` |
| Mise à jour backend | `sudo systemctl restart 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 ## Mise à jour de l'application

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -1,67 +0,0 @@
# Groupe Méditation — Application PWA
**District 87-16 · Région 87 · Alcooliques anonymes**
Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. 27 modules.
## Phases 0 à 9 — Complétées
### Consultation (C1-C7) — Tous les membres
- **C1** Rapports PDF (WeasyPrint) — finances, PV, rapport RSG, financier complet
- **C2** Calendrier district — serviteurs de tous les groupes du 87-16
- **C3** Anniversaires district — sobriété, prochains en évidence
- **C4** Littérature — inventaire avec prix, alerte stock bas
- **C5** Décisions en cours — suivi réunion d'affaires
- **C6** Votes ouverts — sondages, barres de résultat, vote anonyme
- **C7** Mon profil — modifier, PIN, notifications
### Saisie hebdomadaire (S1-S5)
- **S1** Collecte 7e Tradition (trésorier)
- **S2** Remise de jeton (resp. jetons) — 9 types, historique
- **S3** Vente littérature (resp. littérature) — stock auto-décrémenté
- **S4** Rapport de réunion (animateur)
- **S5** Dépense — 6 champs (trésorier)
### Saisie mensuelle (M1-M6)
- **M1** Rédiger PV (secrétaire) — décisions structurées, auto-création C5
- **M2** Présences réunion d'affaires (secrétaire)
- **M3** Rapport RSG — template 3 sections
- **M4** Envoi contributions — répartition auto district/région/BSG/intergroupe
- **M5** Ajuster inventaire — +/−, ajout catalogue
- **M6** Commander jetons — état stock 30 jours
### Gestion (G1-G9) — Exécutif
- **G1** Inviter un membre — code 48h
- **G2** Gérer les membres — fiche complète, droit à l'oubli
- **G3** Affecter les postes
- **G4** Terminer un mandat — transition manuelle
- **G5** Planifier les rotations
- **G6** Lancer un sondage — oui/non, choix multiple, unanimité
- **G7** Jumeler parrain/filleul — confidentiel
- **G8** Config. répartition — % contributions (défaut 30/30/30/10)
- **G9** Paramètres du groupe
## Stack technique
| Couche | Technologie |
|--------|-------------|
| Frontend | React 18 + Vite (PWA) |
| UI | Tailwind CSS |
| État | Zustand |
| Backend | FastAPI (Python) async |
| DB | PostgreSQL 16 + SQLAlchemy async |
| Auth | JWT 30 jours + bcrypt 12 rounds |
| PDF | WeasyPrint |
| Serveur | Apache (statiques) + NGINX (reverse proxy HTTPS) |
## Principes
- **Anonymat AA** — Prénom seul dans les écrans partagés
- **Pas d'administrateur** — Permissions = exécutif du groupe
- **Mandats manuels** — Aucune terminaison automatique
- **Zéro traçage** — Pas d'analytics, pas de cookies tiers
- **Hébergé au Québec** — Souveraineté numérique · 100% logiciel libre
---
*«Nos leaders sont des serviteurs de confiance ; ils ne gouvernent pas.» — 2e Tradition*

View file

@ -1,33 +0,0 @@
Révision corrective appliquée
Principales corrections intégrées :
- backend/app/core/config.py
- chargement robuste de backend/.env avec chemin absolu
- CORS mis à jour pour groupe-meditation.87-16.org
- deploy/apache-groupe-meditation.conf
- Apache local sur *:80
- ProxyPass /api vers 127.0.0.1:8000
- exclusion de /api des règles SPA rewrite
- deploy/groupe-meditation.service
- EnvironmentFile ajouté
- workers retirés pour simplifier le diagnostic
- frontend/src/pages/Login.jsx
- message clair si les groupes ne chargent pas
- bouton Réessayer
- frontend/src/stores/store.js
- loadGroupes gère loading et error correctement
- frontend/src/stores/api.js
- erreurs non JSON mieux exposées
- docs/DEPLOIEMENT.md
- réécrit pour l’architecture réelle : proxy externe, pas de NGINX local
- README.md et backend/.env.example
- FQDN harmonisé vers groupe-meditation.87-16.org
Points restant à valider sur la machine :
1. a2enmod rewrite headers proxy proxy_http
2. a2ensite groupe-meditation.conf
3. systemctl daemon-reload && systemctl restart groupe-meditation
4. build frontend puis copie vers /var/www/groupe-meditation
5. tests :
- curl http://127.0.0.1:8000/api/health
- curl -H "Host: groupe-meditation.87-16.org" http://127.0.0.1/api/health

View file

@ -1,7 +0,0 @@
# Groupe Méditation — Configuration
# Copier vers .env et ajuster les valeurs
DATABASE_URL=postgresql+asyncpg://grpmed:CHANGEZ_MOI@localhost:5432/groupe_meditation
JWT_SECRET=CHANGEZ_MOI_64_caracteres_aleatoires_generes_par_openssl_rand_base64_48
CORS_ORIGINS=["https://groupe-meditation.87-16.org"]
INVITATION_EXPIRE_HOURS=48

View file

@ -1,30 +0,0 @@
"""Configuration de l'application."""
from pathlib import Path
from typing import List
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parents[2] # /opt/groupe-meditation/backend
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://grpmed:grpmed2026@localhost:5432/groupe_meditation"
JWT_SECRET: str = "changez-moi-en-production-clef-secrete-64-caracteres-minimum"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_DAYS: int = 30
CORS_ORIGINS: List[str] = [
"http://localhost:5173",
"http://localhost:3000",
"http://groupe-meditation.87-16.org",
"https://groupe-meditation.87-16.org",
]
INVITATION_EXPIRE_HOURS: int = 48
model_config = SettingsConfigDict(
env_file=BASE_DIR / ".env",
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()

View file

@ -1,21 +0,0 @@
"""Connexion PostgreSQL async."""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_size=5, max_overflow=10)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

View file

@ -1,68 +0,0 @@
"""Authentification JWT + hachage PIN."""
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import bcrypt
from app.core.config import settings
from app.core.database import get_db
security = HTTPBearer()
def hash_pin(pin: str) -> 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

View file

@ -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"}

View file

@ -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")

View file

@ -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))

View file

@ -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)

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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")

View file

@ -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)

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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")

View file

@ -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)

View file

@ -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))

View file

@ -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))

View file

@ -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,
)

View file

@ -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

View file

@ -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),
)

View file

@ -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,
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,
)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)"}

View file

@ -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)

View file

@ -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é"}

View file

@ -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

View file

@ -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)

View file

@ -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"<tr><td>{c.date_collecte}</td><td>{float(c.montant):.2f} $</td><td>{saisi.prenom if saisi else '-'}</td></tr>"
rows_depenses = ""
for d in depenses:
qui = await db.get(Membre, d.qui) if d.qui else None
badge = '<span class="badge-paye">✓</span>' if d.paye else '<span class="badge-impaye">✗ DÛ</span>'
rows_depenses += f"<tr><td>{d.date_depense}</td><td>{float(d.montant):.2f} $</td><td>{qui.prenom if qui else '-'}</td><td>{d.quoi}</td><td>{d.methode}</td><td>{badge}</td></tr>"
html_content = f"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
<div class="header">
<div class="groupe">{groupe.nom if groupe else 'Groupe'}</div>
<div class="sub">District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}</div>
</div>
<h1>Rapport financier — {MOIS_FR[mois]} {annee}</h1>
<h2>Collectes (7e Tradition)</h2>
<table><tr><th>Date</th><th>Montant</th><th>Saisi par</th></tr>
{rows_collectes if rows_collectes else '<tr><td colspan="3">Aucune collecte ce mois</td></tr>'}
</table>
<p class="total">Total collectes : {total_collectes:.2f} $</p>
<h2>Dépenses</h2>
<table><tr><th>Date</th><th>Montant</th><th>Qui</th><th>Quoi</th><th>Méthode</th><th>Payé</th></tr>
{rows_depenses if rows_depenses else '<tr><td colspan="6">Aucune dépense ce mois</td></tr>'}
</table>
<p class="total">Total dépenses : {total_depenses:.2f} $</p>
<h2>Sommaire</h2>
<table>
<tr><td><strong>Collectes</strong></td><td><strong>{total_collectes:.2f} $</strong></td></tr>
<tr><td><strong>Dépenses</strong></td><td><strong>−{total_depenses:.2f} $</strong></td></tr>
<tr><td><strong>Solde du mois</strong></td><td><strong>{'+'if solde>=0 else ''}{solde:.2f} $</strong></td></tr>
</table>
<div class="footer">
Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 100% logiciel libre
</div>
</body></html>"""
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"<tr><td>{r.date_reunion.strftime('%Y-%m-%d')}</td><td>{r.type}</td><td>{anim.prenom if anim else '-'}</td><td>{r.theme or '-'}</td><td>{annonces_str}</td></tr>"
html_content = f"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
<div class="header">
<div class="groupe">{groupe.nom if groupe else 'Groupe'}</div>
<div class="sub">District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}</div>
</div>
<h1>Réunions — {MOIS_FR[mois]} {annee}</h1>
<table><tr><th>Date</th><th>Type</th><th>Animateur</th><th>Thème</th><th>Annonces</th></tr>
{rows if rows else '<tr><td colspan="5">Aucune réunion enregistrée ce mois</td></tr>'}
</table>
<p class="total">{len(reunions)} réunion(s) enregistrée(s)</p>
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
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),
}

View file

@ -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)

View file

@ -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,
)

View file

@ -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"}

View file

@ -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)}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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}

View file

@ -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},
]

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -1,37 +0,0 @@
<VirtualHost *:80>
ServerName groupe-meditation.87-16.org
DocumentRoot /var/www/groupe-meditation
<Directory /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]
</Directory>
ProxyPreserveHost On
ProxyRequests Off
ProxyPass /api http://127.0.0.1:8000/api
ProxyPassReverse /api http://127.0.0.1:8000/api
<LocationMatch "\.(js|css|png|jpg|jpeg|svg|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
<LocationMatch "^/index\.html$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
</LocationMatch>
ErrorLog ${APACHE_LOG_DIR}/groupe-meditation-error.log
CustomLog ${APACHE_LOG_DIR}/groupe-meditation-access.log combined
</VirtualHost>

View file

@ -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"

View file

@ -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

View file

@ -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;
}

View file

@ -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())

View file

@ -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

View file

@ -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*

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a5276" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Groupe Méditation</title>
</head>
<body class="bg-gray-50">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View file

@ -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"
}
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" fill="#1a5276"/>
<text x="50" y="65" font-size="50" text-anchor="middle" fill="white">☮</text>
</svg>

Before

Width:  |  Height:  |  Size: 202 B

View file

@ -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 <Accueil />;
case 'profil': return <Profil />;
case 'S1': return <Collecte />;
case 'S4': return <RapportReunion />;
case 'S5': return <Depense />;
case 'C1': return <Rapports />;
case 'C2': return <Calendrier />;
case 'C5': return <Decisions />;
case 'G1': return <InviterMembre />;
case 'G2': return <GererMembres />;
case 'G3': return <AffecterPostes />;
case 'G4': return <TerminerMandat />;
case 'G5': return <PlanifierRotations />;
case 'G9': return <Parametres />;
case 'M1': return <RedigerPV />;
case 'M2': return <Presences />;
case 'M3': return <RapportRSG />;
case 'S2': return <RemiseJeton />;
case 'C3': return <Anniversaires />;
case 'M6': return <CommanderJetons />;
case 'S3': return <VenteLitterature />;
case 'C4': return <Inventaire />;
case 'M5': return <AjusterInventaire />;
case 'M4': return <EnvoiContributions />;
case 'G8': return <ConfigRepartition />;
case 'G6': return <LancerSondage />;
case 'C6': return <VotesOuverts />;
case 'G7': return <JumelerParrain />;
default: return <Accueil />;
}
}
// Pages publiques
switch (page) {
case 'inscription': return <Inscription />;
default: return <Login />;
}
}

View file

@ -1,15 +0,0 @@
import React from 'react';
import { useStore } from '../stores/store';
export default function PageHeader({ titre, sousTitre }) {
const { setPage } = useStore();
return (
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
<button onClick={() => setPage('accueil')} className="text-2xl active:opacity-60">←</button>
<div>
<h1 className="text-lg font-bold">{titre}</h1>
{sousTitre && <p className="text-blue-200 text-xs">{sousTitre}</p>}
</div>
</div>
);
}

View file

@ -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 (
<div className="fixed inset-0 bg-white/95 flex flex-col items-center justify-center z-50">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mb-4"
style={{ animation: 'scaleIn 0.3s ease-out' }}>
<span className="text-4xl">✓</span>
</div>
<p className="text-green-700 font-bold text-lg">{message}</p>
</div>
);
}

View file

@ -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; }
}

View file

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -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 (
<button
onClick={onClick}
className={`w-full p-4 rounded-2xl border-2 text-left active:scale-[0.97] transition ${cls}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{module.icone}</span>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">{module.nom}</p>
<p className="text-xs opacity-70 truncate">{module.description}</p>
</div>
<span className="text-xs font-mono opacity-40">{module.code}</span>
</div>
</button>
);
}
function Section({ titre, modules }) {
if (!modules || modules.length === 0) return null;
return (
<div className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">{titre}</h2>
<div className="grid gap-2">
{modules.map((m) => (
<Tuile key={m.code} module={m} onClick={() => {
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)`);
}} />
))}
</div>
</div>
);
}
export default function Accueil() {
const { membre, postes, modulesConsultation, modulesSaisie, modulesGestion, logout } = useStore();
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<div className="bg-aa-blue text-white p-4 pb-6 rounded-b-3xl shadow-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-200 text-xs">Bonjour</p>
<h1 className="text-xl font-bold">{membre?.prenom}</h1>
</div>
<button onClick={logout} className="text-blue-200 text-sm active:text-white">
Déconnexion
</button>
</div>
{postes.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{postes.map((p) => (
<span key={p} className="bg-white/20 text-white text-xs px-3 py-1 rounded-full">
{p}
</span>
))}
</div>
)}
{/* Lien aa87.org */}
<a href="https://aa87.org" target="_blank" rel="noopener noreferrer"
className="inline-block mt-3 bg-white/10 text-blue-100 text-xs px-3 py-2 rounded-lg active:bg-white/20">
📅 Événements du district →
</a>
</div>
{/* Modules */}
<div className="p-4 space-y-6 pb-20">
<Section titre="Mes tâches" modules={modulesSaisie} />
<Section titre="Consultation" modules={modulesConsultation} />
<Section titre="Gestion" modules={modulesGestion} />
</div>
</div>
);
}

View file

@ -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 <SuccessCheck message="Poste affecté ✓" />;
const CATEGORIES = { executif: '🔵', service: '🟣', physique: '🟢' };
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Affecter les postes" sousTitre="G3 — Assigner un serviteur" />
<div className="p-4 space-y-4">
{/* Affectations actuelles */}
{affectations.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">Affectations actives</h2>
<div className="space-y-2">
{affectations.map((a) => (
<div key={a.id} className="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
<div>
<p className="font-bold text-sm text-gray-800">{CATEGORIES[a.poste_categorie] || '⚪'} {a.poste_nom}</p>
<p className="text-xs text-gray-400">{a.membre_prenom} · depuis {a.date_debut}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Nouvelle affectation */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Nouvelle affectation</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Membre</label>
<select value={form.membre_id} onChange={(e) => setForm({ ...form, membre_id: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Sélectionner un membre</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Poste</label>
<select value={form.poste_id} onChange={(e) => setForm({ ...form, poste_id: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Sélectionner un poste</option>
{postes.map((p) => (
<option key={p.id} value={p.id}>{CATEGORIES[p.categorie] || '⚪'} {p.nom}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Date de début</label>
<input type="date" value={form.date_debut}
onChange={(e) => 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" />
</div>
</div>
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button onClick={handleSubmit}
disabled={!form.membre_id || !form.poste_id}
className="w-full p-5 bg-purple-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
🎯 Affecter le poste
</button>
</div>
</div>
);
}

View file

@ -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 <SuccessCheck message={success} delai={1000} />;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Ajuster inventaire" sousTitre="M5 — Stock littérature" />
<div className="p-4 space-y-4">
{/* Bouton ajouter un titre */}
<button
onClick={() => setShowAjout(!showAjout)}
className="w-full p-3 bg-purple-100 text-purple-700 rounded-2xl text-sm font-bold active:bg-purple-200 transition"
>
{showAjout ? '✕ Annuler' : '+ Ajouter un titre au catalogue'}
</button>
{showAjout && <FormAjoutTitre onSuccess={() => { setShowAjout(false); charger(); }} />}
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
{/* Liste des titres avec ajustement */}
{items.map((item) => (
<ItemStock key={item.id} item={item} onAjuster={ajusterStock} />
))}
{!loading && items.length === 0 && (
<div className="text-center py-12">
<span className="text-4xl block mb-2">📦</span>
<p className="text-gray-400">Aucun titre dans le catalogue</p>
</div>
)}
</div>
</div>
);
}
function ItemStock({ item, onAjuster }) {
const [ajustement, setAjustement] = useState('');
return (
<div className={`bg-white rounded-2xl p-4 shadow-sm border-2 ${
item.stock_bas ? 'border-orange-300' : 'border-gray-100'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{item.titre}</p>
<p className="text-xs text-gray-400">{item.categorie}</p>
</div>
<div className="text-center">
<p className={`text-2xl font-bold ${item.stock_bas ? 'text-orange-600' : 'text-gray-700'}`}>
{item.quantite_stock}
</p>
<p className="text-xs text-gray-400">en stock</p>
</div>
</div>
{/* Ajustement rapide */}
<div className="flex gap-2">
<button
onClick={() => onAjuster(item.id, -1)}
className="w-12 h-12 bg-red-100 text-red-700 rounded-xl text-xl font-bold active:bg-red-200"
>
−
</button>
<div className="flex-1 flex gap-1">
<input
type="number"
inputMode="numeric"
placeholder="+5"
value={ajustement}
onChange={(e) => 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"
/>
<button
onClick={() => {
if (ajustement && parseInt(ajustement) !== 0) {
onAjuster(item.id, parseInt(ajustement));
setAjustement('');
}
}}
disabled={!ajustement}
className="px-4 bg-purple-600 text-white rounded-xl text-sm font-bold disabled:opacity-40 active:bg-purple-700"
>
OK
</button>
</div>
<button
onClick={() => onAjuster(item.id, 1)}
className="w-12 h-12 bg-green-100 text-green-700 rounded-xl text-xl font-bold active:bg-green-200"
>
+
</button>
</div>
</div>
);
}
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 (
<div className="bg-purple-50 rounded-2xl p-4 space-y-3 border-2 border-purple-200">
<input
type="text"
placeholder="Titre (ex: Gros Livre, 12x12...)"
value={form.titre}
onChange={(e) => update('titre', e.target.value)}
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400"
autoFocus
/>
<div className="grid grid-cols-2 gap-2">
{CATEGORIES.map((c) => (
<button
key={c.value}
type="button"
onClick={() => update('categorie', c.value)}
className={`p-2 rounded-xl text-xs font-bold border-2 ${
form.categorie === c.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-600 border-gray-200'
}`}
>
{c.emoji} {c.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 block mb-1">Prix coûtant ($)</label>
<input
type="number" inputMode="decimal" step="0.01" placeholder="0.00"
value={form.prix_coutant} onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Stock initial</label>
<input
type="number" inputMode="numeric" min="0" placeholder="0"
value={form.quantite_stock} onChange={(e) => 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"
/>
</div>
</div>
<input
type="text" placeholder="Code BSG (optionnel)"
value={form.code_bsg} onChange={(e) => 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 && <p className="text-red-500 text-xs">{error}</p>}
<button
onClick={handleSubmit}
disabled={loading || !form.titre.trim()}
className="w-full p-3 bg-purple-600 text-white rounded-xl font-bold disabled:opacity-40 active:scale-[0.97] transition"
>
{loading ? '...' : '📚 Ajouter au catalogue'}
</button>
</div>
);
}

View file

@ -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 (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Anniversaires" sousTitre="C3 — Sobriété du district 87-16" />
<div className="p-4 space-y-4">
{/* Filtres */}
<div className="flex gap-2">
{[30, 90, 365].map((j) => (
<button
key={j}
onClick={() => setFiltre(j)}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition border-2 ${
filtre === j
? 'bg-aa-blue text-white border-aa-blue'
: 'bg-white text-gray-500 border-gray-200'
}`}
>
{j === 30 ? '30 jours' : j === 90 ? '3 mois' : '1 an'}
</button>
))}
</div>
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
{/* Aujourd'hui */}
{aujourdhui.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-bold text-aa-gold uppercase tracking-wider px-1">
🎉 Aujourd'hui
</h2>
{aujourdhui.map((a, i) => (
<AnniversaireCard key={i} anniversaire={a} emoji={emojiAnnees(a.annees_sobriete)} highlight />
))}
</div>
)}
{/* Prochains */}
{prochains.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
Prochains anniversaires
</h2>
{prochains.map((a, i) => (
<AnniversaireCard key={i} anniversaire={a} emoji={emojiAnnees(a.annees_sobriete)} />
))}
</div>
)}
{!loading && anniversaires.length === 0 && (
<div className="text-center py-12">
<span className="text-4xl block mb-2">🎂</span>
<p className="text-gray-400">Aucun anniversaire dans les prochains {filtre} jours</p>
</div>
)}
</div>
</div>
);
}
function AnniversaireCard({ anniversaire: a, emoji, highlight }) {
return (
<div className={`rounded-2xl p-4 shadow-sm border-2 ${
highlight
? 'bg-yellow-50 border-aa-gold'
: 'bg-white border-gray-100'
}`}>
<div className="flex items-center gap-3">
<span className="text-3xl">{emoji}</span>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">
{a.membre_prenom}
<span className="text-aa-gold ml-1">— {a.annees_sobriete} an{a.annees_sobriete > 1 ? 's' : ''}</span>
</p>
<p className="text-xs text-gray-400">{a.groupe_nom}</p>
</div>
<div className="text-right">
{a.jours_restants === 0 ? (
<span className="bg-aa-gold text-white text-xs px-2 py-1 rounded-full font-bold">
Aujourd'hui!
</span>
) : (
<span className="text-xs text-gray-400">
dans {a.jours_restants} j
</span>
)}
</div>
</div>
<p className="text-xs text-gray-300 mt-1 ml-12">
{a.prochain_anniversaire}
</p>
</div>
);
}

View file

@ -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 (
<div className="min-h-screen bg-gray-100">
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
<button onClick={() => setDetailGroupe(null)} className="text-2xl active:opacity-60">←</button>
<div>
<h1 className="text-lg font-bold">{detailGroupe.groupe?.nom}</h1>
<p className="text-blue-200 text-xs">
{detailGroupe.groupe?.jour_reunion ? `Réunion le ${detailGroupe.groupe.jour_reunion}` : 'Calendrier'}
</p>
</div>
</div>
<div className="p-4 space-y-2">
{detailGroupe.groupe?.adresse && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-400">Adresse</p>
<p className="text-gray-700">{detailGroupe.groupe.adresse}</p>
</div>
)}
{detailGroupe.serviteurs?.map((s, i) => {
const cat = CATEGORIES[s.categorie] || CATEGORIES.physique;
return (
<div key={i} className="bg-white rounded-2xl p-4 shadow-sm flex items-center justify-between">
<div>
<p className="font-bold text-gray-800">{s.poste}</p>
<p className="text-sm text-gray-500">{s.prenom}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-bold ${cat.bg} ${cat.text}`}>
{cat.label}
</span>
</div>
);
})}
{(!detailGroupe.serviteurs || detailGroupe.serviteurs.length === 0) && (
<p className="text-center text-gray-400 py-8">Aucun serviteur assigné</p>
)}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Calendrier district" sousTitre="C2 — Serviteurs du district 87-16" />
<div className="p-4 space-y-4">
{loading ? (
<p className="text-center text-gray-400 py-8">Chargement...</p>
) : groupes.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📅</p>
<p className="text-gray-500">Aucune donnée de calendrier</p>
</div>
) : (
groupes.map((g) => {
const isMon = g.id === groupeId;
return (
<button
key={g.id}
onClick={() => voirDetail(g.id)}
className={`w-full text-left rounded-2xl p-4 shadow-sm active:scale-[0.98] transition ${
isMon ? 'bg-blue-50 border-2 border-blue-300' : 'bg-white border border-gray-100'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{isMon && <span className="text-yellow-500">⭐</span>}
<h3 className="font-bold text-gray-800">{g.nom}</h3>
</div>
<span className="text-gray-300 text-lg">→</span>
</div>
{g.jour && (
<p className="text-xs text-gray-400 mb-2">Réunion le {g.jour}</p>
)}
<div className="flex flex-wrap gap-1">
{g.serviteurs.slice(0, 4).map((s, i) => (
<span key={i} className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
{s.poste_nom}: {s.membre_prenom}
</span>
))}
{g.serviteurs.length > 4 && (
<span className="text-xs text-gray-400 px-2 py-1">+{g.serviteurs.length - 4}</span>
)}
</div>
</button>
);
})
)}
</div>
</div>
);
}

View file

@ -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 <SuccessCheck message="Collecte enregistrée ✓" />;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Collecte 7e Tradition" sousTitre="S1 — Saisir le panier" />
<div className="p-4 space-y-4">
{/* Date pré-remplie */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input
type="date"
value={dateCollecte}
onChange={(e) => 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"
/>
</div>
{/* Montant — gros clavier numérique */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Montant collecté</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl text-gray-400">$</span>
<input
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="0.00"
value={montant}
onChange={(e) => 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
/>
</div>
</div>
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
{/* Bouton enregistrer */}
<button
onClick={handleSubmit}
disabled={loading || !montant}
className="w-full p-5 bg-green-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"
>
{loading ? '...' : '💰 Enregistrer la collecte'}
</button>
</div>
</div>
);
}

View file

@ -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 (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Commander jetons" sousTitre="M6 — Stock et commande Intergroupe" />
<div className="p-4 space-y-4">
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
{/* Alerte stock bas */}
{aCommander.length > 0 && (
<div className="bg-orange-50 border-2 border-orange-300 rounded-2xl p-4">
<p className="font-bold text-orange-700 text-sm mb-2">
⚠️ Jetons à commander ({aCommander.length})
</p>
<div className="space-y-1">
{aCommander.map((s) => (
<p key={s.type_jeton} className="text-sm text-orange-600">
{s.label} — {s.remis_dernier_mois} remis ce mois
</p>
))}
</div>
</div>
)}
{/* Vue complète du stock */}
{!loading && (
<div className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
Utilisation (30 derniers jours)
</h2>
{stock.map((s) => (
<div
key={s.type_jeton}
className={`bg-white rounded-2xl p-4 shadow-sm flex items-center gap-3 border-2 ${
s.stock_suggestion !== 'OK' ? 'border-orange-300' : 'border-gray-100'
}`}
>
<span className="text-2xl">{s.label.split(' ')[0]}</span>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">{s.label.split(' ').slice(1).join(' ')}</p>
<p className="text-xs text-gray-400">{s.remis_dernier_mois} remis</p>
</div>
<span className={`text-xs font-bold px-3 py-1 rounded-full ${
s.stock_suggestion === 'OK'
? 'bg-green-100 text-green-700'
: 'bg-orange-100 text-orange-700'
}`}>
{s.stock_suggestion}
</span>
</div>
))}
</div>
)}
{!loading && stock.length === 0 && (
<div className="text-center py-12">
<span className="text-4xl block mb-2">🏅</span>
<p className="text-gray-400">Aucune donnée de jetons pour le moment</p>
</div>
)}
{/* Info */}
<div className="bg-blue-50 rounded-2xl p-4 mt-4">
<p className="text-xs text-blue-600">
💡 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.
</p>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more