diff --git a/groupe-meditation-corrige/groupe-meditation/README.md b/groupe-meditation-corrige/groupe-meditation/README.md new file mode 100644 index 0000000..32270ec --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/README.md @@ -0,0 +1,67 @@ +# 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* diff --git a/groupe-meditation-corrige/groupe-meditation/REVISION_CORRECTIVE.txt b/groupe-meditation-corrige/groupe-meditation/REVISION_CORRECTIVE.txt new file mode 100644 index 0000000..43a2a90 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/REVISION_CORRECTIVE.txt @@ -0,0 +1,33 @@ +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 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/.env.example b/groupe-meditation-corrige/groupe-meditation/backend/.env.example new file mode 100644 index 0000000..d1fb352 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/.env.example @@ -0,0 +1,7 @@ +# 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 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/__init__.py b/groupe-meditation-corrige/groupe-meditation/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/core/__init__.py b/groupe-meditation-corrige/groupe-meditation/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/core/config.py b/groupe-meditation-corrige/groupe-meditation/backend/app/core/config.py new file mode 100644 index 0000000..e36a68d --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/core/config.py @@ -0,0 +1,30 @@ +"""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() diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/core/database.py b/groupe-meditation-corrige/groupe-meditation/backend/app/core/database.py new file mode 100644 index 0000000..f73f377 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/core/database.py @@ -0,0 +1,21 @@ +"""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 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/core/security.py b/groupe-meditation-corrige/groupe-meditation/backend/app/core/security.py new file mode 100644 index 0000000..0c95e97 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/core/security.py @@ -0,0 +1,68 @@ +"""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 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/main.py b/groupe-meditation-corrige/groupe-meditation/backend/app/main.py new file mode 100644 index 0000000..9ebe70b --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/main.py @@ -0,0 +1,68 @@ +""" +Groupe Méditation — API Backend +District 87-16, Région 87 +100% logiciel libre · Hébergé au Québec +""" +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.database import engine, Base +from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, jumelages + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + await engine.dispose() + + +app = FastAPI( + title="Groupe Méditation API", + description="API du Groupe Méditation — District 87-16, Région 87", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Montage des routes +app.include_router(auth.router, prefix="/api") +app.include_router(membres.router, prefix="/api") +app.include_router(postes.router, prefix="/api") +app.include_router(accueil.router, prefix="/api") +app.include_router(invitations.router, prefix="/api") +app.include_router(collectes.router, prefix="/api") +app.include_router(reunions.router, prefix="/api") +app.include_router(depenses.router, prefix="/api") +app.include_router(decisions.router, prefix="/api") +app.include_router(calendrier.router, prefix="/api") +app.include_router(rapports.router, prefix="/api") +app.include_router(rotations.router, prefix="/api") +app.include_router(parametres.router, prefix="/api") +app.include_router(presences.router, prefix="/api") +app.include_router(pv.router, prefix="/api") +app.include_router(rapports_rsg.router, prefix="/api") +app.include_router(jetons.router, prefix="/api") +app.include_router(anniversaires.router, prefix="/api") +app.include_router(commandes_jetons.router, prefix="/api") +app.include_router(litterature.router, prefix="/api") +app.include_router(ventes_litterature.router, prefix="/api") +app.include_router(contributions.router, prefix="/api") +app.include_router(config_repartition.router, prefix="/api") +app.include_router(sondages.router, prefix="/api") +app.include_router(jumelages.router, prefix="/api") + + +@app.get("/api/health") +async def root_health(): + return {"status": "ok", "app": "Groupe Méditation", "version": "0.1.0"} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/__init__.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/affectation.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/affectation.py new file mode 100644 index 0000000..c18612e --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/affectation.py @@ -0,0 +1,23 @@ +"""Table affectations.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Date, Text, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class Affectation(Base): + __tablename__ = "affectations" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False) + date_debut: Mapped[date] = mapped_column(Date, nullable=False) + date_fin: Mapped[date | None] = mapped_column(Date) + statut: Mapped[str] = mapped_column(SAEnum("actif", "termine", name="statut_affectation"), default="actif") + note_transition: Mapped[str | None] = mapped_column(Text) + termine_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + + membre = relationship("Membre", back_populates="affectations", foreign_keys=[membre_id]) + poste = relationship("Poste") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/collecte.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/collecte.py new file mode 100644 index 0000000..f209660 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/collecte.py @@ -0,0 +1,18 @@ +"""Table collectes — 7e Tradition.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import Date, DateTime, ForeignKey, Numeric +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Collecte(Base): + __tablename__ = "collectes" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + date_collecte: Mapped[date] = mapped_column(Date, nullable=False) + montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id")) + saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/config_repartition.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/config_repartition.py new file mode 100644 index 0000000..db5040e --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/config_repartition.py @@ -0,0 +1,19 @@ +"""Table config_repartition — % de répartition des contributions.""" +import uuid +from datetime import date +from sqlalchemy import Date, ForeignKey, Numeric, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class ConfigRepartition(Base): + __tablename__ = "config_repartition" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + destination: Mapped[str] = mapped_column( + SAEnum("district", "region", "bsg", "intergroupe", name="destination_repartition", create_constraint=False), + nullable=False, + ) + pourcentage: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False) + actif_depuis: Mapped[date] = mapped_column(Date, nullable=False) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/decision.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/decision.py new file mode 100644 index 0000000..0f1ee4a --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/decision.py @@ -0,0 +1,27 @@ +"""Table decisions — suivi des décisions de réunion d'affaires.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Decision(Base): + __tablename__ = "decisions" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id")) + sujet: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + statut: Mapped[str] = mapped_column( + SAEnum("en_attente", "en_cours", "complete", name="statut_decision"), + default="en_attente", + ) + responsable_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + proposeur: Mapped[str | None] = mapped_column(String(100)) + secondeur: Mapped[str | None] = mapped_column(String(100)) + date_decision: Mapped[date] = mapped_column(Date, nullable=False) + date_completion: Mapped[date | None] = mapped_column(Date) + cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/depense.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/depense.py new file mode 100644 index 0000000..1dd66b6 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/depense.py @@ -0,0 +1,24 @@ +"""Table depenses — 6 champs selon l'architecture v4.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Depense(Base): + __tablename__ = "depenses" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + date_depense: Mapped[date] = mapped_column(Date, nullable=False) + montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + qui: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + quoi: Mapped[str] = mapped_column(String(255), nullable=False) + paye: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + methode: Mapped[str] = mapped_column( + SAEnum("DU", "VIREMENT", "CHEQUE", "COLLECTE", name="methode_paiement"), + nullable=False, + ) + saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/envoi_contribution.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/envoi_contribution.py new file mode 100644 index 0000000..792f135 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/envoi_contribution.py @@ -0,0 +1,25 @@ +"""Table envois_contributions — Envois aux organismes AA.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import Date, DateTime, ForeignKey, Numeric, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class EnvoiContribution(Base): + __tablename__ = "envois_contributions" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + date_envoi: Mapped[date] = mapped_column(Date, nullable=False) + destination: Mapped[str] = mapped_column( + SAEnum("district", "region", "bsg", "intergroupe", name="destination_contribution"), + nullable=False, + ) + montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + methode: Mapped[str] = mapped_column( + SAEnum("DU", "VIREMENT", "CHEQUE", name="methode_contribution"), + nullable=False, + ) + saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/groupe.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/groupe.py new file mode 100644 index 0000000..abebad4 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/groupe.py @@ -0,0 +1,23 @@ +"""Table groupes.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, Text, Time, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class Groupe(Base): + __tablename__ = "groupes" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + nom: Mapped[str] = mapped_column(String(100), nullable=False) + district: Mapped[str] = mapped_column(String(20), default="87-16") + region: Mapped[str] = mapped_column(String(20), default="87") + adresse_local: Mapped[str | None] = mapped_column(Text) + jour_reunion: Mapped[str | None] = mapped_column(String(10)) + heure_reunion: Mapped[datetime | None] = mapped_column(Time) + format_reunion: Mapped[str | None] = mapped_column(Text) + lien_aa87: Mapped[str] = mapped_column(String(255), default="https://aa87.org") + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + membres = relationship("Membre", back_populates="groupe") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/invitation.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/invitation.py new file mode 100644 index 0000000..a1ac47d --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/invitation.py @@ -0,0 +1,18 @@ +"""Table invitations.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Invitation(Base): + __tablename__ = "invitations" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + code: Mapped[str] = mapped_column(String(12), unique=True, nullable=False) + cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + utilise_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + expire_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + utilise: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/jeton.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/jeton.py new file mode 100644 index 0000000..832682d --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/jeton.py @@ -0,0 +1,26 @@ +"""Table jetons — Remise de jetons de sobriété.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Integer, Date, DateTime, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Jeton(Base): + __tablename__ = "jetons" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + type_jeton: Mapped[str] = mapped_column( + SAEnum( + "desir", "24h", "1mois", "2mois", "3mois", + "6mois", "9mois", "1an", "multiple", + name="type_jeton", + ), + nullable=False, + ) + date_remise: Mapped[date] = mapped_column(Date, nullable=False) + annees: Mapped[int | None] = mapped_column(Integer) # pour type_jeton='multiple' + remis_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/jumelage.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/jumelage.py new file mode 100644 index 0000000..437f635 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/jumelage.py @@ -0,0 +1,23 @@ +"""Table jumelages — Parrainage discret.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Date, DateTime, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Jumelage(Base): + __tablename__ = "jumelages" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + parrain_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + filleul_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + date_jumelage: Mapped[date] = mapped_column(Date, nullable=False) + statut: Mapped[str] = mapped_column( + SAEnum("actif", "termine", name="statut_jumelage"), + default="actif", + ) + note: Mapped[str | None] = mapped_column(String(255)) + cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/litterature.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/litterature.py new file mode 100644 index 0000000..e39967b --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/litterature.py @@ -0,0 +1,21 @@ +"""Table litterature — Inventaire AA.""" +import uuid +from sqlalchemy import String, Integer, Numeric, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Litterature(Base): + __tablename__ = "litterature" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + titre: Mapped[str] = mapped_column(String(200), nullable=False) + categorie: Mapped[str] = mapped_column( + SAEnum("livre", "brochure", "depliant", "pamphlet", name="categorie_litterature"), + nullable=False, + ) + prix_coutant: Mapped[float | None] = mapped_column(Numeric(8, 2)) + quantite_stock: Mapped[int] = mapped_column(Integer, default=0) + seuil_commande: Mapped[int] = mapped_column(Integer, default=2) + code_bsg: Mapped[str | None] = mapped_column(String(20)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/membre.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/membre.py new file mode 100644 index 0000000..e26345d --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/membre.py @@ -0,0 +1,26 @@ +"""Table membres.""" +import uuid +from datetime import datetime, date, timezone +from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class Membre(Base): + __tablename__ = "membres" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + prenom: Mapped[str] = mapped_column(String(50), nullable=False) + nom: Mapped[str] = mapped_column(String(50), nullable=False) + telephone: Mapped[str | None] = mapped_column(String(20)) + courriel: Mapped[str | None] = mapped_column(String(100)) + date_abstinence: Mapped[date | None] = mapped_column(Date) + pin_hash: Mapped[str] = mapped_column(String(255), nullable=False) + actif: Mapped[bool] = mapped_column(Boolean, default=True) + push_sub: Mapped[dict | None] = mapped_column(JSON) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + groupe = relationship("Groupe", back_populates="membres") + affectations = relationship("Affectation", back_populates="membre", foreign_keys="Affectation.membre_id") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/option_sondage.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/option_sondage.py new file mode 100644 index 0000000..77b9fc1 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/option_sondage.py @@ -0,0 +1,16 @@ +"""Table options_sondage — Options de vote.""" +import uuid +from sqlalchemy import String, Integer, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class OptionSondage(Base): + __tablename__ = "options_sondage" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False) + texte: Mapped[str] = mapped_column(String(200), nullable=False) + ordre: Mapped[int] = mapped_column(Integer, default=0) + + sondage = relationship("Sondage", back_populates="options") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/poste.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/poste.py new file mode 100644 index 0000000..ad09b8b --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/poste.py @@ -0,0 +1,17 @@ +"""Table postes.""" +import uuid +from sqlalchemy import String, Text, Integer, Interval, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Poste(Base): + __tablename__ = "postes" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + nom: Mapped[str] = mapped_column(String(100), nullable=False) + categorie: Mapped[str] = mapped_column(SAEnum("executif", "service", "physique", name="categorie_poste"), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + duree_recommandee: Mapped[str | None] = mapped_column(Interval) + prerequis: Mapped[str | None] = mapped_column(Text) + ordre_affichage: Mapped[int | None] = mapped_column(Integer) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/presence.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/presence.py new file mode 100644 index 0000000..473746a --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/presence.py @@ -0,0 +1,14 @@ +"""Table presences — réunions d'affaires seulement.""" +import uuid +from sqlalchemy import Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Presence(Base): + __tablename__ = "presences" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + reunion_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reunions.id"), nullable=False) + membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + present: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/pv_reunion.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/pv_reunion.py new file mode 100644 index 0000000..87aca74 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/pv_reunion.py @@ -0,0 +1,18 @@ +"""Table pv_reunions — procès-verbaux des réunions d'affaires.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import Text, Boolean, DateTime, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class PvReunion(Base): + __tablename__ = "pv_reunions" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + reunion_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reunions.id"), nullable=False) + redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + contenu: Mapped[str] = mapped_column(Text, nullable=False) + decisions: Mapped[list | None] = mapped_column(JSON) # [{sujet, decision, proposeur, secondeur, statut}] + approuve: Mapped[bool] = mapped_column(Boolean, default=False) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/rapport_rsg.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/rapport_rsg.py new file mode 100644 index 0000000..4961370 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/rapport_rsg.py @@ -0,0 +1,19 @@ +"""Table rapports_rsg — compte-rendus de réunion de district.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import Date, DateTime, Text, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class RapportRsg(Base): + __tablename__ = "rapports_rsg" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + reunion_dist: Mapped[date] = mapped_column(Date, nullable=False) + redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + decisions: Mapped[list | None] = mapped_column(JSON) + infos_region: Mapped[str | None] = mapped_column(Text) + sujets_groupe: Mapped[str | None] = mapped_column(Text) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/reunion.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/reunion.py new file mode 100644 index 0000000..4ec65bc --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/reunion.py @@ -0,0 +1,20 @@ +"""Table reunions.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, Text, ForeignKey, JSON, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Reunion(Base): + __tablename__ = "reunions" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + type: Mapped[str] = mapped_column(SAEnum("reguliere", "affaires", name="type_reunion"), nullable=False) + date_reunion: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + animateur_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + theme: Mapped[str | None] = mapped_column(String(200)) + annonces: Mapped[dict | None] = mapped_column(JSON) + note: Mapped[str | None] = mapped_column(Text) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/rotation.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/rotation.py new file mode 100644 index 0000000..e6ef3bf --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/rotation.py @@ -0,0 +1,19 @@ +"""Table rotations — planification hebdomadaire des serviteurs.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Date, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Rotation(Base): + __tablename__ = "rotations" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + date_reunion: Mapped[date] = mapped_column(Date, nullable=False) + poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False) + membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + note: Mapped[str | None] = mapped_column(String(255)) + cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/sondage.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/sondage.py new file mode 100644 index 0000000..b89834e --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/sondage.py @@ -0,0 +1,29 @@ +"""Table sondages — Votes de conscience de groupe.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, Text, DateTime, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class Sondage(Base): + __tablename__ = "sondages" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + titre: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + type: Mapped[str] = mapped_column( + SAEnum("oui_non", "choix_multiple", "unanimite", name="type_sondage"), + nullable=False, + ) + cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + date_ouverture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + date_fermeture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + statut: Mapped[str] = mapped_column( + SAEnum("brouillon", "ouvert", "ferme", name="statut_sondage"), + default="brouillon", + ) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + options = relationship("OptionSondage", back_populates="sondage", cascade="all, delete-orphan") diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/sync_log.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/sync_log.py new file mode 100644 index 0000000..e17d2fa --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/sync_log.py @@ -0,0 +1,21 @@ +"""Table sync_log — Journal de synchronisation.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, BigInteger, DateTime, ForeignKey, JSON, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class SyncLog(Base): + __tablename__ = "sync_log" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + groupe_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("groupes.id")) + table_name: Mapped[str] = mapped_column(String(50), nullable=False) + record_id: Mapped[uuid.UUID] = mapped_column(nullable=False) + action: Mapped[str] = mapped_column( + SAEnum("insert", "update", "delete", name="sync_action"), + nullable=False, + ) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + data: Mapped[dict | None] = mapped_column(JSON) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/vente_litterature.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/vente_litterature.py new file mode 100644 index 0000000..4829d68 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/vente_litterature.py @@ -0,0 +1,19 @@ +"""Table ventes_litterature — Ventes de littérature AA.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import Integer, Date, DateTime, ForeignKey, Numeric +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class VenteLitterature(Base): + __tablename__ = "ventes_litterature" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + litterature_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("litterature.id"), nullable=False) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + quantite: Mapped[int] = mapped_column(Integer, nullable=False) + montant: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False) + date_vente: Mapped[date] = mapped_column(Date, nullable=False) + vendu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/models/vote.py b/groupe-meditation-corrige/groupe-meditation/backend/app/models/vote.py new file mode 100644 index 0000000..e04d850 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/models/vote.py @@ -0,0 +1,16 @@ +"""Table votes — Votes anonymes.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Vote(Base): + __tablename__ = "votes" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False) + option_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("options_sondage.id", ondelete="CASCADE"), nullable=False) + membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + vote_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/__init__.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/accueil.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/accueil.py new file mode 100644 index 0000000..23ca035 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/accueil.py @@ -0,0 +1,112 @@ +"""Route accueil — écran dynamique par rôle.""" +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.schemas import AccueilResponse, ModuleTuile, MembrePublic + +router = APIRouter(prefix="/accueil", tags=["Accueil"]) + +# ── Définition des modules ── +MODULES_CONSULTATION = [ + ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, rapport RSG", icone="📄", couleur="blue"), + ModuleTuile(code="C2", nom="Calendrier district", description="Serviteurs de tous les groupes", icone="📅", couleur="green"), + ModuleTuile(code="C3", nom="Anniversaires", description="Sobriété du district 87-16", icone="🎂", couleur="yellow"), + ModuleTuile(code="C4", nom="Littérature", description="Inventaire et prix", icone="📚", couleur="purple"), + ModuleTuile(code="C5", nom="Décisions en cours", description="Suivi réunion d'affaires", icone="📌", couleur="red"), + ModuleTuile(code="C6", nom="Votes ouverts", description="Conscience de groupe", icone="🗳️", couleur="indigo"), + ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"), +] + +# Modules de saisie par poste +MODULES_SAISIE = { + "Trésorier(ière)": [ + ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"), + ModuleTuile(code="S5", nom="Dépense", description="Enregistrer une dépense", icone="💸", couleur="red"), + ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"), + ], + "Secrétaire": [ + ModuleTuile(code="M1", nom="Rédiger PV", description="Procès-verbal", icone="📝", couleur="blue"), + ModuleTuile(code="M2", nom="Présences", description="Réunion d'affaires", icone="✅", couleur="green"), + ], + "RSG": [ + ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"), + ], + "RSG Substitut(e)": [ + ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"), + ], + "Animateur(trice)": [ + ModuleTuile(code="S4", nom="Rapport réunion", description="Thème et annonces", icone="🎤", couleur="purple"), + ], + "Responsable littérature": [ + ModuleTuile(code="S3", nom="Vente littérature", description="Enregistrer une vente", icone="📖", couleur="purple"), + ModuleTuile(code="M5", nom="Ajuster inventaire", description="Mettre à jour le stock", icone="📦", couleur="orange"), + ], + "Responsable jetons": [ + ModuleTuile(code="S2", nom="Remise jeton", description="Enregistrer un jeton", icone="🏅", couleur="yellow"), + ModuleTuile(code="M6", nom="Commander jetons", description="Commander à l'Intergroupe", icone="📦", couleur="orange"), + ], +} + +MODULES_GESTION = [ + ModuleTuile(code="G1", nom="Inviter un membre", description="Code d'invitation 48h", icone="➕", couleur="green"), + ModuleTuile(code="G2", nom="Gérer les membres", description="Fiche complète, désactiver", icone="👥", couleur="blue"), + ModuleTuile(code="G3", nom="Affecter les postes", description="Assigner un serviteur", icone="🎯", couleur="purple"), + ModuleTuile(code="G4", nom="Terminer un mandat", description="Transition manuelle", icone="🔄", couleur="orange"), + ModuleTuile(code="G5", nom="Planifier rotations", description="Calendrier serviteurs", icone="📅", couleur="green"), + ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"), + ModuleTuile(code="G7", nom="Jumeler parrain", description="Parrain / filleul", icone="🤝", couleur="yellow"), + ModuleTuile(code="G8", nom="Config. répartition", description="% contributions", icone="⚙️", couleur="gray"), + ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"), +] + + +@router.get("/", response_model=AccueilResponse) +async def accueil( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Écran d'accueil dynamique — retourne les modules selon les postes du membre.""" + # Récupérer les affectations actives + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + ) + ) + affectations = result.scalars().all() + + postes_noms = [] + postes_categories = [] + for a in affectations: + poste = await db.get(Poste, a.poste_id) + if poste: + postes_noms.append(poste.nom) + postes_categories.append(poste.categorie) + + # Modules de consultation : toujours visibles + modules_consultation = MODULES_CONSULTATION + + # Modules de saisie : union des modules de tous les postes + modules_saisie = [] + codes_vus = set() + for nom_poste in postes_noms: + for m in MODULES_SAISIE.get(nom_poste, []): + if m.code not in codes_vus: + modules_saisie.append(m) + codes_vus.add(m.code) + + # Modules de gestion : seulement si exécutif + modules_gestion = MODULES_GESTION if "executif" in postes_categories else [] + + return AccueilResponse( + membre=MembrePublic.model_validate(membre), + postes=postes_noms, + modules_consultation=modules_consultation, + modules_saisie=modules_saisie, + modules_gestion=modules_gestion, + ) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/anniversaires.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/anniversaires.py new file mode 100644 index 0000000..a50f8d2 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/anniversaires.py @@ -0,0 +1,89 @@ +"""Routes C3 — Anniversaires district 87-16.""" +from datetime import date, timedelta +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.membre import Membre +from app.models.groupe import Groupe +from app.schemas.phase5 import AnniversaireOut + +router = APIRouter(prefix="/anniversaires", tags=["C3 — Anniversaires district"]) + + +def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]: + """Calcule les années de sobriété, le prochain anniversaire et les jours restants.""" + annees = aujourdhui.year - date_abstinence.year + # Anniversaire cette année + try: + anniv_cette_annee = date_abstinence.replace(year=aujourdhui.year) + except ValueError: + # 29 février + anniv_cette_annee = date(aujourdhui.year, 3, 1) + + if anniv_cette_annee < aujourdhui: + # L'anniversaire de cette année est passé + try: + prochain = date_abstinence.replace(year=aujourdhui.year + 1) + except ValueError: + prochain = date(aujourdhui.year + 1, 3, 1) + annees_sobriete = annees + elif anniv_cette_annee == aujourdhui: + prochain = anniv_cette_annee + annees_sobriete = annees + else: + prochain = anniv_cette_annee + annees_sobriete = annees - 1 + + # Minimum 0 + annees_sobriete = max(0, annees_sobriete) + jours_restants = (prochain - aujourdhui).days + + return annees_sobriete, prochain, jours_restants + + +@router.get("/", response_model=list[AnniversaireOut]) +async def anniversaires_district( + prochains_jours: int = 90, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C3 — Anniversaires de sobriété de tous les groupes du district 87-16.""" + aujourdhui = date.today() + + # Récupérer le district du groupe du membre + groupe = await db.get(Groupe, membre.groupe_id) + district = groupe.district if groupe else "87-16" + + # Tous les membres actifs du district ayant une date d'abstinence + result = await db.execute( + select(Membre, Groupe.nom) + .join(Groupe, Membre.groupe_id == Groupe.id) + .where( + Groupe.district == district, + Membre.actif == True, + Membre.date_abstinence.isnot(None), + ) + ) + rows = result.all() + + anniversaires = [] + for m, groupe_nom in rows: + annees_sobriete, prochain, jours_restants = _calcul_anniversaire(m.date_abstinence, aujourdhui) + + # Filtrer : prochains X jours OU anniversaire aujourd'hui + if 0 <= jours_restants <= prochains_jours: + anniversaires.append(AnniversaireOut( + membre_prenom=m.prenom, + groupe_nom=groupe_nom, + date_abstinence=m.date_abstinence, + annees_sobriete=annees_sobriete + 1, # prochain anniversaire = +1 + prochain_anniversaire=prochain, + jours_restants=jours_restants, + )) + + # Trier par jours restants (prochains en premier) + anniversaires.sort(key=lambda a: a.jours_restants) + return anniversaires diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/auth.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/auth.py new file mode 100644 index 0000000..67bdcce --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/auth.py @@ -0,0 +1,107 @@ +"""Routes d'authentification.""" +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db +from app.core.security import hash_pin, verify_pin, create_token +from app.models.membre import Membre +from app.models.groupe import Groupe +from app.models.invitation import Invitation +from app.schemas.schemas import ConnexionRequest, InscriptionRequest, TokenResponse, MembrePublic + +router = APIRouter(prefix="/auth", tags=["Authentification"]) + + +@router.get("/health") +async def health(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Groupe).limit(1)) + groupe = result.scalar_one_or_none() + return { + "status": "ok", + "groupe": groupe.nom if groupe else "Aucun", + "district": groupe.district if groupe else "-", + } + + +@router.get("/groupes") +async def liste_groupes(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Groupe).order_by(Groupe.nom)) + groupes = result.scalars().all() + return [{"id": str(g.id), "nom": g.nom, "district": g.district} for g in groupes] + + +@router.post("/connexion", response_model=TokenResponse) +async def connexion(req: ConnexionRequest, db: AsyncSession = Depends(get_db)): + # Chercher le membre par prénom + groupe (insensible à la casse) + result = await db.execute( + select(Membre).where( + func.lower(Membre.prenom) == func.lower(req.prenom), + Membre.groupe_id == req.groupe_id, + Membre.actif == True, + ) + ) + membre = result.scalar_one_or_none() + if not membre or not verify_pin(req.pin, membre.pin_hash): + raise HTTPException(status_code=401, detail="Prénom ou PIN incorrect") + + token = create_token(str(membre.id), str(membre.groupe_id)) + return TokenResponse( + access_token=token, + membre=MembrePublic.model_validate(membre), + ) + + +@router.post("/inscription", response_model=TokenResponse) +async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)): + # Valider le code d'invitation + result = await db.execute( + select(Invitation).where( + Invitation.code == req.code_invitation, + Invitation.utilise == False, + ) + ) + invitation = result.scalar_one_or_none() + if not invitation: + raise HTTPException(status_code=400, detail="Code d'invitation invalide") + if invitation.expire_le < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Code d'invitation expiré") + + # Vérifier unicité du prénom dans le groupe + exists = await db.execute( + select(Membre).where( + func.lower(Membre.prenom) == func.lower(req.prenom), + Membre.groupe_id == invitation.groupe_id, + Membre.actif == True, + ) + ) + if exists.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Ce prénom est déjà utilisé dans ce groupe") + + # Valider le PIN (4 chiffres) + if not req.pin.isdigit() or len(req.pin) != 4: + raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres") + + # Créer le membre + membre = Membre( + groupe_id=invitation.groupe_id, + prenom=req.prenom.strip().title(), + nom=req.nom.strip().title(), + telephone=req.telephone, + courriel=req.courriel, + date_abstinence=req.date_abstinence, + pin_hash=hash_pin(req.pin), + ) + db.add(membre) + + # Marquer l'invitation utilisée + invitation.utilise = True + invitation.utilise_par = membre.id + await db.flush() + + token = create_token(str(membre.id), str(membre.groupe_id)) + return TokenResponse( + access_token=token, + membre=MembrePublic.model_validate(membre), + ) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/calendrier.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/calendrier.py new file mode 100644 index 0000000..c4ff35c --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/calendrier.py @@ -0,0 +1,102 @@ +"""Routes C2 — Calendrier district 87-16.""" +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.groupe import Groupe +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.models.membre import Membre +from app.schemas.phase2 import CalendrierEntry + +router = APIRouter(prefix="/calendrier", tags=["C2 — Calendrier district"]) + + +@router.get("/", response_model=list[CalendrierEntry]) +async def calendrier_district( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C2 — Calendrier des serviteurs de tous les groupes du district 87-16.""" + # Récupérer le district du membre + groupe = await db.get(Groupe, membre.groupe_id) + district = groupe.district if groupe else "87-16" + + # Tous les groupes du même district + result = await db.execute( + select(Groupe).where(Groupe.district == district).order_by(Groupe.nom) + ) + groupes = {g.id: g for g in result.scalars().all()} + + # Toutes les affectations actives de ces groupes + result = await db.execute( + select(Affectation).where( + Affectation.groupe_id.in_(groupes.keys()), + Affectation.statut == "actif", + ) + ) + affectations = result.scalars().all() + + entries = [] + for a in affectations: + poste = await db.get(Poste, a.poste_id) + mbr = await db.get(Membre, a.membre_id) + grp = groupes.get(a.groupe_id) + if poste and mbr and grp: + entries.append(CalendrierEntry( + groupe_nom=grp.nom, + groupe_id=grp.id, + jour_reunion=grp.jour_reunion, + poste_nom=poste.nom, + membre_prenom=mbr.prenom, + date_debut=a.date_debut, + )) + + # Trier par groupe puis par ordre de poste + entries.sort(key=lambda e: (e.groupe_nom, e.poste_nom)) + return entries + + +@router.get("/groupe/{groupe_id}") +async def calendrier_groupe( + groupe_id: str, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Détail des serviteurs d'un groupe spécifique.""" + groupe = await db.get(Groupe, groupe_id) + if not groupe: + return {"groupe": None, "serviteurs": []} + + result = await db.execute( + select(Affectation).where( + Affectation.groupe_id == groupe_id, + Affectation.statut == "actif", + ) + ) + affectations = result.scalars().all() + + serviteurs = [] + for a in affectations: + poste = await db.get(Poste, a.poste_id) + mbr = await db.get(Membre, a.membre_id) + if poste and mbr: + serviteurs.append({ + "poste": poste.nom, + "categorie": poste.categorie, + "prenom": mbr.prenom, + "depuis": str(a.date_debut), + }) + serviteurs.sort(key=lambda s: s.get("poste", "")) + + return { + "groupe": { + "id": str(groupe.id), + "nom": groupe.nom, + "jour_reunion": groupe.jour_reunion, + "adresse": groupe.adresse_local, + }, + "serviteurs": serviteurs, + } diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/collectes.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/collectes.py new file mode 100644 index 0000000..a849b1e --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/collectes.py @@ -0,0 +1,80 @@ +"""Routes S1 — Collecte 7e Tradition.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.collecte import Collecte +from app.models.membre import Membre +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase1 import CollecteCreate, CollecteOut + +router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"]) + + +async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): + """Vérifie que le membre est trésorier.""" + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + Poste.nom.ilike("%trésorier%"), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au trésorier") + return membre + + +@router.post("/", response_model=CollecteOut) +async def saisir_collecte( + req: CollecteCreate, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + """S1 — Saisir le montant collecté au panier.""" + collecte = Collecte( + groupe_id=membre.groupe_id, + date_collecte=req.date_collecte, + montant=req.montant, + reunion_id=req.reunion_id, + saisi_par=membre.id, + ) + db.add(collecte) + await db.flush() + return CollecteOut( + id=collecte.id, + date_collecte=collecte.date_collecte, + montant=float(collecte.montant), + saisi_par_prenom=membre.prenom, + cree_le=collecte.cree_le, + ) + + +@router.get("/", response_model=list[CollecteOut]) +async def liste_collectes( + limite: int = 20, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les collectes récentes du groupe.""" + result = await db.execute( + select(Collecte) + .where(Collecte.groupe_id == membre.groupe_id) + .order_by(desc(Collecte.date_collecte)) + .limit(limite) + ) + collectes = result.scalars().all() + out = [] + for c in collectes: + saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None + out.append(CollecteOut( + id=c.id, + date_collecte=c.date_collecte, + montant=float(c.montant), + saisi_par_prenom=saisi.prenom if saisi else None, + cree_le=c.cree_le, + )) + return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/commandes_jetons.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/commandes_jetons.py new file mode 100644 index 0000000..1e82e71 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/commandes_jetons.py @@ -0,0 +1,66 @@ +"""Routes M6 — Commander des jetons.""" +from datetime import date, timedelta +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.jeton import Jeton +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase5 import StockJetonsOut, TYPES_JETONS +from fastapi import HTTPException + +router = APIRouter(prefix="/commandes-jetons", tags=["M6 — Commander jetons"]) + + +async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): + """Vérifie que le membre est responsable des jetons.""" + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + Poste.nom.ilike("%jeton%"), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au responsable des jetons") + return membre + + +@router.get("/stock", response_model=list[StockJetonsOut]) +async def etat_stock_jetons( + membre=Depends(require_resp_jetons), + db: AsyncSession = Depends(get_db), +): + """M6 — État du stock de jetons (basé sur l'utilisation du dernier mois).""" + il_y_a_30_jours = date.today() - timedelta(days=30) + + # Compter les jetons remis par type dans les 30 derniers jours + result = await db.execute( + select(Jeton.type_jeton, func.count(Jeton.id)) + .where( + Jeton.groupe_id == membre.groupe_id, + Jeton.date_remise >= il_y_a_30_jours, + ) + .group_by(Jeton.type_jeton) + ) + usage = {row[0]: row[1] for row in result.all()} + + stock = [] + for t in TYPES_JETONS: + remis = usage.get(t["value"], 0) + # Suggestion simple : si plus de 2 remis dans le mois, suggérer commande + suggestion = "Bas — commander" if remis >= 2 else "OK" + # Le jeton de désir est toujours à surveiller (remis chaque semaine) + if t["value"] == "desir" and remis >= 4: + suggestion = "Bas — commander" + + stock.append(StockJetonsOut( + type_jeton=t["value"], + label=f"{t['emoji']} {t['label']}", + remis_dernier_mois=remis, + stock_suggestion=suggestion, + )) + return stock diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/config_repartition.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/config_repartition.py new file mode 100644 index 0000000..3031289 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/config_repartition.py @@ -0,0 +1,103 @@ +"""Routes G8 — Configurer la répartition des contributions.""" +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import require_executif +from app.models.config_repartition import ConfigRepartition +from app.schemas.phase7 import ( + ConfigRepartitionOut, ConfigRepartitionBatch, + DESTINATIONS, DESTINATIONS_LABELS, +) + +router = APIRouter(prefix="/config-repartition", tags=["G8 — Config répartition"]) + +DEFAUT_REPARTITION = { + "district": 30.0, + "region": 30.0, + "bsg": 30.0, + "intergroupe": 10.0, +} + + +@router.get("/", response_model=list[ConfigRepartitionOut]) +async def voir_repartition( + membre=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G8 — Voir la répartition actuelle des contributions.""" + result = await db.execute( + select(ConfigRepartition) + .where(ConfigRepartition.groupe_id == membre.groupe_id) + .order_by(desc(ConfigRepartition.actif_depuis)) + ) + configs = result.scalars().all() + + # Garder la plus récente par destination + vu = {} + out = [] + for c in configs: + if c.destination not in vu: + vu[c.destination] = True + out.append(ConfigRepartitionOut( + destination=c.destination, + destination_label=DESTINATIONS_LABELS.get(c.destination, c.destination), + pourcentage=float(c.pourcentage), + actif_depuis=c.actif_depuis, + )) + + # Compléter avec les défauts pour les destinations manquantes + for dest in DESTINATIONS: + if dest not in vu: + out.append(ConfigRepartitionOut( + destination=dest, + destination_label=DESTINATIONS_LABELS[dest], + pourcentage=DEFAUT_REPARTITION[dest], + actif_depuis=date.today(), + )) + + # Trier par destination + ordre = {d: i for i, d in enumerate(DESTINATIONS)} + out.sort(key=lambda x: ordre.get(x.destination, 99)) + return out + + +@router.put("/", response_model=list[ConfigRepartitionOut]) +async def modifier_repartition( + req: ConfigRepartitionBatch, + membre=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G8 — Modifier la répartition (toutes les destinations d'un coup).""" + # Validation + total = sum(item.pourcentage for item in req.items) + if abs(total - 100.0) > 0.01: + raise HTTPException(status_code=400, detail=f"Le total doit être 100%. Actuellement: {total:.2f}%") + + for item in req.items: + if item.destination not in DESTINATIONS: + raise HTTPException(status_code=400, detail=f"Destination invalide: {item.destination}") + if item.pourcentage < 0: + raise HTTPException(status_code=400, detail="Les pourcentages ne peuvent pas être négatifs") + + aujourdhui = date.today() + out = [] + for item in req.items: + config = ConfigRepartition( + groupe_id=membre.groupe_id, + destination=item.destination, + pourcentage=item.pourcentage, + actif_depuis=aujourdhui, + ) + db.add(config) + await db.flush() + out.append(ConfigRepartitionOut( + destination=config.destination, + destination_label=DESTINATIONS_LABELS.get(config.destination, config.destination), + pourcentage=float(config.pourcentage), + actif_depuis=config.actif_depuis, + )) + + return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/contributions.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/contributions.py new file mode 100644 index 0000000..338e14a --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/contributions.py @@ -0,0 +1,151 @@ +"""Routes M4 — Envoi de contributions aux organismes AA.""" +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.envoi_contribution import EnvoiContribution +from app.models.config_repartition import ConfigRepartition +from app.models.membre import Membre +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase7 import ( + EnvoiContributionCreate, EnvoiContributionOut, + CalculRepartitionIn, CalculRepartitionOut, + DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION, +) + +router = APIRouter(prefix="/contributions", tags=["M4 — Envoi contributions"]) + +# Répartition par défaut selon le Guide du groupe +DEFAUT_REPARTITION = { + "district": 30.0, + "region": 30.0, + "bsg": 30.0, + "intergroupe": 10.0, +} + + +async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + Poste.nom.ilike("%trésorier%"), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au trésorier") + return membre + + +async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]: + """Récupère la répartition active ou utilise les défauts.""" + result = await db.execute( + select(ConfigRepartition) + .where(ConfigRepartition.groupe_id == groupe_id) + .order_by(desc(ConfigRepartition.actif_depuis)) + ) + configs = result.scalars().all() + + # Garder la plus récente par destination + repartition = {} + for c in configs: + if c.destination not in repartition: + repartition[c.destination] = float(c.pourcentage) + + # Compléter avec les défauts + for dest in DESTINATIONS: + if dest not in repartition: + repartition[dest] = DEFAUT_REPARTITION[dest] + + return repartition + + +@router.post("/calculer", response_model=list[CalculRepartitionOut]) +async def calculer_repartition( + req: CalculRepartitionIn, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + """Calculer la répartition d'un montant selon les % configurés.""" + if req.montant_total <= 0: + raise HTTPException(status_code=400, detail="Le montant doit être positif") + + repartition = await _get_repartition(membre.groupe_id, db) + return [ + CalculRepartitionOut( + destination=dest, + destination_label=DESTINATIONS_LABELS[dest], + pourcentage=pct, + montant=round(req.montant_total * pct / 100, 2), + ) + for dest, pct in repartition.items() + ] + + +@router.post("/", response_model=EnvoiContributionOut) +async def enregistrer_envoi( + req: EnvoiContributionCreate, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + """M4 — Enregistrer un envoi de contribution à un organisme.""" + if req.destination not in DESTINATIONS: + raise HTTPException(status_code=400, detail=f"Destination invalide. Choix: {DESTINATIONS}") + if req.methode.upper() not in METHODES_CONTRIBUTION: + raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}") + + envoi = EnvoiContribution( + groupe_id=membre.groupe_id, + date_envoi=req.date_envoi, + destination=req.destination, + montant=req.montant, + methode=req.methode.upper(), + saisi_par=membre.id, + ) + db.add(envoi) + await db.flush() + + return EnvoiContributionOut( + id=envoi.id, + date_envoi=envoi.date_envoi, + destination=envoi.destination, + destination_label=DESTINATIONS_LABELS[envoi.destination], + montant=float(envoi.montant), + methode=envoi.methode, + saisi_par_prenom=membre.prenom, + cree_le=envoi.cree_le, + ) + + +@router.get("/", response_model=list[EnvoiContributionOut]) +async def liste_envois( + limite: int = 30, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les envois de contributions récents.""" + result = await db.execute( + select(EnvoiContribution) + .where(EnvoiContribution.groupe_id == membre.groupe_id) + .order_by(desc(EnvoiContribution.date_envoi)) + .limit(limite) + ) + envois = result.scalars().all() + out = [] + for e in envois: + saisi = await db.get(Membre, e.saisi_par) if e.saisi_par else None + out.append(EnvoiContributionOut( + id=e.id, + date_envoi=e.date_envoi, + destination=e.destination, + destination_label=DESTINATIONS_LABELS.get(e.destination, e.destination), + montant=float(e.montant), + methode=e.methode, + saisi_par_prenom=saisi.prenom if saisi else None, + cree_le=e.cree_le, + )) + return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/decisions.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/decisions.py new file mode 100644 index 0000000..52ade4a --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/decisions.py @@ -0,0 +1,110 @@ +"""Routes C5 — Décisions en cours.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_membre, require_executif +from app.models.decision import Decision +from app.models.membre import Membre +from app.schemas.phase2 import DecisionCreate, DecisionOut, DecisionUpdate + +router = APIRouter(prefix="/decisions", tags=["C5 — Décisions en cours"]) + + +async def _to_out(d: Decision, db: AsyncSession) -> DecisionOut: + resp = await db.get(Membre, d.responsable_id) if d.responsable_id else None + return DecisionOut( + id=d.id, + sujet=d.sujet, + description=d.description, + statut=d.statut, + responsable_prenom=resp.prenom if resp else None, + proposeur=d.proposeur, + secondeur=d.secondeur, + date_decision=d.date_decision, + date_completion=d.date_completion, + cree_le=d.cree_le, + ) + + +@router.get("/", response_model=list[DecisionOut]) +async def liste_decisions( + statut: str | None = None, + limite: int = 50, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C5 — Lister les décisions du groupe avec filtre optionnel par statut.""" + query = select(Decision).where(Decision.groupe_id == membre.groupe_id) + if statut: + query = query.where(Decision.statut == statut) + query = query.order_by(desc(Decision.date_decision)).limit(limite) + result = await db.execute(query) + return [await _to_out(d, db) for d in result.scalars().all()] + + +@router.get("/compteur") +async def compteur_decisions( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Nombre de décisions ouvertes (pour le badge sur la tuile).""" + result = await db.execute( + select(Decision).where( + Decision.groupe_id == membre.groupe_id, + Decision.statut.in_(["en_attente", "en_cours"]), + ) + ) + return {"ouvertes": len(result.scalars().all())} + + +@router.post("/", response_model=DecisionOut) +async def creer_decision( + req: DecisionCreate, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Créer une décision (exécutif — typiquement via le PV).""" + decision = Decision( + groupe_id=executif.groupe_id, + reunion_id=req.reunion_id, + sujet=req.sujet.strip(), + description=req.description, + statut=req.statut, + responsable_id=req.responsable_id, + proposeur=req.proposeur, + secondeur=req.secondeur, + date_decision=req.date_decision, + cree_par=executif.id, + ) + db.add(decision) + await db.flush() + return await _to_out(decision, db) + + +@router.patch("/{decision_id}", response_model=DecisionOut) +async def modifier_decision( + decision_id: str, + req: DecisionUpdate, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Modifier le statut d'une décision (exécutif).""" + result = await db.execute( + select(Decision).where(Decision.id == decision_id, Decision.groupe_id == executif.groupe_id) + ) + decision = result.scalar_one_or_none() + if not decision: + raise HTTPException(status_code=404, detail="Décision introuvable") + + if req.statut is not None: + decision.statut = req.statut + if req.description is not None: + decision.description = req.description + if req.responsable_id is not None: + decision.responsable_id = req.responsable_id + if req.date_completion is not None: + decision.date_completion = req.date_completion + await db.flush() + return await _to_out(decision, db) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/depenses.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/depenses.py new file mode 100644 index 0000000..25d24ab --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/depenses.py @@ -0,0 +1,146 @@ +"""Routes S5 — Dépenses (6 champs).""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.depense import Depense +from app.models.membre import Membre +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate + +router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"]) + +METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"} + + +async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): + """Vérifie que le membre est trésorier.""" + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + Poste.nom.ilike("%trésorier%"), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au trésorier") + return membre + + +@router.post("/", response_model=DepenseOut) +async def saisir_depense( + req: DepenseCreate, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + """S5 — Saisir une dépense avec les 6 champs.""" + # Valider la méthode + if req.methode.upper() not in METHODES_VALIDES: + raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}") + + # Valider que le membre 'qui' existe + qui_membre = await db.get(Membre, req.qui) + if not qui_membre or qui_membre.groupe_id != membre.groupe_id: + raise HTTPException(status_code=400, detail="Membre introuvable") + + depense = Depense( + groupe_id=membre.groupe_id, + date_depense=req.date_depense, + montant=req.montant, + qui=req.qui, + quoi=req.quoi.strip(), + paye=req.paye, + methode=req.methode.upper(), + saisi_par=membre.id, + ) + db.add(depense) + await db.flush() + + return DepenseOut( + id=depense.id, + date_depense=depense.date_depense, + montant=float(depense.montant), + qui_prenom=qui_membre.prenom, + quoi=depense.quoi, + paye=depense.paye, + methode=depense.methode, + saisi_par_prenom=membre.prenom, + cree_le=depense.cree_le, + ) + + +@router.get("/", response_model=list[DepenseOut]) +async def liste_depenses( + limite: int = 30, + paye: bool | None = None, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les dépenses du groupe avec filtre optionnel sur payé.""" + query = select(Depense).where(Depense.groupe_id == membre.groupe_id) + if paye is not None: + query = query.where(Depense.paye == paye) + query = query.order_by(desc(Depense.date_depense)).limit(limite) + + result = await db.execute(query) + depenses = result.scalars().all() + + out = [] + for d in depenses: + qui = await db.get(Membre, d.qui) + saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None + out.append(DepenseOut( + id=d.id, + date_depense=d.date_depense, + montant=float(d.montant), + qui_prenom=qui.prenom if qui else "?", + quoi=d.quoi, + paye=d.paye, + methode=d.methode, + saisi_par_prenom=saisi.prenom if saisi else None, + cree_le=d.cree_le, + )) + return out + + +@router.patch("/{depense_id}", response_model=DepenseOut) +async def modifier_depense( + depense_id: str, + req: DepenseUpdate, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + """Modifier une dépense (ex: marquer comme payé).""" + result = await db.execute( + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) + ) + depense = result.scalar_one_or_none() + if not depense: + raise HTTPException(status_code=404, detail="Dépense introuvable") + + if req.paye is not None: + depense.paye = req.paye + if req.methode is not None: + if req.methode.upper() not in METHODES_VALIDES: + raise HTTPException(status_code=400, detail="Méthode invalide") + depense.methode = req.methode.upper() + if req.quoi is not None: + depense.quoi = req.quoi.strip() + + await db.flush() + + qui = await db.get(Membre, depense.qui) + return DepenseOut( + id=depense.id, + date_depense=depense.date_depense, + montant=float(depense.montant), + qui_prenom=qui.prenom if qui else "?", + quoi=depense.quoi, + paye=depense.paye, + methode=depense.methode, + saisi_par_prenom=membre.prenom, + cree_le=depense.cree_le, + ) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/invitations.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/invitations.py new file mode 100644 index 0000000..a48d8d9 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/invitations.py @@ -0,0 +1,31 @@ +"""Routes invitations.""" +import secrets +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.config import settings +from app.core.security import require_executif +from app.models.invitation import Invitation +from app.schemas.schemas import InvitationOut + +router = APIRouter(prefix="/invitations", tags=["Invitations"]) + + +@router.post("/", response_model=InvitationOut) +async def creer_invitation( + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G1 — Générer un code d'invitation (expire après 48h).""" + code = secrets.token_hex(6).upper() # 12 caractères hex + invitation = Invitation( + groupe_id=executif.groupe_id, + code=code, + cree_par=executif.id, + expire_le=datetime.now(timezone.utc) + timedelta(hours=settings.INVITATION_EXPIRE_HOURS), + ) + db.add(invitation) + await db.flush() + return InvitationOut.model_validate(invitation) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jetons.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jetons.py new file mode 100644 index 0000000..ac29bcd --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jetons.py @@ -0,0 +1,109 @@ +"""Routes S2 — Remise de jeton.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.jeton import Jeton +from app.models.membre import Membre +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS + +router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"]) + + +async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): + """Vérifie que le membre est responsable des jetons.""" + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + Poste.nom.ilike("%jeton%"), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au responsable des jetons") + return membre + + +@router.get("/types") +async def types_jetons(): + """Liste des types de jetons disponibles.""" + return TYPES_JETONS + + +@router.post("/", response_model=JetonOut) +async def remettre_jeton( + req: JetonCreate, + membre=Depends(require_resp_jetons), + db: AsyncSession = Depends(get_db), +): + """S2 — Enregistrer la remise d'un jeton.""" + # Validation + types_valides = [t["value"] for t in TYPES_JETONS] + if req.type_jeton not in types_valides: + raise HTTPException(status_code=400, detail=f"Type de jeton invalide. Valeurs: {types_valides}") + + if req.type_jeton == "multiple" and (not req.annees or req.annees < 2): + raise HTTPException(status_code=400, detail="Le nombre d'années est requis (≥ 2) pour un jeton multiple") + + # Vérifier que le membre existe si spécifié + membre_prenom = None + if req.membre_id: + cible = await db.get(Membre, req.membre_id) + if not cible or not cible.actif: + raise HTTPException(status_code=404, detail="Membre introuvable") + membre_prenom = cible.prenom + + jeton = Jeton( + groupe_id=membre.groupe_id, + membre_id=req.membre_id, + type_jeton=req.type_jeton, + date_remise=req.date_remise, + annees=req.annees if req.type_jeton == "multiple" else None, + remis_par=membre.id, + ) + db.add(jeton) + await db.flush() + + return JetonOut( + id=jeton.id, + membre_prenom=membre_prenom, + type_jeton=jeton.type_jeton, + date_remise=jeton.date_remise, + annees=jeton.annees, + remis_par_prenom=membre.prenom, + cree_le=jeton.cree_le, + ) + + +@router.get("/", response_model=list[JetonOut]) +async def liste_jetons( + limite: int = 30, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les jetons remis récemment (tout le groupe).""" + result = await db.execute( + select(Jeton) + .where(Jeton.groupe_id == membre.groupe_id) + .order_by(desc(Jeton.date_remise)) + .limit(limite) + ) + jetons = result.scalars().all() + out = [] + for j in jetons: + m = await db.get(Membre, j.membre_id) if j.membre_id else None + r = await db.get(Membre, j.remis_par) if j.remis_par else None + out.append(JetonOut( + id=j.id, + membre_prenom=m.prenom if m else None, + type_jeton=j.type_jeton, + date_remise=j.date_remise, + annees=j.annees, + remis_par_prenom=r.prenom if r else None, + cree_le=j.cree_le, + )) + return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jumelages.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jumelages.py new file mode 100644 index 0000000..ec18d0d --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/jumelages.py @@ -0,0 +1,99 @@ +"""Routes G7 — Jumelage parrain/filleul (confidentiel).""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import require_executif +from app.models.jumelage import Jumelage +from app.models.membre import Membre +from app.schemas.phase8 import JumelageCreate, JumelageOut, JumelageUpdate + +router = APIRouter(prefix="/jumelages", tags=["G7 — Parrainage"]) + + +async def _to_out(j: Jumelage, db: AsyncSession) -> JumelageOut: + parrain = await db.get(Membre, j.parrain_id) + filleul = await db.get(Membre, j.filleul_id) + cree = await db.get(Membre, j.cree_par) if j.cree_par else None + return JumelageOut( + id=j.id, + parrain_prenom=parrain.prenom if parrain else "?", + filleul_prenom=filleul.prenom if filleul else "?", + date_jumelage=j.date_jumelage, + statut=j.statut, + note=j.note, + cree_par_prenom=cree.prenom if cree else None, + ) + + +@router.post("/", response_model=JumelageOut) +async def creer_jumelage( + req: JumelageCreate, + membre=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G7 — Jumeler un parrain et un filleul.""" + if req.parrain_id == req.filleul_id: + raise HTTPException(status_code=400, detail="Le parrain et le filleul doivent être différents") + + parrain = await db.get(Membre, req.parrain_id) + filleul = await db.get(Membre, req.filleul_id) + if not parrain or not parrain.actif: + raise HTTPException(status_code=404, detail="Parrain introuvable ou inactif") + if not filleul or not filleul.actif: + raise HTTPException(status_code=404, detail="Filleul introuvable ou inactif") + + jumelage = Jumelage( + groupe_id=membre.groupe_id, + parrain_id=req.parrain_id, + filleul_id=req.filleul_id, + date_jumelage=req.date_jumelage, + note=req.note, + cree_par=membre.id, + ) + db.add(jumelage) + await db.flush() + + return await _to_out(jumelage, db) + + +@router.get("/", response_model=list[JumelageOut]) +async def liste_jumelages( + statut: str | None = None, + membre=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Liste des jumelages (confidentiel — exécutif seulement).""" + query = select(Jumelage).where(Jumelage.groupe_id == membre.groupe_id) + if statut: + query = query.where(Jumelage.statut == statut) + query = query.order_by(desc(Jumelage.date_jumelage)) + + result = await db.execute(query) + jumelages = result.scalars().all() + return [await _to_out(j, db) for j in jumelages] + + +@router.patch("/{jumelage_id}", response_model=JumelageOut) +async def modifier_jumelage( + jumelage_id: str, + req: JumelageUpdate, + membre=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Modifier ou terminer un jumelage.""" + result = await db.execute( + select(Jumelage).where(Jumelage.id == jumelage_id, Jumelage.groupe_id == membre.groupe_id) + ) + jumelage = result.scalar_one_or_none() + if not jumelage: + raise HTTPException(status_code=404, detail="Jumelage introuvable") + + if req.statut and req.statut in ("actif", "termine"): + jumelage.statut = req.statut + if req.note is not None: + jumelage.note = req.note + await db.flush() + + return await _to_out(jumelage, db) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/litterature.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/litterature.py new file mode 100644 index 0000000..a865960 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/litterature.py @@ -0,0 +1,124 @@ +"""Routes C4 / M5 — Littérature : inventaire, ajustement, gestion catalogue.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.litterature import Litterature +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock + +router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"]) + + +async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): + """Vérifie que le membre est responsable de la littérature.""" + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + Poste.nom.ilike("%littérature%"), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au responsable de la littérature") + return membre + + +def _to_out(item: Litterature) -> LitteratureOut: + return LitteratureOut( + id=item.id, + titre=item.titre, + categorie=item.categorie, + prix_coutant=float(item.prix_coutant) if item.prix_coutant else None, + quantite_stock=item.quantite_stock, + seuil_commande=item.seuil_commande, + code_bsg=item.code_bsg, + stock_bas=item.quantite_stock <= item.seuil_commande, + ) + + +@router.get("/", response_model=list[LitteratureOut]) +async def inventaire( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C4 — Inventaire de la littérature du groupe avec prix.""" + result = await db.execute( + select(Litterature) + .where(Litterature.groupe_id == membre.groupe_id) + .order_by(Litterature.categorie, Litterature.titre) + ) + return [_to_out(item) for item in result.scalars().all()] + + +@router.post("/", response_model=LitteratureOut) +async def ajouter_titre( + req: LitteratureCreate, + membre=Depends(require_resp_litterature), + db: AsyncSession = Depends(get_db), +): + """Ajouter un titre au catalogue du groupe.""" + categories_valides = ["livre", "brochure", "depliant", "pamphlet"] + if req.categorie not in categories_valides: + raise HTTPException(status_code=400, detail=f"Catégorie invalide. Valeurs: {categories_valides}") + + item = Litterature( + groupe_id=membre.groupe_id, + titre=req.titre, + categorie=req.categorie, + prix_coutant=req.prix_coutant, + quantite_stock=req.quantite_stock, + seuil_commande=req.seuil_commande, + code_bsg=req.code_bsg, + ) + db.add(item) + await db.flush() + return _to_out(item) + + +@router.patch("/{item_id}", response_model=LitteratureOut) +async def modifier_titre( + item_id: str, + req: LitteratureUpdate, + membre=Depends(require_resp_litterature), + db: AsyncSession = Depends(get_db), +): + """Modifier les infos d'un titre.""" + result = await db.execute( + select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Titre introuvable") + + for field, value in req.model_dump(exclude_none=True).items(): + setattr(item, field, value) + await db.flush() + return _to_out(item) + + +@router.patch("/{item_id}/stock", response_model=LitteratureOut) +async def ajuster_stock( + item_id: str, + req: AjustementStock, + membre=Depends(require_resp_litterature), + db: AsyncSession = Depends(get_db), +): + """M5 — Ajuster le stock après réception d'une commande ou correction.""" + result = await db.execute( + select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Titre introuvable") + + nouveau_stock = item.quantite_stock + req.quantite + if nouveau_stock < 0: + raise HTTPException(status_code=400, detail="Le stock ne peut pas être négatif") + + item.quantite_stock = nouveau_stock + await db.flush() + return _to_out(item) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/membres.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/membres.py new file mode 100644 index 0000000..62c0c18 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/membres.py @@ -0,0 +1,127 @@ +"""Routes membres — profil et gestion.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin +from app.models.membre import Membre +from app.schemas.schemas import MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest + +router = APIRouter(prefix="/membres", tags=["Membres"]) + + +@router.get("/moi", response_model=MembreComplet) +async def mon_profil(membre=Depends(get_current_membre)): + """C7 — Mon profil.""" + return MembreComplet.model_validate(membre) + + +@router.patch("/moi", response_model=MembreComplet) +async def modifier_profil( + updates: MembreUpdate, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C7 — Modifier mon profil.""" + if updates.telephone is not None: + membre.telephone = updates.telephone + if updates.courriel is not None: + membre.courriel = updates.courriel + if updates.date_abstinence is not None: + membre.date_abstinence = updates.date_abstinence + await db.flush() + return MembreComplet.model_validate(membre) + + +@router.post("/moi/pin") +async def changer_pin( + req: ChangerPinRequest, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C7 — Changer mon PIN.""" + if not verify_pin(req.ancien_pin, membre.pin_hash): + raise HTTPException(status_code=400, detail="Ancien PIN incorrect") + if not req.nouveau_pin.isdigit() or len(req.nouveau_pin) != 4: + raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres") + membre.pin_hash = hash_pin(req.nouveau_pin) + await db.flush() + return {"message": "PIN modifié avec succès"} + + +@router.get("/actifs", response_model=list[MembrePublic]) +async def liste_membres_actifs( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Liste simplifiée des membres actifs (prénom + id) — pour les dropdowns.""" + result = await db.execute( + select(Membre).where( + Membre.groupe_id == membre.groupe_id, + Membre.actif == True, + ).order_by(Membre.prenom) + ) + return [MembrePublic.model_validate(m) for m in result.scalars().all()] + + +@router.get("/", response_model=list[MembreComplet]) +async def liste_membres( + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G2 — Liste des membres (exécutif seulement).""" + result = await db.execute( + select(Membre).where( + Membre.groupe_id == executif.groupe_id, + Membre.actif == True, + ).order_by(Membre.prenom) + ) + return [MembreComplet.model_validate(m) for m in result.scalars().all()] + + +@router.patch("/{membre_id}/desactiver") +async def desactiver_membre( + membre_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G2 — Désactiver un membre.""" + result = await db.execute( + select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id) + ) + membre = result.scalar_one_or_none() + if not membre: + raise HTTPException(status_code=404, detail="Membre introuvable") + membre.actif = False + await db.flush() + return {"message": f"{membre.prenom} a été désactivé"} + + +@router.delete("/{membre_id}/oubli") +async def droit_oubli( + membre_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G2 — Droit à l'oubli : anonymise les données personnelles.""" + result = await db.execute( + select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id) + ) + membre = result.scalar_one_or_none() + if not membre: + raise HTTPException(status_code=404, detail="Membre introuvable") + if str(membre.id) == str(executif.id): + raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte") + + prenom_original = membre.prenom + # Anonymiser + membre.prenom = f"Ancien membre" + membre.nom = "***" + membre.telephone = None + membre.courriel = None + membre.pin_hash = "SUPPRIME" + membre.actif = False + membre.push_sub = None + await db.flush() + return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/parametres.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/parametres.py new file mode 100644 index 0000000..b1e0ef0 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/parametres.py @@ -0,0 +1,44 @@ +"""Routes G9 — Paramètres du groupe.""" +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import require_executif, get_current_membre +from app.models.groupe import Groupe +from app.schemas.phase3 import GroupeUpdate, GroupeDetail + +router = APIRouter(prefix="/groupe", tags=["G9 — Paramètres"]) + + +@router.get("/", response_model=GroupeDetail) +async def detail_groupe( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Détail du groupe du membre connecté.""" + groupe = await db.get(Groupe, membre.groupe_id) + return GroupeDetail.model_validate(groupe) + + +@router.patch("/", response_model=GroupeDetail) +async def modifier_groupe( + req: GroupeUpdate, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G9 — Modifier les paramètres du groupe (exécutif).""" + groupe = await db.get(Groupe, executif.groupe_id) + if req.nom is not None: + groupe.nom = req.nom.strip() + if req.adresse_local is not None: + groupe.adresse_local = req.adresse_local.strip() + if req.jour_reunion is not None: + groupe.jour_reunion = req.jour_reunion.strip() + if req.heure_reunion is not None: + groupe.heure_reunion = req.heure_reunion + if req.format_reunion is not None: + groupe.format_reunion = req.format_reunion.strip() + if req.lien_aa87 is not None: + groupe.lien_aa87 = req.lien_aa87.strip() + await db.flush() + return GroupeDetail.model_validate(groupe) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/postes.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/postes.py new file mode 100644 index 0000000..403167a --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/postes.py @@ -0,0 +1,116 @@ +"""Routes postes et affectations.""" +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import get_current_membre, require_executif +from app.models.poste import Poste +from app.models.affectation import Affectation +from app.schemas.schemas import PosteOut, AffectationOut, AffectationCreate, TerminerMandatRequest + +router = APIRouter(prefix="/postes", tags=["Postes et affectations"]) + + +@router.get("/", response_model=list[PosteOut]) +async def liste_postes(db: AsyncSession = Depends(get_db), _=Depends(get_current_membre)): + result = await db.execute(select(Poste).order_by(Poste.ordre_affichage)) + return [PosteOut.model_validate(p) for p in result.scalars().all()] + + +@router.get("/affectations") +async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): + """Retourne les affectations actives du membre connecté.""" + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + ) + ) + affectations = result.scalars().all() + out = [] + for a in affectations: + poste = await db.get(Poste, a.poste_id) + out.append({ + "id": str(a.id), + "poste_nom": poste.nom if poste else "?", + "poste_categorie": poste.categorie if poste else "?", + "date_debut": str(a.date_debut), + "statut": a.statut, + }) + return out + + +@router.get("/affectations/groupe") +async def affectations_groupe( + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G3 — Toutes les affectations actives du groupe.""" + result = await db.execute( + select(Affectation).where( + Affectation.groupe_id == executif.groupe_id, + Affectation.statut == "actif", + ) + ) + affectations = result.scalars().all() + out = [] + for a in affectations: + from app.models.membre import Membre + poste = await db.get(Poste, a.poste_id) + mbr = await db.get(Membre, a.membre_id) + out.append({ + "id": str(a.id), + "membre_prenom": mbr.prenom if mbr else "?", + "poste_nom": poste.nom if poste else "?", + "poste_categorie": poste.categorie if poste else "?", + "date_debut": str(a.date_debut), + }) + return out + + +@router.post("/affectations") +async def creer_affectation( + req: AffectationCreate, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G3 — Affecter un membre à un poste.""" + affectation = Affectation( + groupe_id=executif.groupe_id, + membre_id=req.membre_id, + poste_id=req.poste_id, + date_debut=req.date_debut, + statut="actif", + ) + db.add(affectation) + await db.flush() + return {"message": "Affectation créée", "id": str(affectation.id)} + + +@router.post("/affectations/{affectation_id}/terminer") +async def terminer_mandat( + affectation_id: str, + req: TerminerMandatRequest, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G4 — Terminer un mandat manuellement.""" + result = await db.execute( + select(Affectation).where( + Affectation.id == affectation_id, + Affectation.groupe_id == executif.groupe_id, + Affectation.statut == "actif", + ) + ) + affectation = result.scalar_one_or_none() + if not affectation: + raise HTTPException(status_code=404, detail="Affectation introuvable") + + affectation.statut = "termine" + affectation.date_fin = date.today() + affectation.note_transition = req.note_transition + affectation.termine_par = executif.id + await db.flush() + return {"message": "Mandat terminé"} diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/presences.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/presences.py new file mode 100644 index 0000000..1cf0df8 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/presences.py @@ -0,0 +1,128 @@ +"""Routes M2 — Présences en réunion d'affaires.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.reunion import Reunion +from app.models.presence import Presence +from app.models.membre import Membre +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase4 import ( + PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut, +) + +router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"]) + + +async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)): + """Vérifie que le membre est secrétaire ou exécutif.""" + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + (Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif") + return membre + + +@router.post("/reunion", response_model=ReunionAffairesOut) +async def creer_reunion_affaires( + req: ReunionAffairesCreate, + membre=Depends(require_secretaire), + db: AsyncSession = Depends(get_db), +): + """Créer une réunion d'affaires (point de départ pour présences et PV).""" + reunion = Reunion( + groupe_id=membre.groupe_id, + type="affaires", + date_reunion=req.date_reunion, + note=req.note, + ) + db.add(reunion) + await db.flush() + return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note) + + +@router.get("/reunions", response_model=list[ReunionAffairesOut]) +async def liste_reunions_affaires( + limite: int = 12, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les réunions d'affaires récentes avec leurs présences.""" + result = await db.execute( + select(Reunion).where( + Reunion.groupe_id == membre.groupe_id, + Reunion.type == "affaires", + ).order_by(desc(Reunion.date_reunion)).limit(limite) + ) + reunions = result.scalars().all() + + out = [] + for r in reunions: + pres_result = await db.execute( + select(Presence).where(Presence.reunion_id == r.id) + ) + presences = pres_result.scalars().all() + pres_out = [] + for p in presences: + mbr = await db.get(Membre, p.membre_id) + pres_out.append(PresenceOut( + membre_id=p.membre_id, + membre_prenom=mbr.prenom if mbr else "?", + present=p.present, + )) + out.append(ReunionAffairesOut( + id=r.id, date_reunion=r.date_reunion, note=r.note, presences=pres_out, + )) + return out + + +@router.post("/", response_model=list[PresenceOut]) +async def enregistrer_presences( + req: PresencesBatch, + membre=Depends(require_secretaire), + db: AsyncSession = Depends(get_db), +): + """M2 — Enregistrer les présences pour une réunion d'affaires.""" + # Vérifier que la réunion existe et est de type affaires + result = await db.execute( + select(Reunion).where( + Reunion.id == req.reunion_id, + Reunion.groupe_id == membre.groupe_id, + ) + ) + reunion = result.scalar_one_or_none() + if not reunion: + raise HTTPException(status_code=404, detail="Réunion introuvable") + + # Supprimer les présences existantes pour cette réunion (pour pouvoir re-saisir) + existing = await db.execute( + select(Presence).where(Presence.reunion_id == req.reunion_id) + ) + for p in existing.scalars().all(): + await db.delete(p) + + # Insérer les nouvelles + out = [] + for item in req.presences: + pres = Presence( + reunion_id=req.reunion_id, + membre_id=item.membre_id, + present=item.present, + ) + db.add(pres) + mbr = await db.get(Membre, item.membre_id) + out.append(PresenceOut( + membre_id=item.membre_id, + membre_prenom=mbr.prenom if mbr else "?", + present=item.present, + )) + await db.flush() + return out diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/pv.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/pv.py new file mode 100644 index 0000000..b4e0fa8 --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/pv.py @@ -0,0 +1,157 @@ +"""Routes M1 — Rédiger un PV de réunion d'affaires.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.pv_reunion import PvReunion +from app.models.reunion import Reunion +from app.models.membre import Membre +from app.models.decision import Decision +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.phase4 import PvCreate, PvOut, PvUpdate + +router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"]) + + +async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)): + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + (Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")), + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif") + return membre + + +async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut: + redacteur = await db.get(Membre, pv.redacteur_id) + reunion = await db.get(Reunion, pv.reunion_id) if pv.reunion_id else None + return PvOut( + id=pv.id, + reunion_id=pv.reunion_id, + redacteur_prenom=redacteur.prenom if redacteur else "?", + contenu=pv.contenu, + decisions=pv.decisions, + approuve=pv.approuve, + cree_le=pv.cree_le, + date_reunion=reunion.date_reunion if reunion else None, + ) + + +@router.get("/", response_model=list[PvOut]) +async def liste_pv( + limite: int = 12, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les PV des réunions d'affaires.""" + # Récupérer les PV via les réunions du groupe + result = await db.execute( + select(PvReunion) + .join(Reunion, PvReunion.reunion_id == Reunion.id) + .where(Reunion.groupe_id == membre.groupe_id) + .order_by(desc(PvReunion.cree_le)) + .limit(limite) + ) + return [await _to_out(pv, db) for pv in result.scalars().all()] + + +@router.get("/{pv_id}", response_model=PvOut) +async def detail_pv( + pv_id: str, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PvReunion) + .join(Reunion, PvReunion.reunion_id == Reunion.id) + .where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id) + ) + pv = result.scalar_one_or_none() + if not pv: + raise HTTPException(status_code=404, detail="PV introuvable") + return await _to_out(pv, db) + + +@router.post("/", response_model=PvOut) +async def creer_pv( + req: PvCreate, + membre=Depends(require_secretaire), + db: AsyncSession = Depends(get_db), +): + """M1 — Rédiger un procès-verbal.""" + # Vérifier la réunion + result = await db.execute( + select(Reunion).where( + Reunion.id == req.reunion_id, + Reunion.groupe_id == membre.groupe_id, + ) + ) + reunion = result.scalar_one_or_none() + if not reunion: + raise HTTPException(status_code=404, detail="Réunion introuvable") + + decisions_data = None + if req.decisions: + decisions_data = [d.model_dump() for d in req.decisions] + + pv = PvReunion( + reunion_id=req.reunion_id, + redacteur_id=membre.id, + contenu=req.contenu.strip(), + decisions=decisions_data, + ) + db.add(pv) + await db.flush() + + # Créer les décisions dans la table decisions (pour C5) + if req.decisions: + for d in req.decisions: + decision = Decision( + groupe_id=membre.groupe_id, + reunion_id=req.reunion_id, + sujet=d.sujet, + description=d.decision, + statut=d.statut, + proposeur=d.proposeur, + secondeur=d.secondeur, + date_decision=reunion.date_reunion.date() if reunion.date_reunion else None, + cree_par=membre.id, + ) + db.add(decision) + await db.flush() + + return await _to_out(pv, db) + + +@router.patch("/{pv_id}", response_model=PvOut) +async def modifier_pv( + pv_id: str, + req: PvUpdate, + membre=Depends(require_secretaire), + db: AsyncSession = Depends(get_db), +): + """Modifier un PV existant.""" + result = await db.execute( + select(PvReunion) + .join(Reunion, PvReunion.reunion_id == Reunion.id) + .where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id) + ) + pv = result.scalar_one_or_none() + if not pv: + raise HTTPException(status_code=404, detail="PV introuvable") + + if req.contenu is not None: + pv.contenu = req.contenu.strip() + if req.decisions is not None: + pv.decisions = [d.model_dump() for d in req.decisions] + if req.approuve is not None: + pv.approuve = req.approuve + await db.flush() + return await _to_out(pv, db) diff --git a/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports.py b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports.py new file mode 100644 index 0000000..60fe2be --- /dev/null +++ b/groupe-meditation-corrige/groupe-meditation/backend/app/routers/rapports.py @@ -0,0 +1,306 @@ +"""Routes C1 — Rapports PDF (finances, PV, rapport RSG).""" +import io +from datetime import date, datetime +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, extract + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.groupe import Groupe +from app.models.collecte import Collecte +from app.models.depense import Depense +from app.models.membre import Membre +from app.models.reunion import Reunion +from app.models.envoi_contribution import EnvoiContribution + +router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"]) + +MOIS_FR = [ + "", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", + "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", +] + + +def _css(): + return """ + body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; } + h1 { color: #1a5276; font-size: 16pt; margin-bottom: 4pt; } + h2 { color: #1a5276; font-size: 13pt; border-bottom: 2px solid #1a5276; padding-bottom: 4pt; margin-top: 20pt; } + .header { text-align: center; margin-bottom: 20pt; } + .header .groupe { font-size: 18pt; font-weight: bold; color: #1a5276; } + .header .sub { font-size: 9pt; color: #888; } + table { width: 100%; border-collapse: collapse; margin-top: 8pt; } + th { background: #1a5276; color: white; padding: 6pt 8pt; text-align: left; font-size: 9pt; } + td { padding: 5pt 8pt; border-bottom: 1px solid #ddd; font-size: 10pt; } + tr:nth-child(even) { background: #f8f9fa; } + .total { font-weight: bold; font-size: 12pt; margin-top: 10pt; } + .footer { text-align: center; font-size: 8pt; color: #aaa; margin-top: 30pt; } + .badge-paye { color: #27ae60; } + .badge-impaye { color: #e74c3c; } + """ + + +@router.get("/finances/{annee}/{mois}") +async def rapport_finances( + annee: int, + mois: int, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C1 — Rapport financier mensuel en PDF.""" + try: + from weasyprint import HTML + except ImportError: + raise HTTPException(status_code=500, detail="WeasyPrint non installé. Installer avec: pip install weasyprint") + + groupe = await db.get(Groupe, membre.groupe_id) + + # Collectes du mois + result = await db.execute( + select(Collecte).where( + Collecte.groupe_id == membre.groupe_id, + extract("year", Collecte.date_collecte) == annee, + extract("month", Collecte.date_collecte) == mois, + ).order_by(Collecte.date_collecte) + ) + collectes = result.scalars().all() + + # Dépenses du mois + result = await db.execute( + select(Depense).where( + Depense.groupe_id == membre.groupe_id, + extract("year", Depense.date_depense) == annee, + extract("month", Depense.date_depense) == mois, + ).order_by(Depense.date_depense) + ) + depenses = result.scalars().all() + + total_collectes = sum(float(c.montant) for c in collectes) + total_depenses = sum(float(d.montant) for d in depenses) + solde = total_collectes - total_depenses + + # Construire le HTML + rows_collectes = "" + for c in collectes: + saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None + rows_collectes += f"{c.date_collecte}{float(c.montant):.2f} ${saisi.prenom if saisi else '-'}" + + rows_depenses = "" + for d in depenses: + qui = await db.get(Membre, d.qui) if d.qui else None + badge = '✓' if d.paye else '✗ DÛ' + rows_depenses += f"{d.date_depense}{float(d.montant):.2f} ${qui.prenom if qui else '-'}{d.quoi}{d.methode}{badge}" + + html_content = f""" +
+
{groupe.nom if groupe else 'Groupe'}
+
District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}
+
+

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

