diff --git a/backend/app/core/audit.py b/backend/app/core/audit.py index 9153519..35a1cd4 100644 --- a/backend/app/core/audit.py +++ b/backend/app/core/audit.py @@ -17,6 +17,23 @@ SKIPPED_PREFIXES = ( "/api/health", ) + +def _notification_category(path: str) -> str: + mappings = ( + (("/api/propositions", "/api/pv"), "gouvernance"), + (("/api/collectes", "/api/depenses", "/api/contributions", "/api/banque"), "tresorerie"), + (("/api/membres", "/api/invitations"), "membres"), + (("/api/postes", "/api/rotations"), "postes"), + (("/api/reunions", "/api/presences"), "rencontres"), + (("/api/litterature", "/api/ventes-litterature"), "litterature"), + (("/api/evenements", "/api/calendrier"), "evenements"), + ) + for prefixes, category in mappings: + if any(path.startswith(prefix) for prefix in prefixes): + return category + return "systeme" + + def _token_payload(request): header = request.headers.get("authorization") or "" if not header.lower().startswith("bearer "): @@ -73,6 +90,7 @@ class AuditMiddleware(BaseHTTPMiddleware): title="Changement dans l'application", body=f"{membre.prenom} a enregistré une modification.", url="/", + category=_notification_category(path), ) await db.commit() return response diff --git a/backend/app/main.py b/backend/app/main.py index 6729825..18d6d43 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from sqlalchemy import text from app.core.config import settings from app.core.audit import AuditMiddleware from app.core.database import engine, Base -from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal +from app.routers import admin, auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal # Import models pour que create_all crée toutes les tables from app.models.sync_log import SyncLog # noqa: F401 @@ -26,6 +26,7 @@ from app.models.mouvement_reserve import MouvementReserve # noqa: F401 from app.models.journal_action import JournalAction # noqa: F401 from app.models.push_subscription import PushSubscription # noqa: F401 from app.models.destinataire_contribution import DestinataireContribution # noqa: F401 +from app.models.rapport_adopte import RapportAdopte # noqa: F401 @asynccontextmanager @@ -40,6 +41,7 @@ async def lifespan(app: FastAPI): await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT")) await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT")) await conn.execute(text("ALTER TABLE propositions DROP COLUMN IF EXISTS type_proposition")) + await conn.execute(text("ALTER TABLE membres ADD COLUMN IF NOT EXISTS notification_preferences JSON")) await conn.execute(text( "ALTER TABLE envois_contributions " "ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text" @@ -79,6 +81,7 @@ app.add_middleware(AuditMiddleware) # Montage des routes app.include_router(auth.router, prefix="/api") +app.include_router(admin.router, prefix="/api") app.include_router(membres.router, prefix="/api") app.include_router(postes.router, prefix="/api") app.include_router(accueil.router, prefix="/api") diff --git a/backend/app/models/membre.py b/backend/app/models/membre.py index 4fe3943..f862903 100644 --- a/backend/app/models/membre.py +++ b/backend/app/models/membre.py @@ -22,6 +22,7 @@ class Membre(Base): 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) + notification_preferences: 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)) diff --git a/backend/app/models/rapport_adopte.py b/backend/app/models/rapport_adopte.py new file mode 100644 index 0000000..97d2cb8 --- /dev/null +++ b/backend/app/models/rapport_adopte.py @@ -0,0 +1,23 @@ +"""Rapports cristallisés après adoption par le groupe.""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class RapportAdopte(Base): + __tablename__ = "rapports_adoptes" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False, index=True) + adopte_par_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + type_rapport: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + titre: Mapped[str] = mapped_column(String(180), nullable=False) + annee: Mapped[int | None] = mapped_column(Integer) + mois: Mapped[int | None] = mapped_column(Integer) + contenu_html: Mapped[str] = mapped_column(Text, nullable=False) + donnees: Mapped[dict | None] = mapped_column(JSON) + adopte_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..2173e8b --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,31 @@ +"""Routes d'administration reservees au sysadmin.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_membre, is_sysadmin +from app.services.admin_database import initialiser_demo, remettre_a_zero + +router = APIRouter(prefix="/admin", tags=["Administration"]) + + +async def require_sysadmin(membre=Depends(get_current_membre)): + if not is_sysadmin(membre): + raise HTTPException(status_code=403, detail="Réservé au sysadmin") + return membre + + +@router.post("/reset") +async def reset_database( + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + return await remettre_a_zero(db) + + +@router.post("/demo") +async def demo_database( + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + return await initialiser_demo(db) diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py index 6828928..963fc1b 100644 --- a/backend/app/routers/notifications.py +++ b/backend/app/routers/notifications.py @@ -8,7 +8,12 @@ from app.core.config import settings from app.core.database import get_db from app.core.security import get_current_membre from app.models.push_subscription import PushSubscription -from app.services.notifications import notifications_configured, send_to_subscriptions +from app.services.notifications import ( + NOTIFICATION_TYPES, + normalized_preferences, + notifications_configured, + send_to_subscriptions, +) router = APIRouter(prefix="/notifications", tags=["Notifications"]) @@ -19,6 +24,10 @@ class PushSubscriptionIn(BaseModel): keys: dict +class NotificationPreferencesIn(BaseModel): + preferences: dict[str, bool] + + @router.get("/configuration") async def configuration_notifications(): return { @@ -27,6 +36,31 @@ async def configuration_notifications(): } +@router.get("/preferences") +async def lire_preferences_notifications(membre=Depends(get_current_membre)): + return { + "types": NOTIFICATION_TYPES, + "preferences": normalized_preferences(membre.notification_preferences), + } + + +@router.patch("/preferences") +async def modifier_preferences_notifications( + req: NotificationPreferencesIn, + membre=Depends(get_current_membre), +): + current = normalized_preferences(membre.notification_preferences) + for item in NOTIFICATION_TYPES: + code = item["code"] + if code in req.preferences: + current[code] = bool(req.preferences[code]) + membre.notification_preferences = current + return { + "types": NOTIFICATION_TYPES, + "preferences": current, + } + + @router.post("/abonnement") async def enregistrer_abonnement( abonnement: PushSubscriptionIn, diff --git a/backend/app/routers/pv.py b/backend/app/routers/pv.py index 52dd2b0..162d0d3 100644 --- a/backend/app/routers/pv.py +++ b/backend/app/routers/pv.py @@ -140,6 +140,8 @@ async def adopter_pv( pv = result.scalar_one_or_none() if not pv: raise HTTPException(status_code=404, detail="PV introuvable") + if pv.approuve: + raise HTTPException(status_code=400, detail="PV déjà adopté") if req.proposeur_id == req.secondeur_id: raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents") proposeur = await db.get(Membre, req.proposeur_id) @@ -149,6 +151,12 @@ async def adopter_pv( if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif: raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif") + if req.contenu is not None: + contenu = req.contenu.strip() + if not contenu: + raise HTTPException(status_code=400, detail="Le contenu du PV ne peut pas être vide") + pv.contenu = contenu + decisions = list(pv.decisions or []) decisions.append({ "sujet": "Adoption du procès-verbal précédent", diff --git a/backend/app/routers/rapports.py b/backend/app/routers/rapports.py index 72be14a..1b90884 100644 --- a/backend/app/routers/rapports.py +++ b/backend/app/routers/rapports.py @@ -4,10 +4,12 @@ from html import escape from datetime import date, datetime, time, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, extract from app.core.database import get_db +from app.core.roles import require_tresorier from app.core.security import get_current_membre from app.models.groupe import Groupe from app.models.collecte import Collecte @@ -16,6 +18,7 @@ from app.models.membre import Membre from app.models.reunion import Reunion from app.models.envoi_contribution import EnvoiContribution from app.models.proposition import Proposition +from app.models.rapport_adopte import RapportAdopte router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"]) @@ -25,6 +28,10 @@ MOIS_FR = [ ] +class RapportTresorerieAdoption(BaseModel): + note: str | None = None + + def _texte_decision_resolution(p: Proposition) -> str: """Retourne le texte décisionnel si la résolution utilise le format structuré.""" if p.groupe_a_decide_de: @@ -582,19 +589,13 @@ async def etat_financier_temps_reel( } -@router.get("/etat-resultats/{annee}/{mois}") -async def rapport_etat_resultats( +async def _html_etat_resultats( annee: int, mois: int, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), + membre, + db: AsyncSession, + note_adoption: str | None = None, ): - """PDF mensuel: état des résultats et position de trésorerie.""" - try: - from weasyprint import HTML - except ImportError: - raise HTTPException(status_code=500, detail="WeasyPrint non installé") - if mois < 1 or mois > 12: raise HTTPException(status_code=400, detail="Mois invalide") @@ -739,6 +740,12 @@ async def rapport_etat_resultats( f"
{escape(note_adoption.strip()).replace(chr(10), '
')}