diff --git a/.gitignore b/.gitignore
index 557d6ac..fb5bcbe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
node_modules/
dist/
+__pycache__/
+*.py[cod]
*.retry
ansible/group_vars/vault.yml
diff --git a/README.md b/README.md
index e082767..3fe26f7 100644
--- a/README.md
+++ b/README.md
@@ -11,29 +11,28 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
| Frontend | React 18 + Vite (PWA installable) |
| UI | Tailwind CSS (mobile-first) |
| État | Zustand |
-| Backend | FastAPI (Python) async, 82 routes |
-| DB | PostgreSQL + SQLAlchemy async (23+ tables) |
+| Backend | FastAPI (Python) async |
+| DB | PostgreSQL + SQLAlchemy async |
| Auth | JWT 30 jours + bcrypt 12 rounds (prénom + PIN) |
| PDF | WeasyPrint |
| Serveur | NGINX (statiques + reverse proxy HTTPS) |
## Modules
-### Consultation (C1-C7) — Tous les membres
+### Consultation — Tous les membres
- **C1** Rapports PDF — finances, PV, résolutions, rapport complet
-- **C2** Événements à venir — anniversaires de sobriété et événements ad hoc du district
+- **C2** Événements — anniversaires de sobriété, événements et résolutions
- **C4** Littérature — inventaire avec prix, alerte stock bas
-- **C5** Décisions en cours — suivi des résolutions
-- **C6** Votes ouverts — sondages de conscience de groupe, vote anonyme
+- **C5** Gouvernance — décisions du groupe, propositions, résolutions, nominations et contributions
- **C7** Mon profil — modifier, changer PIN, notifications
-- **C8** Propositions — soumettre, seconder, voter (créables en tout temps)
-- **C9** S'impliquer — postes vacants, candidatures
+- **C9** Postes à pourvoir — postes vacants, candidatures
### Saisie hebdomadaire (S1-S5)
-- **S1** Collecte 7e Tradition — saisie le jour de réunion, confirmation par un second membre, historique mensuel avec totaux
-- **S1T** Trésorerie — soldes (banque + encaisse + disponible après réserves), réception des collectes et ventes de littérature, registre détaillé des transactions, opérations bancaires (dépôt/retrait/relevé)
+- **S0** Réunion hebdomadaire — formulaire regroupé pour rapport, collecte, jeton et littérature
+- **S1** Collecte 7e Tradition — réservé aux postes exécutifs
+- **S1T** Trésorerie — soldes (banque + encaisse + réserves affectées), réception des collectes et ventes de littérature, registre détaillé des transactions, opérations bancaires (dépôt/retrait/relevé)
- **S2** Remise de jeton — 9 types, historique
- **S3** Vente littérature — stock auto-décrémenté, montant à remettre au trésorier
- **S4** Rapport de réunion — thème + annonces
@@ -41,6 +40,7 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
### Saisie mensuelle (M1-M6)
+- **M0** Assemblée d'affaires — formulaire regroupé pour réunion, présences et PV
- **M1** Rédiger PV — auto-renseigné depuis les propositions secondées, décisions structurées
- **M2** Présences réunion d'affaires
- **M3** Rapport RSG — template 3 sections
@@ -50,27 +50,23 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
### Gestion — Exécutif
-- **G1** Inviter un membre — code 48h
-- **G2** Gérer les membres — fiche complète, droit à l'oubli
-- **G6** Lancer un sondage — oui/non, choix multiple, unanimité
+- **G2** Gestion des membres — membres, invitations, droit à l'oubli
- **G9** Paramètres du groupe — nom, horaire, adresse, format
- **G11** Gestion des mandats — affecter, terminer, planifier les rotations
- **G12** Gestion des postes — créer, abolir, candidatures, accès aux modules
## Trésorerie
-Le calcul des soldes repose sur le relevé bancaire mensuel :
+La source de vérité financière est le registre `transactions_comptables`.
-- **Solde banque** = dernier relevé + dépôts − retraits depuis le relevé
-- **Disponible** = solde banque − engagements non débités − réserves
-- **Réserves** = réserve prudente (500 $) + réserve anniversaire (20 $/mois)
-- **Encaisse** = collectes reçues + ventes remises − dépôts − dépenses cash
+- **Banque** et **encaisse** sont calculées depuis les écritures comptables.
+- **Réserves** est calculé depuis l'historique `mouvements_reserves`.
+- Toute dépense ou contribution par chèque/virement reste un engagement jusqu'à confirmation du débit.
+- Les rapports mensuels doivent s'appuyer sur ce registre.
-Toute dépense par chèque ou virement est un engagement. Le trésorier confirme le débit lorsque l'argent quitte effectivement le compte.
+## Gouvernance
-## Propositions et résolutions
-
-Les propositions peuvent être créées en tout temps par n'importe quel membre, sans nécessiter une réunion d'affaires. Le cycle est : proposée → secondée → votée (adoptée/rejetée). Lorsqu'adoptée, la proposition devient une résolution numérotée (RES-2026-001) et peut être associée à l'assemblée d'affaires lors du vote.
+Les propositions peuvent être créées en tout temps par n'importe quel membre, sans type initial. Le cycle est : proposée → secondée → transformée en résolution par l'exécutif. Lorsqu'adoptée, la proposition devient une décision numérotée avec les préfixes `RES` (résolution), `NOM` (nomination) ou `DON` (contribution), et les sections « ÉTANT DONNÉ QUE » et « LE GROUPE A DÉCIDÉ DE ».
Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions secondées en attente de vote.
@@ -87,9 +83,10 @@ Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions second
```
backend/ API FastAPI (Python)
app/
- core/ Config, DB, sécurité
- models/ Modèles SQLAlchemy (23+ tables)
- routers/ Routes API (26 groupes)
+ core/ Config, DB, sécurité, rôles, règles communes
+ services/ Logique métier réutilisable
+ models/ Modèles SQLAlchemy
+ routers/ Routes API
schemas/ Schémas Pydantic
frontend/ React + Vite + Tailwind
src/
@@ -103,6 +100,7 @@ docs/ DEPLOIEMENT.md, RUNBOOK.md
## Documentation
- [Guide utilisateur](docs/UTILISATEURS.md)
+- [Architecture technique](docs/ARCHITECTURE.md)
- [Déploiement Ansible](docs/ANSIBLE.md)
- [Runbook d'exploitation](docs/RUNBOOK.md)
diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml
index e1345ac..1df9dd3 100644
--- a/ansible/group_vars/all.yml
+++ b/ansible/group_vars/all.yml
@@ -33,6 +33,9 @@ groupe_meditation_database_url: >-
groupe_meditation_cors_origins:
- "https://{{ groupe_meditation_domain }}"
groupe_meditation_invitation_expire_hours: 48
+groupe_meditation_vapid_public_key: ""
+groupe_meditation_vapid_private_key_b64: ""
+groupe_meditation_vapid_subject: "mailto:admin@{{ groupe_meditation_domain }}"
# Initialisation applicative. Le seed existant est interactif; Ansible lui
# fournit ces valeurs seulement si la base ne contient encore aucun groupe.
@@ -52,4 +55,3 @@ groupe_meditation_certbot_email: admin@example.com
# Sauvegardes.
groupe_meditation_backup_dir: /var/backups/groupe-meditation
groupe_meditation_backup_retention_days: 30
-
diff --git a/ansible/roles/app/templates/env.j2 b/ansible/roles/app/templates/env.j2
index 959a268..5bf8f63 100644
--- a/ansible/roles/app/templates/env.j2
+++ b/ansible/roles/app/templates/env.j2
@@ -3,4 +3,6 @@ DATABASE_URL={{ groupe_meditation_database_url }}
JWT_SECRET={{ groupe_meditation_jwt_secret }}
CORS_ORIGINS={{ groupe_meditation_cors_origins | to_json }}
INVITATION_EXPIRE_HOURS={{ groupe_meditation_invitation_expire_hours }}
-
+VAPID_PUBLIC_KEY={{ groupe_meditation_vapid_public_key }}
+VAPID_PRIVATE_KEY_B64={{ groupe_meditation_vapid_private_key_b64 }}
+VAPID_SUBJECT={{ groupe_meditation_vapid_subject }}
diff --git a/ansible/roles/nginx/templates/groupe-meditation.conf.j2 b/ansible/roles/nginx/templates/groupe-meditation.conf.j2
index 5e6a345..d45f833 100644
--- a/ansible/roles/nginx/templates/groupe-meditation.conf.j2
+++ b/ansible/roles/nginx/templates/groupe-meditation.conf.j2
@@ -18,9 +18,24 @@ server {
root {{ groupe_meditation_web_root }};
index index.html;
+ location = /index.html {
+ expires off;
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header Pragma "no-cache" always;
+ add_header Expires "0" always;
+ }
+
+ location ~ ^/(sw\.js|push-sw\.js|registerSW\.js|manifest\.webmanifest)$ {
+ expires off;
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header Pragma "no-cache" always;
+ add_header Expires "0" always;
+ try_files $uri =404;
+ }
+
location ~* \.(js|css|png|jpg|svg|woff2|ico)$ {
expires 1y;
- add_header Cache-Control "public, immutable";
+ add_header Cache-Control "public, immutable" always;
try_files $uri =404;
}
@@ -28,10 +43,5 @@ server {
try_files $uri $uri/ /index.html;
}
- location = /index.html {
- add_header Cache-Control "no-cache, no-store, must-revalidate";
- }
-
client_max_body_size 5M;
}
-
diff --git a/backend/app/core/audit.py b/backend/app/core/audit.py
new file mode 100644
index 0000000..ea6b88f
--- /dev/null
+++ b/backend/app/core/audit.py
@@ -0,0 +1,78 @@
+"""Journalisation automatique des changements API."""
+from jose import JWTError, jwt
+from sqlalchemy import select
+from starlette.middleware.base import BaseHTTPMiddleware
+
+from app.core.config import settings
+from app.core.database import async_session
+from app.models.journal_action import JournalAction
+from app.models.membre import Membre
+from app.services.notifications import notify_group_change
+
+
+MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
+SKIPPED_PREFIXES = (
+ "/api/auth/connexion",
+ "/api/health",
+)
+
+
+def _token_payload(request):
+ header = request.headers.get("authorization") or ""
+ if not header.lower().startswith("bearer "):
+ return None
+ token = header.split(" ", 1)[1].strip()
+ try:
+ return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
+ except JWTError:
+ return None
+
+
+class AuditMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request, call_next):
+ response = await call_next(request)
+ path = request.url.path
+ if (
+ request.method not in MUTATING_METHODS
+ or not path.startswith("/api/")
+ or any(path.startswith(prefix) for prefix in SKIPPED_PREFIXES)
+ or response.status_code >= 400
+ ):
+ return response
+
+ payload = _token_payload(request)
+ if not payload or not payload.get("sub"):
+ return response
+
+ async with async_session() as db:
+ membre = None
+ result = await db.execute(select(Membre).where(Membre.id == payload["sub"]))
+ membre = result.scalar_one_or_none()
+ if not membre:
+ return response
+
+ details = {
+ "query": dict(request.query_params),
+ "impersonification": bool(payload.get("adm")),
+ }
+ db.add(JournalAction(
+ groupe_id=membre.groupe_id,
+ membre_id=membre.id,
+ admin_id=payload.get("adm"),
+ methode=request.method,
+ chemin=path,
+ statut_http=response.status_code,
+ adresse_ip=request.client.host if request.client else None,
+ user_agent=(request.headers.get("user-agent") or "")[:255] or None,
+ details=details,
+ ))
+ await notify_group_change(
+ db,
+ groupe_id=membre.groupe_id,
+ actor_id=membre.id,
+ title="Changement dans l'application",
+ body=f"{membre.prenom} a enregistré une modification.",
+ url="/",
+ )
+ await db.commit()
+ return response
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index fd17bbd..906776f 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -17,6 +17,9 @@ class Settings(BaseSettings):
"https://groupe-meditation.87-16.org",
]
INVITATION_EXPIRE_HOURS: int = 48
+ VAPID_PUBLIC_KEY: str | None = None
+ VAPID_PRIVATE_KEY_B64: str | None = None
+ VAPID_SUBJECT: str = "mailto:admin@groupe-meditation.local"
class Config:
env_file = str(_ENV_FILE)
diff --git a/backend/app/core/decisions.py b/backend/app/core/decisions.py
new file mode 100644
index 0000000..6ba70b9
--- /dev/null
+++ b/backend/app/core/decisions.py
@@ -0,0 +1,24 @@
+"""Utilitaires pour la numérotation des décisions du groupe."""
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.proposition import Proposition
+
+PREFIXES_DECISION = {
+ "resolution": "RES",
+ "nomination": "NOM",
+ "contribution": "DON",
+}
+
+
+async def prochain_numero_decision(groupe_id, type_decision: str, annee: int, db: AsyncSession) -> str:
+ prefixe = PREFIXES_DECISION.get(type_decision, "RES")
+ result = await db.execute(
+ select(func.count(Proposition.id)).where(
+ Proposition.groupe_id == groupe_id,
+ Proposition.numero_resolution.isnot(None),
+ Proposition.numero_resolution.like(f"{prefixe}-{annee}-%"),
+ )
+ )
+ count = result.scalar() or 0
+ return f"{prefixe}-{annee}-{count + 1:03d}"
diff --git a/backend/app/core/reunions.py b/backend/app/core/reunions.py
new file mode 100644
index 0000000..3bf94bb
--- /dev/null
+++ b/backend/app/core/reunions.py
@@ -0,0 +1,38 @@
+"""Règles communes liées aux dates de réunion."""
+from datetime import date as dt_date
+from calendar import monthrange
+
+JOURS_SEMAINE = {
+ "lundi": 0,
+ "mardi": 1,
+ "mercredi": 2,
+ "jeudi": 3,
+ "vendredi": 4,
+ "samedi": 5,
+ "dimanche": 6,
+}
+
+
+def jour_reunion_attendu(groupe) -> int | None:
+ if not getattr(groupe, "jour_reunion", None):
+ return None
+ return JOURS_SEMAINE.get(groupe.jour_reunion.lower())
+
+
+def date_correspond_au_jour_reunion(groupe, date_reunion: dt_date) -> bool:
+ jour = jour_reunion_attendu(groupe)
+ return True if jour is None else date_reunion.weekday() == jour
+
+
+def date_assemblee_affaires_du_mois(annee: int, mois: int, jour_cible: int, ordre: str) -> dt_date:
+ dates = []
+ for jour in range(1, monthrange(annee, mois)[1] + 1):
+ date = dt_date(annee, mois, jour)
+ if date.weekday() == jour_cible:
+ dates.append(date)
+ if not dates:
+ return dt_date(annee, mois, 1)
+ if ordre == "derniere":
+ return dates[-1]
+ index = {"premiere": 0, "deuxieme": 1, "troisieme": 2, "quatrieme": 3}.get(ordre, 0)
+ return dates[index] if index < len(dates) else dates[-1]
diff --git a/backend/app/core/roles.py b/backend/app/core/roles.py
new file mode 100644
index 0000000..5878cd0
--- /dev/null
+++ b/backend/app/core/roles.py
@@ -0,0 +1,51 @@
+"""Vérifications de rôles applicatifs."""
+from fastapi import Depends, HTTPException
+from sqlalchemy import select
+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.models.affectation import Affectation
+from app.models.poste import Poste
+
+
+async def membre_a_poste_ou_executif(membre_id, libelle_poste: str, db: AsyncSession) -> bool:
+ from app.models.membre import Membre
+ membre = await db.get(Membre, membre_id)
+ if membre and is_sysadmin(membre):
+ return True
+ result = await db.execute(
+ select(Affectation).join(Poste).where(
+ Affectation.membre_id == membre_id,
+ Affectation.statut == "actif",
+ (Poste.nom.ilike(f"%{libelle_poste}%") | (Poste.categorie == "executif")),
+ )
+ )
+ return result.scalars().first() is not None
+
+
+async def require_tresorier(
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ if not await membre_a_poste_ou_executif(membre.id, "trésorier", db):
+ raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
+ return membre
+
+
+async def require_secretaire(
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ if not await membre_a_poste_ou_executif(membre.id, "secrétaire", db):
+ raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
+ return membre
+
+
+async def require_rsg(
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ if not await membre_a_poste_ou_executif(membre.id, "RSG", db):
+ raise HTTPException(status_code=403, detail="Réservé au RSG ou à l'exécutif")
+ return membre
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index b0c4fb6..4af9b2e 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -21,10 +21,12 @@ 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, expire_jours: int = None) -> str:
+def create_token(membre_id: str, groupe_id: str, expire_jours: int = None, admin_id: str | None = None) -> str:
jours = expire_jours or settings.JWT_EXPIRE_DAYS
expire = datetime.now(timezone.utc) + timedelta(days=jours)
payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire}
+ if admin_id:
+ payload["adm"] = str(admin_id)
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
@@ -35,6 +37,10 @@ def decode_token(token: str) -> dict:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide ou expiré")
+def is_sysadmin(membre) -> bool:
+ return bool(getattr(membre, "is_sysadmin", False))
+
+
async def get_current_membre(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
@@ -51,6 +57,8 @@ async def get_current_membre(
raise HTTPException(status_code=401, detail="Membre introuvable ou inactif")
if groupe_id and str(membre.groupe_id) != str(groupe_id):
raise HTTPException(status_code=401, detail="Session invalide pour ce groupe")
+ if payload.get("adm"):
+ setattr(membre, "_sysadmin_session", True)
return membre
@@ -58,6 +66,8 @@ async def require_executif(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
+ if is_sysadmin(membre):
+ return membre
from app.models.affectation import Affectation
from app.models.poste import Poste
result = await db.execute(
diff --git a/backend/app/main.py b/backend/app/main.py
index b1aedf6..8da8579 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,10 +6,12 @@ District 87-16, Région 87
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+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, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, propositions, operations_bancaires, evenements
+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
# Import models pour que create_all crée toutes les tables
from app.models.sync_log import SyncLog # noqa: F401
@@ -19,12 +21,27 @@ from app.models.evenement import Evenement # noqa: F401
from app.models.candidature import Candidature # noqa: F401
from app.models.poste_module import PosteModule # noqa: F401
from app.models.reserve import Reserve # noqa: F401
+from app.models.transaction_comptable import TransactionComptable # noqa: F401
+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
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+ await conn.execute(text(
+ "ALTER TABLE groupes "
+ "ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'"
+ ))
+ await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE"))
+ 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("DROP TABLE IF EXISTS votes"))
+ await conn.execute(text("DROP TABLE IF EXISTS options_sondage"))
+ await conn.execute(text("DROP TABLE IF EXISTS sondages"))
+ await conn.execute(text("DROP TABLE IF EXISTS decisions"))
yield
await engine.dispose()
@@ -43,6 +60,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
+app.add_middleware(AuditMiddleware)
# Montage des routes
app.include_router(auth.router, prefix="/api")
@@ -53,7 +71,6 @@ 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")
@@ -68,10 +85,11 @@ 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(propositions.router, prefix="/api")
app.include_router(operations_bancaires.router, prefix="/api")
app.include_router(evenements.router, prefix="/api")
+app.include_router(notifications.router, prefix="/api")
+app.include_router(journal.router, prefix="/api")
@app.get("/api/health")
diff --git a/backend/app/models/decision.py b/backend/app/models/decision.py
deleted file mode 100644
index 0f1ee4a..0000000
--- a/backend/app/models/decision.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Table decisions — suivi des décisions de réunion d'affaires."""
-import uuid
-from datetime import date, datetime, timezone
-from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Enum as SAEnum
-from sqlalchemy.orm import Mapped, mapped_column
-from app.core.database import Base
-
-
-class Decision(Base):
- __tablename__ = "decisions"
-
- id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
- groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
- reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
- sujet: Mapped[str] = mapped_column(String(255), nullable=False)
- description: Mapped[str | None] = mapped_column(Text)
- statut: Mapped[str] = mapped_column(
- SAEnum("en_attente", "en_cours", "complete", name="statut_decision"),
- default="en_attente",
- )
- responsable_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
- proposeur: Mapped[str | None] = mapped_column(String(100))
- secondeur: Mapped[str | None] = mapped_column(String(100))
- date_decision: Mapped[date] = mapped_column(Date, nullable=False)
- date_completion: Mapped[date | None] = mapped_column(Date)
- cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
- cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
diff --git a/backend/app/models/groupe.py b/backend/app/models/groupe.py
index ef36382..f5b67e6 100644
--- a/backend/app/models/groupe.py
+++ b/backend/app/models/groupe.py
@@ -16,6 +16,7 @@ class Groupe(Base):
adresse_local: Mapped[str | None] = mapped_column(Text)
jour_reunion: Mapped[str | None] = mapped_column(String(10))
heure_reunion: Mapped[time | None] = mapped_column(Time)
+ assemblee_affaires_ordre: Mapped[str] = mapped_column(String(20), default="premiere")
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))
diff --git a/backend/app/models/journal_action.py b/backend/app/models/journal_action.py
new file mode 100644
index 0000000..5cf92af
--- /dev/null
+++ b/backend/app/models/journal_action.py
@@ -0,0 +1,22 @@
+"""Journal d'audit des actions membres."""
+import uuid
+from datetime import datetime, timezone
+from sqlalchemy import String, DateTime, ForeignKey, JSON, Integer
+from sqlalchemy.orm import Mapped, mapped_column
+from app.core.database import Base
+
+
+class JournalAction(Base):
+ __tablename__ = "journal_actions"
+
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
+ groupe_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("groupes.id"), index=True)
+ membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"), index=True)
+ admin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"), index=True)
+ methode: Mapped[str] = mapped_column(String(10), nullable=False)
+ chemin: Mapped[str] = mapped_column(String(255), nullable=False)
+ statut_http: Mapped[int] = mapped_column(Integer, nullable=False)
+ adresse_ip: Mapped[str | None] = mapped_column(String(64))
+ user_agent: Mapped[str | None] = mapped_column(String(255))
+ details: Mapped[dict | None] = mapped_column(JSON)
+ cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
diff --git a/backend/app/models/membre.py b/backend/app/models/membre.py
index 73c608e..4fe3943 100644
--- a/backend/app/models/membre.py
+++ b/backend/app/models/membre.py
@@ -27,3 +27,7 @@ class Membre(Base):
groupe = relationship("Groupe", back_populates="membres")
affectations = relationship("Affectation", back_populates="membre", foreign_keys="Affectation.membre_id")
+
+ @property
+ def is_sysadmin(self) -> bool:
+ return (self.courriel or "").strip().lower() == "sysadmin" or bool(getattr(self, "_sysadmin_session", False))
diff --git a/backend/app/models/mouvement_reserve.py b/backend/app/models/mouvement_reserve.py
new file mode 100644
index 0000000..8de9e49
--- /dev/null
+++ b/backend/app/models/mouvement_reserve.py
@@ -0,0 +1,20 @@
+"""Historique des mouvements entre réserves."""
+import uuid
+from datetime import datetime, timezone
+from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+from app.core.database import Base
+
+
+class MouvementReserve(Base):
+ __tablename__ = "mouvements_reserves"
+
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
+ groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
+ source_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reserves.id"), nullable=True)
+ destination_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reserves.id"), nullable=True)
+ montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
+ type: Mapped[str] = mapped_column(String(40), nullable=False)
+ note: Mapped[str | None] = mapped_column(Text)
+ 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/backend/app/models/option_sondage.py b/backend/app/models/option_sondage.py
deleted file mode 100644
index 77b9fc1..0000000
--- a/backend/app/models/option_sondage.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""Table options_sondage — Options de vote."""
-import uuid
-from sqlalchemy import String, Integer, ForeignKey
-from sqlalchemy.orm import Mapped, mapped_column, relationship
-from app.core.database import Base
-
-
-class OptionSondage(Base):
- __tablename__ = "options_sondage"
-
- id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
- sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False)
- texte: Mapped[str] = mapped_column(String(200), nullable=False)
- ordre: Mapped[int] = mapped_column(Integer, default=0)
-
- sondage = relationship("Sondage", back_populates="options")
diff --git a/backend/app/models/proposition.py b/backend/app/models/proposition.py
index 41f03ce..0014347 100644
--- a/backend/app/models/proposition.py
+++ b/backend/app/models/proposition.py
@@ -13,7 +13,7 @@ class Proposition(Base):
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"), nullable=True)
type_proposition: Mapped[str] = mapped_column(
- String(20), default="generale", # generale, nomination, contribution
+ String(20), default="proposition", # proposition, resolution, nomination, contribution
)
sujet: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@@ -23,8 +23,11 @@ class Proposition(Base):
SAEnum("proposee", "secondee", "adoptee", "rejetee", name="statut_proposition"),
default="proposee",
)
- numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES-2026-001
+ numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES/NOM/DON-2026-001
date_vote: Mapped[date | None] = mapped_column(Date)
+ date_adoption: Mapped[date | None] = mapped_column(Date)
+ etant_donne_que: Mapped[str | None] = mapped_column(Text)
+ groupe_a_decide_de: Mapped[str | None] = mapped_column(Text)
note: Mapped[str | None] = mapped_column(Text)
# Lien vers la candidature (si type=nomination)
candidature_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("candidatures.id"))
diff --git a/backend/app/models/push_subscription.py b/backend/app/models/push_subscription.py
new file mode 100644
index 0000000..598836a
--- /dev/null
+++ b/backend/app/models/push_subscription.py
@@ -0,0 +1,22 @@
+"""Abonnements Web Push des appareils membres."""
+import uuid
+from datetime import datetime, timezone
+from sqlalchemy import String, DateTime, ForeignKey, JSON, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+from app.core.database import Base
+
+
+class PushSubscription(Base):
+ __tablename__ = "push_subscriptions"
+ __table_args__ = (
+ UniqueConstraint("endpoint", name="uq_push_subscription_endpoint"),
+ )
+
+ 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)
+ membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False, index=True)
+ endpoint: Mapped[str] = mapped_column(String(500), nullable=False)
+ abonnement: Mapped[dict] = mapped_column(JSON, nullable=False)
+ user_agent: Mapped[str | None] = mapped_column(String(255))
+ 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/sondage.py b/backend/app/models/sondage.py
deleted file mode 100644
index b89834e..0000000
--- a/backend/app/models/sondage.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""Table sondages — Votes de conscience de groupe."""
-import uuid
-from datetime import datetime, timezone
-from sqlalchemy import String, Text, DateTime, ForeignKey, Enum as SAEnum
-from sqlalchemy.orm import Mapped, mapped_column, relationship
-from app.core.database import Base
-
-
-class Sondage(Base):
- __tablename__ = "sondages"
-
- id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
- groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
- titre: Mapped[str] = mapped_column(String(200), nullable=False)
- description: Mapped[str | None] = mapped_column(Text)
- type: Mapped[str] = mapped_column(
- SAEnum("oui_non", "choix_multiple", "unanimite", name="type_sondage"),
- nullable=False,
- )
- cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
- date_ouverture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
- date_fermeture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
- statut: Mapped[str] = mapped_column(
- SAEnum("brouillon", "ouvert", "ferme", name="statut_sondage"),
- default="brouillon",
- )
- cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
-
- options = relationship("OptionSondage", back_populates="sondage", cascade="all, delete-orphan")
diff --git a/backend/app/models/transaction_comptable.py b/backend/app/models/transaction_comptable.py
new file mode 100644
index 0000000..60e269b
--- /dev/null
+++ b/backend/app/models/transaction_comptable.py
@@ -0,0 +1,23 @@
+"""Registre comptable central."""
+import uuid
+from datetime import date, datetime, timezone
+from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+from app.core.database import Base
+
+
+class TransactionComptable(Base):
+ __tablename__ = "transactions_comptables"
+
+ 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_transaction: Mapped[date] = mapped_column(Date, nullable=False)
+ type: Mapped[str] = mapped_column(String(40), nullable=False)
+ compte: Mapped[str] = mapped_column(String(40), nullable=False)
+ montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
+ sens: Mapped[str] = mapped_column(String(10), nullable=False) # debit ou credit
+ source_table: Mapped[str | None] = mapped_column(String(80))
+ source_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
+ note: Mapped[str | None] = mapped_column(Text)
+ 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/backend/app/models/vote.py b/backend/app/models/vote.py
deleted file mode 100644
index e04d850..0000000
--- a/backend/app/models/vote.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""Table votes — Votes anonymes."""
-import uuid
-from datetime import datetime, timezone
-from sqlalchemy import DateTime, ForeignKey
-from sqlalchemy.orm import Mapped, mapped_column
-from app.core.database import Base
-
-
-class Vote(Base):
- __tablename__ = "votes"
-
- id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
- sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False)
- option_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("options_sondage.id", ondelete="CASCADE"), nullable=False)
- membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
- vote_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
diff --git a/backend/app/routers/accueil.py b/backend/app/routers/accueil.py
index 784f141..7bc78f5 100644
--- a/backend/app/routers/accueil.py
+++ b/backend/app/routers/accueil.py
@@ -4,7 +4,7 @@ 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.core.security import get_current_membre, is_sysadmin
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.groupe import Groupe
@@ -18,26 +18,24 @@ MODULES_CONSULTATION = [
ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, résolutions", icone="📄", couleur="blue"),
ModuleTuile(code="C2", nom="Événements", description="Anniversaires et événements", icone="📅", couleur="green"),
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="C5", nom="Gouvernance", description="Décisions du groupe", icone="📌", couleur="red"),
ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"),
- ModuleTuile(code="C8", nom="Propositions", description="Soumettre, seconder, résolutions", icone="📋", couleur="orange"),
ModuleTuile(code="C9", nom="Postes à pourvoir", description="Postes disponibles, candidature", icone="🙋", couleur="yellow"),
- ModuleTuile(code="C10", nom="État financier", description="Résultats et transactions", icone="📊", couleur="blue"),
+ ModuleTuile(code="S1T", nom="Trésorerie", description="État des résultats, soldes, registre", icone="🏦", couleur="green"),
]
MODULES_PAR_CODE = {m.code: m for m in MODULES_CONSULTATION}
+MODULES_PAR_CODE["S0"] = ModuleTuile(code="S0", nom="Rencontre hebdomadaire", description="Saisies du contexte régulier", icone="🗓️", couleur="green")
+MODULES_PAR_CODE["M0"] = ModuleTuile(code="M0", nom="Assemblée d'affaires", description="Présences et procès-verbal", icone="🏛️", couleur="indigo")
+MODULES_PAR_CODE["S1"] = ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green")
-# Modules de saisie visibles par TOUS les membres
MODULES_SAISIE_TOUS = [
- ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"),
ModuleTuile(code="S5", nom="Dépense", description="Soumettre une dépense", icone="💸", couleur="red"),
]
# Modules de saisie par poste
MODULES_SAISIE = {
"Trésorier(ière)": [
- ModuleTuile(code="S1T", nom="Trésorerie", description="Réceptions, dépôts, soldes", icone="🏦", couleur="green"),
ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"),
],
"Secrétaire": [
@@ -65,10 +63,10 @@ MODULES_SAISIE = {
MODULES_GESTION = [
ModuleTuile(code="G2", nom="Gestion des membres", description="Membres, invitations, désactivation", icone="👥", couleur="blue"),
- ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"),
ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"),
ModuleTuile(code="G11", nom="Gestion des mandats", description="Affecter, terminer, rotations", icone="🎯", couleur="purple"),
ModuleTuile(code="G12", nom="Gestion des postes", description="Postes, candidatures, accès", icone="📋", couleur="purple"),
+ ModuleTuile(code="G13", nom="Journal", description="Changements enregistrés", icone="🧾", couleur="gray"),
]
for _modules in [MODULES_SAISIE_TOUS, MODULES_GESTION, *MODULES_SAISIE.values()]:
@@ -82,6 +80,34 @@ async def accueil(
db: AsyncSession = Depends(get_db),
):
"""Écran d'accueil dynamique — retourne les modules selon les postes du membre."""
+ if is_sysadmin(membre):
+ modules_saisie_sysadmin = []
+ codes_vus = set()
+ for module in [MODULES_PAR_CODE["S1"], MODULES_PAR_CODE["S0"], MODULES_PAR_CODE["M0"], *MODULES_SAISIE_TOUS, *[m for mods in MODULES_SAISIE.values() for m in mods]]:
+ if module.code not in codes_vus:
+ modules_saisie_sysadmin.append(module)
+ codes_vus.add(module.code)
+ groupe = await db.get(Groupe, membre.groupe_id)
+ groupe_info = None
+ if groupe:
+ groupe_info = {
+ "id": str(groupe.id),
+ "nom": groupe.nom,
+ "jour_reunion": groupe.jour_reunion,
+ "heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None,
+ "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
+ "adresse_local": groupe.adresse_local,
+ }
+ return AccueilResponse(
+ membre=MembrePublic.model_validate(membre),
+ postes=["Sysadmin"],
+ est_executif=True,
+ modules_consultation=MODULES_CONSULTATION,
+ modules_saisie=modules_saisie_sysadmin,
+ modules_gestion=MODULES_GESTION,
+ groupe=groupe_info,
+ )
+
# Récupérer les affectations actives
result = await db.execute(
select(Affectation).join(Poste).where(
@@ -106,13 +132,15 @@ async def accueil(
# Modules de saisie : collecte pour tous + modules par poste
modules_saisie = list(MODULES_SAISIE_TOUS)
- codes_vus = {"S1", "S5"}
+ codes_vus = {"S5"}
result = await db.execute(select(PosteModule).where(PosteModule.poste_id.in_(poste_ids))) if poste_ids else None
modules_configures = result.scalars().all() if result else []
if modules_configures:
codes = sorted({pm.module_code for pm in modules_configures})
for code in codes:
+ if code == "S1" and "executif" not in postes_categories:
+ continue
m = MODULES_PAR_CODE.get(code)
if m and not code.startswith("G") and m.code not in codes_vus:
modules_saisie.append(m)
@@ -123,6 +151,13 @@ async def accueil(
if m.code not in codes_vus:
modules_saisie.append(m)
codes_vus.add(m.code)
+ if "executif" in postes_categories and "S1" not in codes_vus:
+ modules_saisie.insert(0, MODULES_PAR_CODE["M0"])
+ modules_saisie.insert(0, MODULES_PAR_CODE["S0"])
+ modules_saisie.insert(0, ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"))
+ codes_vus.add("S1")
+ codes_vus.add("S0")
+ codes_vus.add("M0")
# Modules de gestion : seulement si exécutif
if modules_configures:
@@ -143,12 +178,14 @@ async def accueil(
"nom": groupe.nom,
"jour_reunion": groupe.jour_reunion,
"heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None,
+ "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
"adresse_local": groupe.adresse_local,
}
return AccueilResponse(
membre=MembrePublic.model_validate(membre),
postes=postes_noms,
+ est_executif="executif" in postes_categories,
modules_consultation=modules_consultation,
modules_saisie=modules_saisie,
modules_gestion=modules_gestion,
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index fce6d16..67f22c9 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -5,7 +5,7 @@ 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.core.security import get_current_membre, hash_pin, verify_pin, create_token, is_sysadmin
from app.models.membre import Membre
from app.models.groupe import Groupe
from app.models.invitation import Invitation
@@ -36,6 +36,7 @@ async def liste_groupes(db: AsyncSession = Depends(get_db)):
"adresse_local": g.adresse_local,
"jour_reunion": g.jour_reunion,
"heure_reunion": g.heure_reunion.strftime("%H:%M") if g.heure_reunion else None,
+ "assemblee_affaires_ordre": g.assemblee_affaires_ordre,
"format_reunion": g.format_reunion,
} for g in groupes]
@@ -63,6 +64,25 @@ async def connexion(req: ConnexionRequest, db: AsyncSession = Depends(get_db)):
)
+@router.post("/impersoner/{membre_id}", response_model=TokenResponse)
+async def impersoner_membre(
+ membre_id: str,
+ admin=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ if not is_sysadmin(admin):
+ raise HTTPException(status_code=403, detail="Réservé au sysadmin")
+ cible = await db.get(Membre, membre_id)
+ if not cible or cible.groupe_id != admin.groupe_id or not cible.actif:
+ raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
+ setattr(cible, "_sysadmin_session", True)
+ token = create_token(str(cible.id), str(cible.groupe_id), admin_id=str(admin.id))
+ return TokenResponse(
+ access_token=token,
+ membre=MembrePublic.model_validate(cible),
+ )
+
+
@router.post("/inscription", response_model=TokenResponse)
async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)):
# Valider le code d'invitation
diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py
index 7a7be62..4c8afbc 100644
--- a/backend/app/routers/collectes.py
+++ b/backend/app/routers/collectes.py
@@ -1,11 +1,13 @@
"""Routes S1 — Collecte 7e Tradition."""
-from datetime import date, datetime, time as dt_time, timezone
+from datetime import date, datetime, time as dt_time, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract, func
from app.core.database import get_db
-from app.core.security import get_current_membre, require_executif
+from app.core.roles import membre_a_poste_ou_executif, require_tresorier
+from app.core.reunions import JOURS_SEMAINE
+from app.core.security import get_current_membre, require_executif, is_sysadmin
from app.models.collecte import Collecte
from app.models.groupe import Groupe
from app.models.membre import Membre
@@ -13,34 +15,16 @@ from app.models.depense import Depense
from app.models.envoi_contribution import EnvoiContribution
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
-from app.models.affectation import Affectation
-from app.models.poste import Poste
+from app.models.mouvement_reserve import MouvementReserve
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
+from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
-JOURS_SEMAINE = {
- "lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
- "vendredi": 4, "samedi": 5, "dimanche": 6,
-}
-
async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool:
"""Vérifie si un membre est trésorier ou exécutif."""
- result = await db.execute(
- select(Affectation).join(Poste).where(
- Affectation.membre_id == membre_id,
- Affectation.statut == "actif",
- (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
- )
- )
- return result.scalars().first() is not None
-
-
-async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
- if not await _est_tresorier(membre.id, membre.groupe_id, db):
- raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
- return membre
+ return await membre_a_poste_ou_executif(membre_id, "trésorier", db)
async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut:
@@ -66,28 +50,41 @@ async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut:
@router.post("/", response_model=CollecteOut)
async def saisir_collecte(
req: CollecteCreate,
- membre=Depends(get_current_membre),
+ membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
- """S1 — N'importe quel membre saisit la collecte. Rétroactif possible sur les dates de réunion."""
+ """S1 — Un membre exécutif saisit la collecte."""
groupe = await db.get(Groupe, membre.groupe_id)
aujourdhui = date.today()
+ retro_sysadmin = is_sysadmin(membre)
# Validation : pas dans le futur
- if req.date_collecte > aujourdhui:
+ if req.date_collecte > aujourdhui and not retro_sysadmin:
raise HTTPException(status_code=400, detail="La date ne peut pas être dans le futur")
# Validation : la date doit tomber un jour de réunion
if groupe.jour_reunion:
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
- if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu:
+ if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu and not retro_sysadmin:
raise HTTPException(
status_code=400,
detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}",
)
+ if jour_attendu is not None and not retro_sysadmin:
+ dernieres_reunions = []
+ d = aujourdhui
+ while len(dernieres_reunions) < 52:
+ if d.weekday() == jour_attendu:
+ dernieres_reunions.append(d)
+ d -= timedelta(days=1)
+ if req.date_collecte not in dernieres_reunions:
+ raise HTTPException(
+ status_code=400,
+ detail="La date doit faire partie des 52 dernières rencontres du groupe",
+ )
# Validation : si c'est aujourd'hui, pas avant l'heure de la réunion
- if req.date_collecte == aujourdhui and groupe.heure_reunion:
+ if req.date_collecte == aujourdhui and groupe.heure_reunion and not retro_sysadmin:
now = datetime.now(timezone.utc)
if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion:
raise HTTPException(
@@ -126,10 +123,10 @@ async def saisir_collecte(
async def ajuster_collecte(
collecte_id: str,
req: CollecteUpdate,
- membre=Depends(get_current_membre),
+ membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
- """Ajuster le montant (exécutif ou celui qui a saisi, avant confirmation)."""
+ """Ajuster le montant avant confirmation."""
result = await db.execute(
select(Collecte).where(
Collecte.id == collecte_id,
@@ -142,11 +139,6 @@ async def ajuster_collecte(
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")
- # Vérifier : exécutif ou saisi_par
- est_exec = await _est_tresorier(membre.id, membre.groupe_id, db)
- if collecte.saisi_par != membre.id and not est_exec:
- raise HTTPException(status_code=403, detail="Seul un exécutif ou la personne qui a saisi peut modifier")
-
if req.montant is not None:
collecte.montant = req.montant
await db.flush()
@@ -156,7 +148,7 @@ async def ajuster_collecte(
@router.post("/{collecte_id}/confirmer", response_model=CollecteOut)
async def confirmer_collecte(
collecte_id: str,
- membre=Depends(get_current_membre),
+ membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Un second membre confirme le montant. Si le trésorier est impliqué → réception auto."""
@@ -186,6 +178,7 @@ async def confirmer_collecte(
collecte.statut = "recu"
collecte.recu_par = tresorier_id
collecte.date_reception = date.today()
+ db.add(transaction(collecte.groupe_id, tresorier_id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
else:
collecte.statut = "confirmee"
@@ -196,7 +189,7 @@ async def confirmer_collecte(
@router.post("/{collecte_id}/reception", response_model=CollecteOut)
async def accuser_reception(
collecte_id: str,
- membre=Depends(_require_tresorier),
+ membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier accuse réception (pour les cas sans trésorier impliqué)."""
@@ -220,6 +213,7 @@ async def accuser_reception(
collecte.statut = "recu"
collecte.recu_par = membre.id
collecte.date_reception = date.today()
+ db.add(transaction(collecte.groupe_id, membre.id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
await db.flush()
return await _to_out(collecte, db)
@@ -230,7 +224,7 @@ async def liste_collectes(
mois: int | None = None,
annee: int | None = None,
limite: int = 20,
- membre=Depends(get_current_membre),
+ membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
query = select(Collecte).where(Collecte.groupe_id == membre.groupe_id)
@@ -257,8 +251,6 @@ async def soldes_tresorerie(
from app.models.vente_litterature import VenteLitterature
gid = membre.groupe_id
-
- # ── Dernier relevé bancaire ──
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
@@ -266,54 +258,10 @@ async def soldes_tresorerie(
).order_by(desc(OperationBancaire.date_operation)).limit(1)
)
dernier_releve = result.scalar_one_or_none()
- solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
date_releve = str(dernier_releve.date_operation) if dernier_releve else None
- # ── Dépôts et retraits depuis le dernier relevé ──
- filtre_depuis = dernier_releve.date_operation if dernier_releve else None
-
- # Dépôts après relevé
- q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == gid,
- OperationBancaire.type == "depot",
- )
- if filtre_depuis:
- q = q.where(OperationBancaire.date_operation > filtre_depuis)
- result = await db.execute(q)
- depots_depuis = float(result.scalar())
-
- # Retraits après relevé
- q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == gid,
- OperationBancaire.type == "retrait",
- )
- if filtre_depuis:
- q = q.where(OperationBancaire.date_operation > filtre_depuis)
- result = await db.execute(q)
- retraits_depuis = float(result.scalar())
-
- # Contributions débitées après relevé
- q = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
- EnvoiContribution.groupe_id == gid,
- EnvoiContribution.debitee == True,
- )
- if filtre_depuis:
- q = q.where(EnvoiContribution.date_envoi > filtre_depuis)
- result = await db.execute(q)
- contributions_debitees_depuis = float(result.scalar())
-
- # Dépenses bancaires débitées après relevé
- q = select(func.coalesce(func.sum(Depense.montant), 0)).where(
- Depense.groupe_id == gid,
- Depense.paye == True,
- Depense.methode.in_(["VIREMENT", "CHEQUE"]),
- )
- if filtre_depuis:
- q = q.where(Depense.date_confirmation > filtre_depuis)
- result = await db.execute(q)
- depenses_bancaires_depuis = float(result.scalar())
-
- solde_banque = solde_releve + depots_depuis - retraits_depuis - contributions_debitees_depuis - depenses_bancaires_depuis
+ solde_banque_actuel = await solde_banque(gid, db)
+ encaisse_actuelle = await encaisse(gid, db)
# ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ──
result = await db.execute(
@@ -336,7 +284,6 @@ async def soldes_tresorerie(
contributions_non_debitees = float(result.scalar())
engagements_non_debites += contributions_non_debitees
- # ── Réserves ──
result = await db.execute(
select(Reserve).where(
Reserve.groupe_id == gid,
@@ -344,49 +291,20 @@ async def soldes_tresorerie(
).order_by(Reserve.nom)
)
reserves = result.scalars().all()
- reserves_details = [
- {
+ reserves_details = []
+ for r in reserves:
+ montant = await solde_reserve(r.id, db)
+ r.montant = montant
+ reserves_details.append({
"id": str(r.id),
"nom": r.nom,
- "montant": float(r.montant),
+ "montant": round(montant, 2),
"note": r.note,
- }
- for r in reserves
- ]
- total_reserves = sum(r["montant"] for r in reserves_details)
+ })
+ total_reserves_reelles = sum(r["montant"] for r in reserves_details)
# ── Somme disponible ──
- disponible = solde_banque - engagements_non_debites - total_reserves
-
- # ── Encaisse (cash reçu par le trésorier, pas déposé) ──
- result = await db.execute(
- select(func.coalesce(func.sum(Collecte.montant), 0)).where(
- Collecte.groupe_id == gid, Collecte.statut == "recu"))
- total_collectes_recues = float(result.scalar())
-
- result = await db.execute(
- select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
- total_depots = float(result.scalar())
-
- result = await db.execute(
- select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait"))
- total_retraits = float(result.scalar())
-
- result = await db.execute(
- select(func.coalesce(func.sum(Depense.montant), 0)).where(
- Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
- depenses_cash = float(result.scalar())
-
- # Ventes de littérature remises au trésorier
- result = await db.execute(
- select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
- VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True))
- ventes_recues = float(result.scalar())
-
- encaisse = total_collectes_recues + ventes_recues + total_retraits - total_depots - depenses_cash
-
+ disponible = solde_banque_actuel + encaisse_actuelle - engagements_non_debites - total_reserves_reelles
# ── Argent en circulation (collectes non remises au trésorier) ──
result = await db.execute(
select(Collecte).where(
@@ -398,6 +316,7 @@ async def soldes_tresorerie(
det = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None
total_en_circulation += float(c.montant)
details_circulation.append({
+ "id": str(c.id),
"date": str(c.date_collecte),
"montant": float(c.montant),
"detenteur": det.prenom if det else "?",
@@ -428,14 +347,15 @@ async def soldes_tresorerie(
})
return {
- "solde_banque": round(solde_banque, 2),
+ "solde_banque": round(solde_banque_actuel, 2),
"date_releve": date_releve,
- "encaisse": round(encaisse, 2),
+ "encaisse": round(encaisse_actuelle, 2),
"disponible": round(disponible, 2),
"engagements_non_debites": round(engagements_non_debites, 2),
"reserve_prudente": 0,
"reserve_anniversaire": 0,
- "total_reserves": round(total_reserves, 2),
+ "total_reserves": round(total_reserves_reelles, 2),
+ "total_reserves_reelles": round(total_reserves_reelles, 2),
"reserves_details": reserves_details,
"en_possession": round(total_en_circulation, 2),
"en_possession_details": details_circulation,
@@ -455,9 +375,11 @@ async def registre_transactions(
"""Registre détaillé de toutes les transactions financières du groupe."""
from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature
+ from app.models.transaction_comptable import TransactionComptable
gid = membre.groupe_id
transactions = []
+ dons_operation_ids = set()
# Filtres date
def filtre_mois(d):
@@ -544,7 +466,30 @@ async def registre_transactions(
"par": "Trésorier",
})
- # 5. Opérations bancaires (dépôts/retraits)
+ # 5. Dons déposés directement à la banque
+ result = await db.execute(
+ select(TransactionComptable).where(
+ TransactionComptable.groupe_id == gid,
+ TransactionComptable.type == "don",
+ TransactionComptable.compte == "banque",
+ TransactionComptable.sens == "debit",
+ ).order_by(TransactionComptable.date_transaction)
+ )
+ for don in result.scalars().all():
+ if filtre_mois(don.date_transaction):
+ m = await db.get(Membre, don.cree_par) if don.cree_par else None
+ if don.source_id:
+ dons_operation_ids.add(don.source_id)
+ ajouter_transaction({
+ "date": str(don.date_transaction),
+ "type": "don",
+ "description": f"Don déposé directement{(' — ' + don.note) if don.note else ''}",
+ "entree": float(don.montant),
+ "sortie": 0,
+ "par": m.prenom if m else "?",
+ })
+
+ # 6. Opérations bancaires (dépôts/retraits)
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
@@ -552,6 +497,8 @@ async def registre_transactions(
).order_by(OperationBancaire.date_operation)
)
for op in result.scalars().all():
+ if op.id in dons_operation_ids:
+ continue
if filtre_mois(op.date_operation):
m = await db.get(Membre, op.effectue_par)
is_depot = op.type == "depot"
@@ -564,5 +511,27 @@ async def registre_transactions(
"par": m.prenom if m else "?",
})
- transactions.sort(key=lambda t: t["date"])
+ # 7. Mouvements virtuels entre le disponible et les réserves
+ result = await db.execute(
+ select(MouvementReserve).where(MouvementReserve.groupe_id == gid)
+ .order_by(MouvementReserve.cree_le)
+ )
+ for mv in result.scalars().all():
+ date_mouvement = mv.cree_le.date()
+ if filtre_mois(date_mouvement):
+ source = await db.get(Reserve, mv.source_id) if mv.source_id else None
+ destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None
+ createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None
+ source_nom = source.nom if source else "Disponible"
+ destination_nom = destination.nom if destination else "Disponible"
+ ajouter_transaction({
+ "date": str(date_mouvement),
+ "type": "reserve_virement",
+ "description": f"Virement de réserve : {source_nom} → {destination_nom} ({float(mv.montant):.2f} $)",
+ "entree": 0,
+ "sortie": 0,
+ "par": createur.prenom if createur else "?",
+ })
+
+ transactions.sort(key=lambda t: t["date"], reverse=True)
return transactions
diff --git a/backend/app/routers/commandes_jetons.py b/backend/app/routers/commandes_jetons.py
index 2f0aff2..c8cadb5 100644
--- a/backend/app/routers/commandes_jetons.py
+++ b/backend/app/routers/commandes_jetons.py
@@ -5,7 +5,7 @@ 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.core.security import get_current_membre, is_sysadmin
from app.models.jeton import Jeton
from app.models.affectation import Affectation
from app.models.poste import Poste
@@ -17,6 +17,8 @@ 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."""
+ if is_sysadmin(membre):
+ return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
diff --git a/backend/app/routers/contributions.py b/backend/app/routers/contributions.py
index 18507ff..365f14f 100644
--- a/backend/app/routers/contributions.py
+++ b/backend/app/routers/contributions.py
@@ -2,15 +2,17 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, desc, func
+from sqlalchemy import select, desc
from app.core.database import get_db
+from app.core.decisions import prochain_numero_decision
+from app.core.roles import require_tresorier
+from app.core.security import is_sysadmin
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.services.finance import solde_banque, total_reserves, transaction
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
@@ -28,33 +30,6 @@ DEFAUT_REPARTITION = {
}
-async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str:
- from app.models.proposition import Proposition
-
- result = await db.execute(
- select(func.count(Proposition.id)).where(
- Proposition.groupe_id == groupe_id,
- Proposition.numero_resolution.isnot(None),
- Proposition.numero_resolution.like(f"RES-{annee}-%"),
- )
- )
- count = result.scalar() or 0
- return f"RES-{annee}-{count + 1:03d}"
-
-
-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.scalars().first():
- 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(
@@ -123,7 +98,8 @@ async def enregistrer_envoi(
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
- numero_resolution = await _prochain_numero_resolution(membre.groupe_id, date.today().year, db)
+ date_decision = req.date_envoi if is_sysadmin(membre) else date.today()
+ numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db)
resolution = Proposition(
groupe_id=membre.groupe_id,
type_proposition="contribution",
@@ -136,7 +112,9 @@ async def enregistrer_envoi(
secondeur_id=req.secondeur_id,
statut="adoptee",
numero_resolution=numero_resolution,
- date_vote=date.today(),
+ date_vote=date_decision,
+ date_adoption=date_decision,
+ groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}.",
)
db.add(resolution)
await db.flush()
@@ -189,17 +167,16 @@ async def confirmer_debit_contribution(
if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
- from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
-
- solde_banque = await _solde_banque_brut(membre.groupe_id, db)
- if float(envoi.montant) > solde_banque:
+ solde_banque_actuel = await solde_banque(membre.groupe_id, db)
+ if float(envoi.montant) > solde_banque_actuel:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
- total_reserves = await _total_reserves(membre.groupe_id, db)
- if solde_banque - float(envoi.montant) < total_reserves:
+ total_reserves_actuel = await total_reserves(membre.groupe_id, db)
+ if solde_banque_actuel - float(envoi.montant) < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
envoi.debitee = True
envoi.engagee = True
+ db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination)))
await db.flush()
saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None
diff --git a/backend/app/routers/decisions.py b/backend/app/routers/decisions.py
deleted file mode 100644
index 52ade4a..0000000
--- a/backend/app/routers/decisions.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""Routes C5 — Décisions en cours."""
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, desc
-
-from app.core.database import get_db
-from app.core.security import get_current_membre, require_executif
-from app.models.decision import Decision
-from app.models.membre import Membre
-from app.schemas.phase2 import DecisionCreate, DecisionOut, DecisionUpdate
-
-router = APIRouter(prefix="/decisions", tags=["C5 — Décisions en cours"])
-
-
-async def _to_out(d: Decision, db: AsyncSession) -> DecisionOut:
- resp = await db.get(Membre, d.responsable_id) if d.responsable_id else None
- return DecisionOut(
- id=d.id,
- sujet=d.sujet,
- description=d.description,
- statut=d.statut,
- responsable_prenom=resp.prenom if resp else None,
- proposeur=d.proposeur,
- secondeur=d.secondeur,
- date_decision=d.date_decision,
- date_completion=d.date_completion,
- cree_le=d.cree_le,
- )
-
-
-@router.get("/", response_model=list[DecisionOut])
-async def liste_decisions(
- statut: str | None = None,
- limite: int = 50,
- membre=Depends(get_current_membre),
- db: AsyncSession = Depends(get_db),
-):
- """C5 — Lister les décisions du groupe avec filtre optionnel par statut."""
- query = select(Decision).where(Decision.groupe_id == membre.groupe_id)
- if statut:
- query = query.where(Decision.statut == statut)
- query = query.order_by(desc(Decision.date_decision)).limit(limite)
- result = await db.execute(query)
- return [await _to_out(d, db) for d in result.scalars().all()]
-
-
-@router.get("/compteur")
-async def compteur_decisions(
- membre=Depends(get_current_membre),
- db: AsyncSession = Depends(get_db),
-):
- """Nombre de décisions ouvertes (pour le badge sur la tuile)."""
- result = await db.execute(
- select(Decision).where(
- Decision.groupe_id == membre.groupe_id,
- Decision.statut.in_(["en_attente", "en_cours"]),
- )
- )
- return {"ouvertes": len(result.scalars().all())}
-
-
-@router.post("/", response_model=DecisionOut)
-async def creer_decision(
- req: DecisionCreate,
- executif=Depends(require_executif),
- db: AsyncSession = Depends(get_db),
-):
- """Créer une décision (exécutif — typiquement via le PV)."""
- decision = Decision(
- groupe_id=executif.groupe_id,
- reunion_id=req.reunion_id,
- sujet=req.sujet.strip(),
- description=req.description,
- statut=req.statut,
- responsable_id=req.responsable_id,
- proposeur=req.proposeur,
- secondeur=req.secondeur,
- date_decision=req.date_decision,
- cree_par=executif.id,
- )
- db.add(decision)
- await db.flush()
- return await _to_out(decision, db)
-
-
-@router.patch("/{decision_id}", response_model=DecisionOut)
-async def modifier_decision(
- decision_id: str,
- req: DecisionUpdate,
- executif=Depends(require_executif),
- db: AsyncSession = Depends(get_db),
-):
- """Modifier le statut d'une décision (exécutif)."""
- result = await db.execute(
- select(Decision).where(Decision.id == decision_id, Decision.groupe_id == executif.groupe_id)
- )
- decision = result.scalar_one_or_none()
- if not decision:
- raise HTTPException(status_code=404, detail="Décision introuvable")
-
- if req.statut is not None:
- decision.statut = req.statut
- if req.description is not None:
- decision.description = req.description
- if req.responsable_id is not None:
- decision.responsable_id = req.responsable_id
- if req.date_completion is not None:
- decision.date_completion = req.date_completion
- await db.flush()
- return await _to_out(decision, db)
diff --git a/backend/app/routers/depenses.py b/backend/app/routers/depenses.py
index 9fd863e..9d3a682 100644
--- a/backend/app/routers/depenses.py
+++ b/backend/app/routers/depenses.py
@@ -1,42 +1,24 @@
"""Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
import os
-import uuid as uuid_mod
-from datetime import datetime, timezone
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from datetime import date, datetime, timezone
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
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.core.roles import membre_a_poste_ou_executif, require_tresorier
+from app.core.security import get_current_membre, is_sysadmin
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
+from app.services.finance import encaisse, engagements_non_debites, solde_banque, total_reserves, transaction
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
-METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"}
+METHODES_VALIDES = {"VIREMENT", "CHEQUE", "COLLECTE"}
UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves"
-async def _est_tresorier_ou_executif(membre_id, db: AsyncSession) -> bool:
- result = await db.execute(
- select(Affectation).join(Poste).where(
- Affectation.membre_id == membre_id,
- Affectation.statut == "actif",
- (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
- )
- )
- return result.scalars().first() is not None
-
-
-async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
- if not await _est_tresorier_ou_executif(membre.id, db):
- raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
- return membre
-
-
async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut:
qui = await db.get(Membre, d.qui)
saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None
@@ -61,12 +43,21 @@ async def soumettre_depense(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
- """S5 — N'importe quel membre soumet une dépense courante."""
+ """S5 — Un membre soumet une dépense pour lui-même; un exécutif peut le faire pour un autre."""
+ qui_id = membre.id
+ if req.membre_id and req.membre_id != membre.id:
+ if not await membre_a_poste_ou_executif(membre.id, "trésorier", db):
+ raise HTTPException(status_code=403, detail="Seul un exécutif peut soumettre une dépense pour un autre membre")
+ beneficiaire = await db.get(Membre, req.membre_id)
+ if not beneficiaire or beneficiaire.groupe_id != membre.groupe_id or not beneficiaire.actif:
+ raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
+ qui_id = beneficiaire.id
+
depense = Depense(
groupe_id=membre.groupe_id,
- date_depense=req.date_depense,
+ date_depense=req.date_depense if req.date_depense and is_sysadmin(membre) else date.today(),
montant=req.montant,
- qui=membre.id, # le membre qui soumet est celui qui a payé
+ qui=qui_id,
quoi=req.quoi.strip(),
paye=False,
methode=None, # sera renseigné par le trésorier
@@ -110,7 +101,7 @@ async def telecharger_preuve(
async def confirmer_depense(
depense_id: str,
methode: str,
- membre=Depends(_require_tresorier),
+ membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme la dépense et renseigne le mode de paiement."""
@@ -124,13 +115,23 @@ async def confirmer_depense(
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
+ if depense.confirme_par is not None:
+ raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
if methode_norm == "COLLECTE":
- from app.routers.operations_bancaires import _encaisse
-
- encaisse = await _encaisse(membre.groupe_id, db)
- if float(depense.montant) > encaisse:
+ solde_encaisse = await encaisse(membre.groupe_id, db)
+ if float(depense.montant) > solde_encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
+ elif methode_norm in ("CHEQUE", "VIREMENT"):
+ solde_banque_actuel = await solde_banque(membre.groupe_id, db)
+ total_reserves_actuel = await total_reserves(membre.groupe_id, db)
+ engagements = await engagements_non_debites(membre.groupe_id, db)
+ disponible = solde_banque_actuel - total_reserves_actuel - engagements
+ if float(depense.montant) > disponible:
+ raise HTTPException(
+ status_code=400,
+ detail="Le solde bancaire disponible est insuffisant pour engager cette dépense",
+ )
depense.methode = methode_norm
depense.confirme_par = membre.id
@@ -139,8 +140,8 @@ async def confirmer_depense(
# Si cash (COLLECTE) → payé immédiatement
if methode_norm == "COLLECTE":
depense.paye = True
+ db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi))
- # Si DU → le groupe doit rembourser, pas encore payé
# Si CHEQUE/VIREMENT → engagement, en attente de débit
await db.flush()
@@ -150,7 +151,7 @@ async def confirmer_depense(
@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut)
async def confirmer_debit(
depense_id: str,
- membre=Depends(_require_tresorier),
+ membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que le chèque/virement a été débité du compte."""
@@ -165,20 +166,46 @@ async def confirmer_debit(
if depense.methode not in ("CHEQUE", "VIREMENT"):
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
- from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
-
- solde_banque = await _solde_banque_brut(membre.groupe_id, db)
- if float(depense.montant) > solde_banque:
+ solde_banque_actuel = await solde_banque(membre.groupe_id, db)
+ if float(depense.montant) > solde_banque_actuel:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
- total_reserves = await _total_reserves(membre.groupe_id, db)
- if solde_banque - float(depense.montant) < total_reserves:
+ total_reserves_actuel = await total_reserves(membre.groupe_id, db)
+ if solde_banque_actuel - float(depense.montant) < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
depense.paye = True
+ depense.date_confirmation = datetime.now(timezone.utc)
+ db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi))
await db.flush()
return await _to_out(depense, db)
+@router.delete("/{depense_id}")
+async def rejeter_depense(
+ depense_id: str,
+ membre=Depends(require_tresorier),
+ db: AsyncSession = Depends(get_db),
+):
+ """Rejeter une dépense soumise : elle est supprimée du registre."""
+ 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 depense.confirme_par is not None:
+ raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées")
+
+ if depense.preuve_nom:
+ chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom)
+ if os.path.exists(chemin):
+ os.remove(chemin)
+
+ await db.delete(depense)
+ await db.flush()
+ return {"message": "Dépense rejetée"}
+
+
@router.get("/", response_model=list[DepenseOut])
async def liste_depenses(
limite: int = 30,
@@ -204,7 +231,7 @@ async def liste_depenses(
@router.get("/a-confirmer")
async def depenses_a_confirmer(
- membre=Depends(_require_tresorier),
+ membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Dépenses soumises en attente de confirmation par le trésorier."""
@@ -221,7 +248,7 @@ async def depenses_a_confirmer(
async def modifier_depense(
depense_id: str,
req: DepenseUpdate,
- membre=Depends(_require_tresorier),
+ membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Modifier une dépense (trésorier)."""
diff --git a/backend/app/routers/evenements.py b/backend/app/routers/evenements.py
index 35f55c7..2373dc3 100644
--- a/backend/app/routers/evenements.py
+++ b/backend/app/routers/evenements.py
@@ -1,8 +1,8 @@
-"""Routes — Événements (anniversaires + ad hoc + résolutions)."""
+"""Routes — Événements (anniversaires + ad hoc)."""
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, desc
+from sqlalchemy import select
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif
@@ -11,7 +11,6 @@ from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.evenement import Evenement
-from app.models.proposition import Proposition
router = APIRouter(prefix="/evenements", tags=["Événements"])
@@ -31,7 +30,7 @@ async def evenements_a_venir(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
- """Tous les événements : anniversaires, ad hoc et résolutions."""
+ """Tous les événements : anniversaires et ad hoc."""
aujourdhui = date.today()
debut = aujourdhui - timedelta(days=jours_avant)
limite = aujourdhui + timedelta(days=jours)
@@ -84,24 +83,6 @@ async def evenements_a_venir(
"groupe_nom": groupe_nom,
})
- # ── 3. Résolutions adoptées du groupe ──
- result = await db.execute(
- select(Proposition).where(
- Proposition.groupe_id == membre.groupe_id,
- Proposition.statut == "adoptee",
- ).order_by(desc(Proposition.date_vote))
- )
- for p in result.scalars().all():
- date_res = str(p.date_vote) if p.date_vote else str(p.cree_le.date())
- evenements.append({
- "type": "resolution",
- "date": date_res,
- "titre": f"📜 {p.numero_resolution or 'RES'} — {p.sujet}",
- "detail": p.description or p.note,
- "lieu": None,
- "groupe_nom": groupe.nom if groupe else "?",
- })
-
# Trier par date
evenements.sort(key=lambda e: e["date"])
return evenements
diff --git a/backend/app/routers/jetons.py b/backend/app/routers/jetons.py
index bb24a88..337c765 100644
--- a/backend/app/routers/jetons.py
+++ b/backend/app/routers/jetons.py
@@ -4,7 +4,7 @@ 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.core.security import get_current_membre, is_sysadmin
from app.models.jeton import Jeton
from app.models.membre import Membre
from app.models.affectation import Affectation
@@ -16,11 +16,13 @@ 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."""
+ if is_sysadmin(membre):
+ return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
- Poste.nom.ilike("%jeton%"),
+ (Poste.nom.ilike("%jeton%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():
diff --git a/backend/app/routers/journal.py b/backend/app/routers/journal.py
new file mode 100644
index 0000000..ad2e04b
--- /dev/null
+++ b/backend/app/routers/journal.py
@@ -0,0 +1,41 @@
+"""Consultation du journal d'audit."""
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.security import require_executif
+from app.models.journal_action import JournalAction
+
+router = APIRouter(prefix="/journal", tags=["Journal"])
+
+
+@router.get("/actions")
+async def journal_actions(
+ limite: int = 100,
+ executif=Depends(require_executif),
+ db: AsyncSession = Depends(get_db),
+):
+ limite = max(1, min(limite, 500))
+ result = await db.execute(
+ select(JournalAction)
+ .where(JournalAction.groupe_id == executif.groupe_id)
+ .order_by(JournalAction.cree_le.desc())
+ .limit(limite)
+ )
+ return [
+ {
+ "id": str(item.id),
+ "groupe_id": str(item.groupe_id) if item.groupe_id else None,
+ "membre_id": str(item.membre_id) if item.membre_id else None,
+ "admin_id": str(item.admin_id) if item.admin_id else None,
+ "methode": item.methode,
+ "chemin": item.chemin,
+ "statut_http": item.statut_http,
+ "adresse_ip": item.adresse_ip,
+ "user_agent": item.user_agent,
+ "details": item.details,
+ "cree_le": item.cree_le.isoformat(),
+ }
+ for item in result.scalars().all()
+ ]
diff --git a/backend/app/routers/litterature.py b/backend/app/routers/litterature.py
index 9038752..de4f67c 100644
--- a/backend/app/routers/litterature.py
+++ b/backend/app/routers/litterature.py
@@ -4,7 +4,7 @@ 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.core.security import get_current_membre, is_sysadmin
from app.models.litterature import Litterature
from app.models.affectation import Affectation
from app.models.poste import Poste
@@ -15,6 +15,8 @@ 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."""
+ if is_sysadmin(membre):
+ return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py
new file mode 100644
index 0000000..6828928
--- /dev/null
+++ b/backend/app/routers/notifications.py
@@ -0,0 +1,91 @@
+"""Routes de notifications Web Push."""
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+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
+
+router = APIRouter(prefix="/notifications", tags=["Notifications"])
+
+
+class PushSubscriptionIn(BaseModel):
+ endpoint: str
+ expirationTime: int | None = None
+ keys: dict
+
+
+@router.get("/configuration")
+async def configuration_notifications():
+ return {
+ "enabled": notifications_configured(),
+ "public_key": settings.VAPID_PUBLIC_KEY,
+ }
+
+
+@router.post("/abonnement")
+async def enregistrer_abonnement(
+ abonnement: PushSubscriptionIn,
+ request: Request,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ if not notifications_configured():
+ raise HTTPException(status_code=400, detail="Les notifications ne sont pas configurées sur ce serveur")
+ result = await db.execute(select(PushSubscription).where(PushSubscription.endpoint == abonnement.endpoint))
+ sub = result.scalar_one_or_none()
+ if sub:
+ sub.groupe_id = membre.groupe_id
+ sub.membre_id = membre.id
+ sub.abonnement = abonnement.model_dump()
+ sub.user_agent = (request.headers.get("user-agent") or "")[:255] or None
+ else:
+ db.add(PushSubscription(
+ groupe_id=membre.groupe_id,
+ membre_id=membre.id,
+ endpoint=abonnement.endpoint,
+ abonnement=abonnement.model_dump(),
+ user_agent=(request.headers.get("user-agent") or "")[:255] or None,
+ ))
+ membre.push_sub = abonnement.model_dump()
+ await db.flush()
+ return {"message": "Notifications activées"}
+
+
+@router.delete("/abonnement")
+async def retirer_abonnement(
+ endpoint: str,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(PushSubscription).where(
+ PushSubscription.endpoint == endpoint,
+ PushSubscription.membre_id == membre.id,
+ )
+ )
+ sub = result.scalar_one_or_none()
+ if sub:
+ await db.delete(sub)
+ membre.push_sub = None
+ await db.flush()
+ return {"message": "Notifications désactivées"}
+
+
+@router.post("/test")
+async def envoyer_test(
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(PushSubscription).where(PushSubscription.membre_id == membre.id))
+ sent = await send_to_subscriptions(db, result.scalars().all(), {
+ "title": "Notifications activées",
+ "body": "Cet appareil recevra les avis du groupe.",
+ "url": "/",
+ })
+ await db.flush()
+ return {"message": "Notification de test envoyée", "sent": sent}
diff --git a/backend/app/routers/operations_bancaires.py b/backend/app/routers/operations_bancaires.py
index c662fc8..79b0e25 100644
--- a/backend/app/routers/operations_bancaires.py
+++ b/backend/app/routers/operations_bancaires.py
@@ -1,128 +1,87 @@
"""Routes — Opérations bancaires (dépôts, retraits)."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, desc, func
+from sqlalchemy import select, desc
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.operation_bancaire import OperationBancaire
-from app.models.collecte import Collecte
-from app.models.depense import Depense
+from app.models.transaction_comptable import TransactionComptable
+from app.models.mouvement_reserve import MouvementReserve
from app.models.reserve import Reserve
-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.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveUpdate
+from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
+from app.services.finance import encaisse, reserve_disponible, solde_banque, solde_reserve, total_reserves
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
-async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
- """Vérifie que le membre est trésorier ou exécutif."""
- result = await db.execute(
- select(Affectation).join(Poste).where(
- Affectation.membre_id == membre.id,
- Affectation.statut == "actif",
- (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
- )
+def _mouvement_reserve(groupe_id, membre_id, montant, type_mouvement, source_id=None, destination_id=None, note=None):
+ return MouvementReserve(
+ groupe_id=groupe_id,
+ source_id=source_id,
+ destination_id=destination_id,
+ montant=montant,
+ type=type_mouvement,
+ note=note,
+ cree_par=membre_id,
)
- if not result.scalars().first():
- raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
- return membre
-async def _encaisse(groupe_id, db: AsyncSession) -> float:
- total_collectes = float((await db.execute(
- select(func.coalesce(func.sum(Collecte.montant), 0)).where(
- Collecte.groupe_id == groupe_id, Collecte.statut == "recu")
- )).scalar())
- ventes_recues = float((await db.execute(
- select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
- VenteLitterature.groupe_id == groupe_id, VenteLitterature.remis_tresorier == True)
- )).scalar())
- retraits = float((await db.execute(
- select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait")
- )).scalar())
- depots = float((await db.execute(
- select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot")
- )).scalar())
- depenses_cash = float((await db.execute(
- select(func.coalesce(func.sum(Depense.montant), 0)).where(
- Depense.groupe_id == groupe_id, Depense.paye == True, Depense.methode == "COLLECTE")
- )).scalar())
- return total_collectes + ventes_recues + retraits - depots - depenses_cash
-
-
-async def _solde_banque_brut(groupe_id, db: AsyncSession) -> float:
- from app.models.envoi_contribution import EnvoiContribution
-
- result = await db.execute(
- select(OperationBancaire).where(
- OperationBancaire.groupe_id == groupe_id,
- OperationBancaire.type == "releve",
- ).order_by(desc(OperationBancaire.date_operation)).limit(1)
+def _ligne_comptable(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str):
+ return TransactionComptable(
+ groupe_id=groupe_id,
+ date_transaction=op.date_operation,
+ type=op.type,
+ compte=compte,
+ montant=op.montant,
+ sens=sens,
+ source_table="operations_bancaires",
+ source_id=op.id,
+ note=op.note,
+ cree_par=membre_id,
)
- dernier_releve = result.scalar_one_or_none()
- solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
- filtre_depuis = dernier_releve.date_operation if dernier_releve else None
- q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot")
- q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
- OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait")
- q_contribs = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
- EnvoiContribution.groupe_id == groupe_id, EnvoiContribution.debitee == True)
- q_depenses = select(func.coalesce(func.sum(Depense.montant), 0)).where(
- Depense.groupe_id == groupe_id,
- Depense.paye == True,
- Depense.methode.in_(["VIREMENT", "CHEQUE"]),
+
+def _ligne_comptable_montant(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str, montant: float):
+ return TransactionComptable(
+ groupe_id=groupe_id,
+ date_transaction=op.date_operation,
+ type=op.type,
+ compte=compte,
+ montant=montant,
+ sens=sens,
+ source_table="operations_bancaires",
+ source_id=op.id,
+ note=op.note,
+ cree_par=membre_id,
)
- if filtre_depuis:
- q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis)
- q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis)
- q_contribs = q_contribs.where(EnvoiContribution.date_envoi > filtre_depuis)
- q_depenses = q_depenses.where(Depense.date_confirmation > filtre_depuis)
-
- depots = float((await db.execute(q_depots)).scalar())
- retraits = float((await db.execute(q_retraits)).scalar())
- contribs = float((await db.execute(q_contribs)).scalar())
- depenses = float((await db.execute(q_depenses)).scalar())
- return solde_releve + depots - retraits - contribs - depenses
-
-
-async def _total_reserves(groupe_id, db: AsyncSession, exclure_id: str | None = None) -> float:
- query = select(func.coalesce(func.sum(Reserve.montant), 0)).where(
- Reserve.groupe_id == groupe_id,
- Reserve.active == True,
- )
- if exclure_id:
- query = query.where(Reserve.id != exclure_id)
- return float((await db.execute(query)).scalar())
@router.post("/", response_model=OperationBancaireOut)
async def enregistrer_operation(
req: OperationBancaireCreate,
- membre=Depends(_require_tresorier),
+ membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Enregistrer un dépôt ou retrait bancaire (trésorier)."""
if req.type not in ("depot", "retrait", "releve"):
raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou 'releve'")
+ provenance_depot = (req.provenance_depot or "encaisse").lower()
+ if req.type == "depot" and provenance_depot not in ("encaisse", "don"):
+ raise HTTPException(status_code=400, detail="Provenance du dépôt invalide")
if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
- solde_banque = await _solde_banque_brut(membre.groupe_id, db)
- total_reserves = await _total_reserves(membre.groupe_id, db)
- if req.type == "depot" and req.montant > await _encaisse(membre.groupe_id, db):
+ solde_banque_actuel = await solde_banque(membre.groupe_id, db)
+ total_reserves_actuel = await total_reserves(membre.groupe_id, db)
+ if req.type == "depot" and provenance_depot == "encaisse" and req.montant > await encaisse(membre.groupe_id, db):
raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible")
- if req.type == "retrait" and req.montant > solde_banque:
+ if req.type == "retrait" and req.montant > solde_banque_actuel:
raise HTTPException(status_code=400, detail="Le retrait ne peut pas dépasser le solde en banque")
- if req.type == "retrait" and solde_banque - req.montant < total_reserves:
+ if req.type == "retrait" and solde_banque_actuel - req.montant < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Le retrait ferait dépasser les réserves par rapport au solde en banque")
- if req.type == "releve" and req.montant < total_reserves:
+ if req.type == "releve" and req.montant < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
op = OperationBancaire(
@@ -131,10 +90,39 @@ async def enregistrer_operation(
montant=req.montant,
date_operation=req.date_operation,
effectue_par=membre.id,
- note=req.note,
+ note=req.note or ("Don déposé directement au compte bancaire" if provenance_depot == "don" else None),
)
db.add(op)
await db.flush()
+ if op.type == "depot":
+ if provenance_depot == "don":
+ db.add(TransactionComptable(
+ groupe_id=membre.groupe_id,
+ date_transaction=op.date_operation,
+ type="don",
+ compte="banque",
+ montant=op.montant,
+ sens="debit",
+ source_table="operations_bancaires",
+ source_id=op.id,
+ note=op.note,
+ cree_par=membre.id,
+ ))
+ else:
+ db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "debit"))
+ db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "credit"))
+ elif op.type == "retrait":
+ db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "credit"))
+ db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit"))
+ else:
+ delta = req.montant - solde_banque_actuel
+ if delta != 0:
+ db.add(_ligne_comptable_montant(
+ membre.groupe_id, membre.id, op, "banque",
+ "debit" if delta > 0 else "credit",
+ abs(delta),
+ ))
+ await db.flush()
return OperationBancaireOut(
id=op.id,
type=op.type,
@@ -153,55 +141,154 @@ async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession =
.order_by(Reserve.nom)
)
return [
- ReserveOut(id=r.id, nom=r.nom, montant=float(r.montant), note=r.note, active=r.active, cree_le=r.cree_le)
+ ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le)
for r in result.scalars().all()
]
+@router.get("/registre-comptable", response_model=list[TransactionComptableOut])
+async def registre_comptable(
+ limite: int = 200,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(TransactionComptable)
+ .where(TransactionComptable.groupe_id == membre.groupe_id)
+ .order_by(desc(TransactionComptable.date_transaction), desc(TransactionComptable.cree_le))
+ .limit(limite)
+ )
+ transactions = result.scalars().all()
+ out = []
+ for tx in transactions:
+ createur = await db.get(Membre, tx.cree_par) if tx.cree_par else None
+ out.append(TransactionComptableOut(
+ id=tx.id,
+ date_transaction=tx.date_transaction,
+ type=tx.type,
+ compte=tx.compte,
+ montant=float(tx.montant),
+ sens=tx.sens,
+ source_table=tx.source_table,
+ source_id=tx.source_id,
+ note=tx.note,
+ cree_par_prenom=createur.prenom if createur else None,
+ cree_le=tx.cree_le,
+ ))
+ return out
+
+
@router.post("/reserves", response_model=ReserveOut)
-async def creer_reserve(req: ReserveCreate, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
+async def creer_reserve(req: ReserveCreate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
- if req.montant < 0:
- raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul")
- total = await _total_reserves(membre.groupe_id, db) + req.montant
- solde_banque = await _solde_banque_brut(membre.groupe_id, db)
- if total > solde_banque:
- raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque")
- reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=req.montant, note=req.note)
+ if req.montant != 0:
+ raise HTTPException(status_code=400, detail="Créer la réserve à zéro, puis utiliser un virement pour l'approvisionner")
+ reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=0, note=req.note)
db.add(reserve)
await db.flush()
- return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
+ return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
@router.patch("/reserves/{reserve_id}", response_model=ReserveOut)
-async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
+async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
- montant = float(reserve.montant) if req.montant is None else req.montant
- if montant < 0:
- raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul")
- total = await _total_reserves(membre.groupe_id, db, reserve_id) + montant
- solde_banque = await _solde_banque_brut(membre.groupe_id, db)
- if total > solde_banque:
- raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque")
+ ancien_montant = await solde_reserve(reserve.id, db)
+ if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2):
+ raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement")
if req.nom is not None:
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
reserve.nom = req.nom.strip()
- reserve.montant = montant
if req.note is not None:
reserve.note = req.note.strip() or None
+ reserve.montant = await solde_reserve(reserve.id, db)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
+@router.get("/reserves/mouvements", response_model=list[ReserveMouvementOut])
+async def liste_mouvements_reserves(
+ limite: int = 100,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(MouvementReserve)
+ .where(MouvementReserve.groupe_id == membre.groupe_id)
+ .order_by(desc(MouvementReserve.cree_le))
+ .limit(limite)
+ )
+ mouvements = result.scalars().all()
+ out = []
+ for mv in mouvements:
+ source = await db.get(Reserve, mv.source_id) if mv.source_id else None
+ destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None
+ createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None
+ out.append(ReserveMouvementOut(
+ id=mv.id,
+ source_id=mv.source_id,
+ source_nom=source.nom if source else "Disponible",
+ destination_id=mv.destination_id,
+ destination_nom=destination.nom if destination else "Disponible",
+ montant=float(mv.montant),
+ type=mv.type,
+ note=mv.note,
+ cree_par_prenom=createur.prenom if createur else None,
+ cree_le=mv.cree_le,
+ ))
+ return out
+
+
+@router.post("/reserves/transfert")
+async def transferer_reserve(req: ReserveTransfer, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
+ if req.montant <= 0:
+ raise HTTPException(status_code=400, detail="Le montant doit être positif")
+ if req.source_id == req.destination_id:
+ raise HTTPException(status_code=400, detail="La source et la destination doivent être différentes")
+
+ source = None if req.source_id == "disponible" else await db.get(Reserve, req.source_id)
+ destination = None if req.destination_id == "disponible" else await db.get(Reserve, req.destination_id)
+
+ if req.source_id != "disponible" and (not source or source.groupe_id != membre.groupe_id or not source.active):
+ raise HTTPException(status_code=404, detail="Réserve source introuvable")
+ if req.destination_id != "disponible" and (not destination or destination.groupe_id != membre.groupe_id or not destination.active):
+ raise HTTPException(status_code=404, detail="Réserve destination introuvable")
+
+ if req.source_id == "disponible":
+ disponible = await reserve_disponible(membre.groupe_id, db)
+ if req.montant > disponible:
+ raise HTTPException(status_code=400, detail="La réserve disponible est insuffisante")
+ elif req.montant > await solde_reserve(source.id, db):
+ raise HTTPException(status_code=400, detail="La réserve source est insuffisante")
+
+ if source:
+ source.montant = await solde_reserve(source.id, db) - req.montant
+ if destination:
+ destination.montant = await solde_reserve(destination.id, db) + req.montant
+
+ db.add(_mouvement_reserve(
+ membre.groupe_id,
+ membre.id,
+ req.montant,
+ "transfert",
+ source_id=source.id if source else None,
+ destination_id=destination.id if destination else None,
+ ))
+ await db.flush()
+ return {"message": "Transfert effectué"}
+
+
@router.delete("/reserves/{reserve_id}")
-async def supprimer_reserve(reserve_id: str, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
+async def supprimer_reserve(reserve_id: str, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
+ montant = await solde_reserve(reserve.id, db)
+ if round(montant, 2) != 0:
+ raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée")
reserve.active = False
await db.flush()
return {"message": "Réserve supprimée"}
diff --git a/backend/app/routers/parametres.py b/backend/app/routers/parametres.py
index cef229f..1d682cb 100644
--- a/backend/app/routers/parametres.py
+++ b/backend/app/routers/parametres.py
@@ -10,6 +10,8 @@ from app.schemas.phase3 import GroupeUpdate, GroupeDetail
router = APIRouter(prefix="/groupe", tags=["G9 — Paramètres"])
+ORDRES_ASSEMBLEE = {"premiere", "deuxieme", "troisieme", "quatrieme", "derniere"}
+
@router.get("/", response_model=GroupeDetail)
async def detail_groupe(
@@ -45,6 +47,10 @@ async def modifier_groupe(
groupe.heure_reunion = None
else:
groupe.heure_reunion = None
+ if req.assemblee_affaires_ordre is not None:
+ ordre = req.assemblee_affaires_ordre.strip()
+ if ordre in ORDRES_ASSEMBLEE:
+ groupe.assemblee_affaires_ordre = ordre
if req.format_reunion is not None:
groupe.format_reunion = req.format_reunion.strip()
if req.lien_aa87 is not None:
diff --git a/backend/app/routers/postes.py b/backend/app/routers/postes.py
index 7e4f714..9b6fd51 100644
--- a/backend/app/routers/postes.py
+++ b/backend/app/routers/postes.py
@@ -2,9 +2,10 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func
+from sqlalchemy import select
from app.core.database import get_db
+from app.core.decisions import prochain_numero_decision
from app.core.security import get_current_membre, require_executif
from app.models.poste import Poste
from app.models.affectation import Affectation
@@ -16,20 +17,6 @@ from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, Termin
router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
-async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str:
- from app.models.proposition import Proposition
-
- result = await db.execute(
- select(func.count(Proposition.id)).where(
- Proposition.groupe_id == groupe_id,
- Proposition.numero_resolution.isnot(None),
- Proposition.numero_resolution.like(f"RES-{annee}-%"),
- )
- )
- count = result.scalar() or 0
- return f"RES-{annee}-{count + 1:03d}"
-
-
# ── Postes ──
@router.get("/", response_model=list[PosteOut])
@@ -501,7 +488,7 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce poste est déjà affecté")
- numero_resolution = await _prochain_numero_resolution(executif.groupe_id, req.date_debut.year, db)
+ numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", req.date_debut.year, db)
resolution = Proposition(
groupe_id=executif.groupe_id,
type_proposition="nomination",
@@ -512,6 +499,8 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=req.date_debut,
+ date_adoption=req.date_debut,
+ groupe_a_decide_de=f"Nommer {cible.prenom} au poste de {poste.nom}.",
)
db.add(resolution)
await db.flush()
diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py
index 5656751..3696e5c 100644
--- a/backend/app/routers/presences.py
+++ b/backend/app/routers/presences.py
@@ -1,50 +1,22 @@
"""Routes M2 — Présences en réunion d'affaires."""
-from datetime import date as dt_date
-from calendar import monthrange
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
from app.core.database import get_db
+from app.core.roles import require_secretaire
+from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu
from app.core.security import get_current_membre
from app.models.reunion import Reunion
from app.models.groupe import Groupe
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"])
-JOURS_SEMAINE = {
- "lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
- "vendredi": 4, "samedi": 5, "dimanche": 6,
-}
-
-
-def _premier_jour_du_mois(annee: int, mois: int, jour_cible: int) -> dt_date:
- """Retourne la date du premier 'jour_cible' (0=lundi) du mois."""
- premier = dt_date(annee, mois, 1)
- diff = (jour_cible - premier.weekday()) % 7
- return dt_date(annee, mois, 1 + diff)
-
-
-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.scalars().first():
- 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(
@@ -55,16 +27,17 @@ async def creer_reunion_affaires(
"""Créer une réunion d'affaires (point de départ pour présences et PV)."""
groupe = await db.get(Groupe, membre.groupe_id)
- # ── Validation : premier jour de réunion du mois, une seule par mois ──
+ # ── Validation : réunion d'affaires prévue dans les paramètres du groupe ──
dt = req.date_reunion
if groupe.jour_reunion:
- jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
+ jour_attendu = jour_reunion_attendu(groupe)
if jour_attendu is not None:
- premier = _premier_jour_du_mois(dt.year, dt.month, jour_attendu)
- if dt.date() != premier:
+ ordre = groupe.assemblee_affaires_ordre or "premiere"
+ date_attendue = date_assemblee_affaires_du_mois(dt.year, dt.month, jour_attendu, ordre)
+ if dt.date() != date_attendue:
raise HTTPException(
status_code=400,
- detail=f"La réunion d'affaires doit avoir lieu le premier {groupe.jour_reunion} du mois ({premier.isoformat()})",
+ detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
)
# Une seule réunion d'affaires par mois
diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py
index 6e55934..823e707 100644
--- a/backend/app/routers/propositions.py
+++ b/backend/app/routers/propositions.py
@@ -2,9 +2,10 @@
from datetime import date, timezone, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, desc, extract, func
+from sqlalchemy import select, desc, extract
from app.core.database import get_db
+from app.core.decisions import prochain_numero_decision
from app.core.security import get_current_membre, require_executif
from app.models.proposition import Proposition
from app.models.reunion import Reunion
@@ -24,7 +25,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
return PropositionOut(
id=p.id,
reunion_id=p.reunion_id,
- type_proposition=p.type_proposition or "generale",
+ type_proposition=p.type_proposition or "proposition",
sujet=p.sujet,
description=p.description,
proposeur_prenom=proposeur.prenom if proposeur else "?",
@@ -32,6 +33,9 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
statut=p.statut,
numero_resolution=p.numero_resolution,
date_vote=p.date_vote,
+ date_adoption=p.date_adoption,
+ etant_donne_que=p.etant_donne_que,
+ groupe_a_decide_de=p.groupe_a_decide_de,
note=p.note,
candidature_id=p.candidature_id,
cree_le=p.cree_le,
@@ -39,19 +43,6 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
)
-async def _prochain_numero(groupe_id, annee: int, db: AsyncSession) -> str:
- """Génère le prochain numéro de résolution : RES-2026-001."""
- result = await db.execute(
- select(func.count(Proposition.id)).where(
- Proposition.groupe_id == groupe_id,
- Proposition.numero_resolution.isnot(None),
- Proposition.numero_resolution.like(f"RES-{annee}-%"),
- )
- )
- count = result.scalar() or 0
- return f"RES-{annee}-{count + 1:03d}"
-
-
@router.post("/", response_model=PropositionOut)
async def creer_proposition(
req: PropositionCreate,
@@ -72,7 +63,7 @@ async def creer_proposition(
prop = Proposition(
groupe_id=membre.groupe_id,
reunion_id=req.reunion_id,
- type_proposition=req.type_proposition or "generale",
+ type_proposition="proposition",
sujet=req.sujet.strip(),
description=req.description,
proposeur_id=membre.id,
@@ -130,18 +121,28 @@ async def voter_proposition(
prop = result.scalar_one_or_none()
if not prop:
raise HTTPException(status_code=404, detail="Proposition introuvable")
- if prop.statut != "secondee":
- raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être mise au vote")
-
- prop.statut = req.statut
- prop.date_vote = date.today()
- if req.note:
- prop.note = req.note.strip()
+ if req.statut == "adoptee" and prop.statut != "secondee":
+ raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être transformée en résolution")
+ if req.statut == "rejetee" and prop.statut not in ("proposee", "secondee"):
+ raise HTTPException(status_code=400, detail="Seule une proposition en cours peut être rejetée")
# Attribuer un numéro de résolution si adoptée
if req.statut == "adoptee":
- prop.numero_resolution = await _prochain_numero(
- executif.groupe_id, date.today().year, db
+ etant_donne_que = (req.etant_donne_que or "").strip()
+ groupe_a_decide_de = (req.groupe_a_decide_de or "").strip()
+ if not etant_donne_que or not groupe_a_decide_de:
+ raise HTTPException(
+ status_code=400,
+ detail='Les sections "ÉTANT DONNÉ QUE" et "LE GROUPE A DÉCIDÉ DE" sont obligatoires',
+ )
+
+ prop.etant_donne_que = etant_donne_que
+ prop.groupe_a_decide_de = groupe_a_decide_de
+ prop.note = None
+ if prop.type_proposition not in ("nomination", "contribution"):
+ prop.type_proposition = "resolution"
+ prop.numero_resolution = await prochain_numero_decision(
+ executif.groupe_id, prop.type_proposition, date.today().year, db
)
if req.reunion_id and not prop.reunion_id:
prop.reunion_id = req.reunion_id
@@ -162,12 +163,20 @@ async def voter_proposition(
db.add(affectation)
elif req.statut == "rejetee":
+ if req.note:
+ prop.note = req.note.strip()
+
# Si nomination rejetée, mettre la candidature en refusée
if prop.type_proposition == "nomination" and prop.candidature_id:
cand = await db.get(Candidature, prop.candidature_id)
if cand:
cand.statut = "refusee"
+ prop.statut = req.statut
+ prop.date_vote = date.today()
+ if req.statut == "adoptee":
+ prop.date_adoption = prop.date_vote
+
await db.flush()
return await _to_out(prop, db)
@@ -211,7 +220,7 @@ async def registre_resolutions(
query = query.where(extract("year", Proposition.cree_le) == annee)
if type_proposition:
query = query.where(Proposition.type_proposition == type_proposition)
- query = query.order_by(Proposition.numero_resolution)
+ query = query.order_by(Proposition.date_vote, Proposition.cree_le, Proposition.numero_resolution)
result = await db.execute(query)
return [await _to_out(p, db) for p in result.scalars().all()]
diff --git a/backend/app/routers/pv.py b/backend/app/routers/pv.py
index 03713ab..5eef6bb 100644
--- a/backend/app/routers/pv.py
+++ b/backend/app/routers/pv.py
@@ -4,31 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
+from app.core.roles import require_secretaire
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
+from app.schemas.phase4 import PvAdoption, 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.scalars().first():
- 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
@@ -110,23 +95,6 @@ async def creer_pv(
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)
@@ -155,3 +123,35 @@ async def modifier_pv(
pv.approuve = req.approuve
await db.flush()
return await _to_out(pv, db)
+
+
+@router.patch("/{pv_id}/adopter", response_model=PvOut)
+async def adopter_pv(
+ pv_id: str,
+ req: PvAdoption,
+ membre=Depends(require_secretaire),
+ 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")
+ if not req.proposeur.strip() or not req.secondeur.strip():
+ raise HTTPException(status_code=400, detail="Proposeur et secondeur requis")
+
+ decisions = list(pv.decisions or [])
+ decisions.append({
+ "sujet": "Adoption du procès-verbal précédent",
+ "decision": "Le procès-verbal précédent est adopté.",
+ "proposeur": req.proposeur.strip(),
+ "secondeur": req.secondeur.strip(),
+ "statut": "complete",
+ })
+ pv.decisions = decisions
+ pv.approuve = True
+ await db.flush()
+ return await _to_out(pv, db)
diff --git a/backend/app/routers/rapports.py b/backend/app/routers/rapports.py
index 46c53b5..72be14a 100644
--- a/backend/app/routers/rapports.py
+++ b/backend/app/routers/rapports.py
@@ -1,6 +1,7 @@
"""Routes C1 — Rapports PDF (finances, PV, rapport RSG)."""
import io
-from datetime import date, datetime
+from html import escape
+from datetime import date, datetime, time, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,6 +25,19 @@ MOIS_FR = [
]
+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:
+ return p.groupe_a_decide_de
+ if not p.note:
+ return p.sujet
+ marqueur = "LE GROUPE A DÉCIDÉ DE"
+ if marqueur not in p.note:
+ return p.sujet
+ decision = p.note.split(marqueur, 1)[1].strip()
+ return decision or p.sujet
+
+
def _css():
return """
body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; }
@@ -355,33 +369,34 @@ async def rapport_resolutions(
for p in propositions:
proposeur = await db.get(Membre, p.proposeur_id) if p.proposeur_id else None
secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None
- badge = '✓ Adoptée' if p.statut == "adoptee" else '✗ Rejetée'
+ numero_date = f"{escape(p.numero_resolution or '—')} {escape(str(p.date_adoption or p.date_vote or '—'))}"
+ resolution = escape(_texte_decision_resolution(p)).replace("\n", " ")
+ proposeurs = f"{escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else '—')}"
rows += f"""
-
{p.numero_resolution or '—'}
-
{p.date_vote or '—'}
-
{p.sujet}
-
{proposeur.prenom if proposeur else '—'}
-
{secondeur.prenom if secondeur else '—'}
-
{badge}
+
{numero_date}
+
{resolution}
+
{proposeurs}
"""
- nb_adoptees = sum(1 for p in propositions if p.statut == "adoptee")
- nb_rejetees = sum(1 for p in propositions if p.statut == "rejetee")
-
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'}