+ +

Collectes (7e Tradition)

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

Total collectes : {total_collectes:.2f} $

+ +

Dépenses

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

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

+ +

Sommaire

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

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

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

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

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

{titre}

+ {sousTitre &&

{sousTitre}

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

{message}

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

{titre}

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

Bonjour

+

{membre?.prenom}

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

Affectations actives

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

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

+

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

+
+
+ ))} +
+
+ )} + + {/* Nouvelle affectation */} +
+

Nouvelle affectation

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

{error}

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

{error}

} + + {loading &&

Chargement...

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

Aucun titre dans le catalogue

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

{item.titre}

+

{item.categorie}

+
+
+

+ {item.quantite_stock} +

+

en stock

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

{error}

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

Chargement...

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

+ 🎉 Aujourd'hui +

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

+ Prochains anniversaires +

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

Aucun anniversaire dans les prochains {filtre} jours

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

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

+

{a.groupe_nom}

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

+ {a.prochain_anniversaire} +

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

{detailGroupe.groupe?.nom}

+

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

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

Adresse

+

{detailGroupe.groupe.adresse}

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

{s.poste}

+

{s.prenom}

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

Aucun serviteur assigné

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

Chargement...

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

📅

+

Aucune donnée de calendrier

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

{error}

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

Chargement...

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

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

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

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

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

+ Utilisation (30 derniers jours) +

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

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

+

{s.remis_dernier_mois} remis

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

Aucune donnée de jetons pour le moment

+
+ )} + + {/* Info */} +
+

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

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

Chargement...

} + +
+

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

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

{item.destination_label}

+

depuis {item.actif_depuis}

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

Le total doit être exactement 100%

+ )} +
+ )} + + {error &&

{error}

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

Chargement...

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

📌

+

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

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

{d.sujet}

+ {d.description && ( +

{d.description}

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

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

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

{error}

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

{r.destination_label}

+

{r.pourcentage}%

+
+

{r.montant.toFixed(2)} $

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

{error}

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

+ Derniers envois +

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

{e.destination_label}

+

{e.date_envoi} · {e.methode}

+
+

{e.montant.toFixed(2)} $

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

{detail.prenom} {detail.nom}

+
+
+ {msg &&
{msg}
} + +
+

Prénom

{detail.prenom}

+

Nom

{detail.nom}

+

Téléphone

{detail.telephone || '—'}

+

Courriel

{detail.courriel || '—'}

+

Date d'abstinence

{detail.date_abstinence || '—'}

+

Statut

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

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

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

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

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

Chargement...

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

Nouveau membre

+

Groupe Méditation

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

{error}

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

Chargement...

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

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

+

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

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

+ {info.emoji} {info.label} +

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

{item.titre}

+ {item.code_bsg && ( +

BSG: {item.code_bsg}

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

{item.prix_coutant.toFixed(2)} $

+ )} +

+ {item.quantite_stock} en stock +

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

Aucune littérature dans l'inventaire

+

Le responsable de la littérature peut ajouter des titres

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

➕

+

Générer un code d'invitation

+

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

+ +
+ ) : ( +
+
+

Code d'invitation

+

{code}

+

Expire le {expiration}

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

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

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

{error}

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

Actifs

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

{j.parrain_prenom} → {j.filleul_prenom}

+

Depuis {j.date_jumelage}

+ {j.note &&

{j.note}

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

Terminés

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

{j.parrain_prenom} → {j.filleul_prenom}

+

{j.date_jumelage}

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

Aucun jumelage enregistré

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