diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 1df9dd3..e911bca 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -6,6 +6,7 @@ groupe_meditation_domain: groupe-meditation.87-16.org groupe_meditation_app_dir: /opt/groupe-meditation groupe_meditation_backend_dir: "{{ groupe_meditation_app_dir }}/backend" groupe_meditation_deploy_dir: "{{ groupe_meditation_app_dir }}/deploy" +groupe_meditation_data_dir: "{{ groupe_meditation_app_dir }}/data" groupe_meditation_frontend_dir: "{{ groupe_meditation_app_dir }}/frontend" groupe_meditation_web_root: /var/www/groupe-meditation groupe_meditation_upload_dir: "{{ groupe_meditation_app_dir }}/uploads/preuves" diff --git a/ansible/roles/app/tasks/main.yml b/ansible/roles/app/tasks/main.yml index 2833f7b..aa1ba13 100644 --- a/ansible/roles/app/tasks/main.yml +++ b/ansible/roles/app/tasks/main.yml @@ -26,6 +26,7 @@ group: root - path: "{{ groupe_meditation_upload_dir }}" mode: "0755" + - path: "{{ groupe_meditation_data_dir }}/import" - path: "{{ groupe_meditation_web_root }}" - name: Synchroniser le backend @@ -47,6 +48,13 @@ delete: true notify: Redemarrer groupe-meditation +- name: Synchroniser les donnees d'import + ansible.posix.synchronize: + src: "{{ playbook_dir }}/../data/" + dest: "{{ groupe_meditation_data_dir }}/" + delete: true + notify: Redemarrer groupe-meditation + - name: Synchroniser le frontend ansible.posix.synchronize: src: "{{ playbook_dir }}/../frontend/" @@ -163,14 +171,17 @@ ansible.builtin.shell: cmd: | set -euo pipefail - printf '%s\n%s\n%s\n%s\n%s\n%s\n' \ - {{ groupe_meditation_first_member.prenom | quote }} \ - {{ groupe_meditation_first_member.nom | default("") | quote }} \ - {{ groupe_meditation_first_member.courriel | quote }} \ - {{ groupe_meditation_first_member.telephone | quote }} \ - {{ groupe_meditation_first_member.pin | quote }} \ - {{ groupe_meditation_first_member.poste_numero | default("1") | quote }} \ - | {{ groupe_meditation_backend_dir }}/venv/bin/python ../deploy/seed.py + { + printf '%s\n%s\n%s\n%s\n%s\n' \ + {{ groupe_meditation_first_member.prenom | quote }} \ + {{ groupe_meditation_first_member.nom | default("") | quote }} \ + {{ groupe_meditation_first_member.courriel | quote }} \ + {{ groupe_meditation_first_member.telephone | quote }} \ + {{ groupe_meditation_first_member.pin | quote }} + {% if groupe_meditation_first_member.courriel | lower != "sysadmin" %} + printf '%s\n' {{ groupe_meditation_first_member.poste_numero | default("1") | quote }} + {% endif %} + } | {{ groupe_meditation_backend_dir }}/venv/bin/python ../deploy/seed.py executable: /bin/bash chdir: "{{ groupe_meditation_backend_dir }}" when: diff --git a/backend/app/main.py b/backend/app/main.py index 8da8579..704bc3d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,6 +25,7 @@ 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 +from app.models.destinataire_contribution import DestinataireContribution # noqa: F401 @asynccontextmanager @@ -38,10 +39,23 @@ async def lifespan(app: FastAPI): 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( + "ALTER TABLE envois_contributions " + "ALTER COLUMN destination TYPE VARCHAR(120) USING destination::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")) + await conn.execute(text( + "INSERT INTO destinataires_contributions (id, groupe_id, nom, protege, actif, cree_le) " + "SELECT CAST(md5(random()::text || clock_timestamp()::text) AS uuid), g.id, 'District 87-16', true, true, now() " + "FROM groupes g " + "WHERE NOT EXISTS (" + " SELECT 1 FROM destinataires_contributions d " + " WHERE d.groupe_id = g.id AND d.nom = 'District 87-16'" + ")" + )) yield await engine.dispose() diff --git a/backend/app/models/destinataire_contribution.py b/backend/app/models/destinataire_contribution.py new file mode 100644 index 0000000..efb2260 --- /dev/null +++ b/backend/app/models/destinataire_contribution.py @@ -0,0 +1,20 @@ +"""Destinataires configurables pour les contributions.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class DestinataireContribution(Base): + __tablename__ = "destinataires_contributions" + __table_args__ = ( + UniqueConstraint("groupe_id", "nom", name="uq_destinataire_contribution_groupe_nom"), + ) + + 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) + nom: Mapped[str] = mapped_column(String(120), nullable=False) + protege: Mapped[bool] = mapped_column(Boolean, default=False) + actif: Mapped[bool] = mapped_column(Boolean, default=True) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/envoi_contribution.py b/backend/app/models/envoi_contribution.py index 805b4f9..9fa6143 100644 --- a/backend/app/models/envoi_contribution.py +++ b/backend/app/models/envoi_contribution.py @@ -1,7 +1,7 @@ """Table envois_contributions — Envois aux organismes AA.""" import uuid from datetime import date, datetime, timezone -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum, String from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -12,10 +12,7 @@ class EnvoiContribution(Base): id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) date_envoi: Mapped[date] = mapped_column(Date, nullable=False) - destination: Mapped[str] = mapped_column( - SAEnum("district", "region", "bsg", "intergroupe", name="destination_contribution"), - nullable=False, - ) + destination: Mapped[str] = mapped_column(String(120), nullable=False) montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) methode: Mapped[str] = mapped_column( SAEnum("DU", "VIREMENT", "CHEQUE", name="methode_contribution"), diff --git a/backend/app/routers/accueil.py b/backend/app/routers/accueil.py index 7bc78f5..cdfc67d 100644 --- a/backend/app/routers/accueil.py +++ b/backend/app/routers/accueil.py @@ -36,7 +36,7 @@ MODULES_SAISIE_TOUS = [ # Modules de saisie par poste MODULES_SAISIE = { "Trésorier(ière)": [ - ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"), + ModuleTuile(code="M4", nom="Donner au suivant", description="Destinataires et contributions", icone="📮", couleur="blue"), ], "Secrétaire": [ ModuleTuile(code="M1", nom="Rédiger PV", description="Procès-verbal", icone="📝", couleur="blue"), diff --git a/backend/app/routers/contributions.py b/backend/app/routers/contributions.py index 365f14f..c5bb9fd 100644 --- a/backend/app/routers/contributions.py +++ b/backend/app/routers/contributions.py @@ -2,7 +2,7 @@ from datetime import date from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc +from sqlalchemy import select, desc, func from app.core.database import get_db from app.core.decisions import prochain_numero_decision @@ -12,14 +12,16 @@ 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.destinataire_contribution import DestinataireContribution from app.services.finance import solde_banque, total_reserves, transaction from app.schemas.phase7 import ( EnvoiContributionCreate, EnvoiContributionOut, CalculRepartitionIn, CalculRepartitionOut, DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION, + DestinataireContributionCreate, DestinataireContributionUpdate, DestinataireContributionOut, ) -router = APIRouter(prefix="/contributions", tags=["M4 — Envoi contributions"]) +router = APIRouter(prefix="/contributions", tags=["M4 — Donner au suivant"]) # Répartition par défaut selon le Guide du groupe DEFAUT_REPARTITION = { @@ -53,6 +55,121 @@ async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]: return repartition +async def _ensure_destinataire_defaut(groupe_id, db: AsyncSession) -> DestinataireContribution: + result = await db.execute( + select(DestinataireContribution).where( + DestinataireContribution.groupe_id == groupe_id, + func.lower(DestinataireContribution.nom) == "district 87-16", + ) + ) + dest = result.scalar_one_or_none() + if dest: + if not dest.protege or not dest.actif: + dest.protege = True + dest.actif = True + await db.flush() + return dest + dest = DestinataireContribution(groupe_id=groupe_id, nom="District 87-16", protege=True, actif=True) + db.add(dest) + await db.flush() + return dest + + +async def _get_destinataire(groupe_id, nom: str, db: AsyncSession) -> DestinataireContribution: + nom = (nom or "").strip() + if not nom: + raise HTTPException(status_code=400, detail="Destinataire requis") + await _ensure_destinataire_defaut(groupe_id, db) + result = await db.execute( + select(DestinataireContribution).where( + DestinataireContribution.groupe_id == groupe_id, + func.lower(DestinataireContribution.nom) == nom.lower(), + DestinataireContribution.actif == True, + ) + ) + dest = result.scalar_one_or_none() + if not dest: + raise HTTPException(status_code=404, detail="Destinataire introuvable") + return dest + + +@router.get("/destinataires", response_model=list[DestinataireContributionOut]) +async def liste_destinataires( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + await _ensure_destinataire_defaut(membre.groupe_id, db) + result = await db.execute( + select(DestinataireContribution).where( + DestinataireContribution.groupe_id == membre.groupe_id, + DestinataireContribution.actif == True, + ).order_by(DestinataireContribution.protege.desc(), DestinataireContribution.nom) + ) + return [DestinataireContributionOut.model_validate(d) for d in result.scalars().all()] + + +@router.post("/destinataires", response_model=DestinataireContributionOut) +async def creer_destinataire( + req: DestinataireContributionCreate, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + nom = req.nom.strip() + if not nom: + raise HTTPException(status_code=400, detail="Nom requis") + result = await db.execute( + select(DestinataireContribution).where( + DestinataireContribution.groupe_id == membre.groupe_id, + func.lower(DestinataireContribution.nom) == nom.lower(), + ) + ) + existant = result.scalar_one_or_none() + if existant: + existant.actif = True + await db.flush() + return DestinataireContributionOut.model_validate(existant) + dest = DestinataireContribution(groupe_id=membre.groupe_id, nom=nom, protege=nom.lower() == "district 87-16") + db.add(dest) + await db.flush() + return DestinataireContributionOut.model_validate(dest) + + +@router.patch("/destinataires/{destinataire_id}", response_model=DestinataireContributionOut) +async def modifier_destinataire( + destinataire_id: str, + req: DestinataireContributionUpdate, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + dest = await db.get(DestinataireContribution, destinataire_id) + if not dest or dest.groupe_id != membre.groupe_id or not dest.actif: + raise HTTPException(status_code=404, detail="Destinataire introuvable") + if dest.protege: + raise HTTPException(status_code=400, detail="Ce destinataire est protégé") + nom = req.nom.strip() + if not nom: + raise HTTPException(status_code=400, detail="Nom requis") + dest.nom = nom + await db.flush() + return DestinataireContributionOut.model_validate(dest) + + +@router.delete("/destinataires/{destinataire_id}") +async def supprimer_destinataire( + destinataire_id: str, + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): + dest = await db.get(DestinataireContribution, destinataire_id) + if not dest or dest.groupe_id != membre.groupe_id or not dest.actif: + raise HTTPException(status_code=404, detail="Destinataire introuvable") + if dest.protege: + raise HTTPException(status_code=400, detail="Ce destinataire est protégé") + dest.actif = False + await db.flush() + return {"message": "Destinataire supprimé"} + + @router.post("/calculer", response_model=list[CalculRepartitionOut]) async def calculer_repartition( req: CalculRepartitionIn, @@ -84,8 +201,7 @@ async def enregistrer_envoi( """M4 — Enregistrer un envoi de contribution et créer sa résolution.""" from app.models.proposition import Proposition - if req.destination not in DESTINATIONS: - raise HTTPException(status_code=400, detail=f"Destination invalide. Choix: {DESTINATIONS}") + destination = await _get_destinataire(membre.groupe_id, req.destination, db) if req.methode.upper() not in METHODES_CONTRIBUTION: raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}") if req.proposeur_id == req.secondeur_id: @@ -103,9 +219,9 @@ async def enregistrer_envoi( resolution = Proposition( groupe_id=membre.groupe_id, type_proposition="contribution", - sujet=f"Envoi de {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}", + sujet=f"Envoi de {req.montant:.2f} $ à {destination.nom}", description=( - f"Contribution enregistrée le {req.date_envoi} par le module Envoi contributions. " + f"Contribution enregistrée le {req.date_envoi} par le module Donner au suivant. " f"Méthode prévue : {req.methode.upper()}." ), proposeur_id=req.proposeur_id, @@ -114,7 +230,7 @@ async def enregistrer_envoi( numero_resolution=numero_resolution, date_vote=date_decision, date_adoption=date_decision, - groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}.", + groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {destination.nom}.", ) db.add(resolution) await db.flush() @@ -122,7 +238,7 @@ async def enregistrer_envoi( envoi = EnvoiContribution( groupe_id=membre.groupe_id, date_envoi=req.date_envoi, - destination=req.destination, + destination=destination.nom, montant=req.montant, methode=req.methode.upper(), resolution_id=resolution.id, @@ -137,7 +253,7 @@ async def enregistrer_envoi( id=envoi.id, date_envoi=envoi.date_envoi, destination=envoi.destination, - destination_label=DESTINATIONS_LABELS[envoi.destination], + destination_label=DESTINATIONS_LABELS.get(envoi.destination, envoi.destination), montant=float(envoi.montant), methode=envoi.methode, resolution_id=envoi.resolution_id, diff --git a/backend/app/routers/membres.py b/backend/app/routers/membres.py index 23d1d36..b79382a 100644 --- a/backend/app/routers/membres.py +++ b/backend/app/routers/membres.py @@ -4,7 +4,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, require_executif, hash_pin, verify_pin +from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin, is_sysadmin from app.models.membre import Membre from app.schemas.schemas import MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest @@ -116,6 +116,8 @@ async def desactiver_membre( membre = result.scalar_one_or_none() if not membre: raise HTTPException(status_code=404, detail="Membre introuvable") + if is_sysadmin(membre): + raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé") membre.actif = False await db.flush() return {"message": f"{membre.prenom} a été désactivé"} @@ -136,6 +138,8 @@ async def droit_oubli( raise HTTPException(status_code=404, detail="Membre introuvable") if str(membre.id) == str(executif.id): raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte") + if is_sysadmin(membre): + raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé") prenom_original = membre.prenom # Anonymiser diff --git a/backend/app/routers/postes.py b/backend/app/routers/postes.py index 9b6fd51..7e5c969 100644 --- a/backend/app/routers/postes.py +++ b/backend/app/routers/postes.py @@ -12,7 +12,7 @@ from app.models.affectation import Affectation from app.models.membre import Membre from app.models.candidature import Candidature from app.models.poste_module import PosteModule -from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, TerminerMandatRequest +from app.schemas.schemas import PosteOut, PosteCreate, PosteUpdate, AffectationCreate, TerminerMandatRequest router = APIRouter(prefix="/postes", tags=["Postes et affectations"]) @@ -56,6 +56,28 @@ async def creer_poste( return PosteOut.model_validate(poste) +@router.patch("/{poste_id}", response_model=PosteOut) +async def modifier_poste( + poste_id: str, + req: PosteUpdate, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + poste = await db.get(Poste, poste_id) + if not poste: + raise HTTPException(status_code=404, detail="Poste introuvable") + if req.categorie is not None and req.categorie not in ("executif", "service", "physique"): + raise HTTPException(status_code=400, detail="Catégorie invalide") + for attr in ["nom", "categorie", "description", "prerequis", "informations", "ordre_affichage"]: + value = getattr(req, attr) + if value is not None: + setattr(poste, attr, value.strip() if isinstance(value, str) else value) + if not poste.nom: + raise HTTPException(status_code=400, detail="Nom requis") + await db.flush() + return PosteOut.model_validate(poste) + + @router.delete("/{poste_id}") async def abolir_poste( poste_id: str, @@ -139,11 +161,34 @@ async def postes_disponibles( "categorie": p.categorie, "description": p.description, "prerequis": p.prerequis, + "informations": p.informations, "deja_postule": deja_postule, + "candidats": await _candidats_poste(p.id, db), }) return disponibles +async def _candidats_poste(poste_id, db: AsyncSession): + result = await db.execute( + select(Candidature).where( + Candidature.poste_id == poste_id, + Candidature.statut == "proposee", + ) + ) + candidats = [] + for c in result.scalars().all(): + membre = await db.get(Membre, c.membre_id) + if membre: + candidats.append({ + "id": str(c.id), + "membre_id": str(membre.id), + "prenom": membre.prenom, + "type": c.type, + "motivation": c.motivation, + }) + return candidats + + # ── Candidatures ── @router.post("/candidatures") @@ -235,6 +280,47 @@ async def nominer( } +@router.post("/candidatures/proposer") +async def proposer_membre( + poste_id: str, + membre_id: str, + motivation: str = "", + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Proposer un autre membre pour un poste.""" + poste = await db.get(Poste, poste_id) + if not poste or not poste.actif: + raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") + cible = await db.get(Membre, membre_id) + if not cible or cible.groupe_id != membre.groupe_id or not cible.actif: + raise HTTPException(status_code=404, detail="Membre introuvable") + if cible.id == membre.id: + raise HTTPException(status_code=400, detail="Utilisez Postuler pour votre propre candidature") + + result = await db.execute( + select(Candidature).where( + Candidature.poste_id == poste_id, + Candidature.membre_id == membre_id, + Candidature.statut == "proposee", + ) + ) + if result.scalars().first(): + raise HTTPException(status_code=400, detail="Ce membre est déjà proposé pour ce poste") + + cand = Candidature( + groupe_id=membre.groupe_id, + poste_id=poste_id, + membre_id=membre_id, + type="nomination", + nomme_par=membre.id, + motivation=motivation.strip() or None, + ) + db.add(cand) + await db.flush() + return {"message": f"{cible.prenom} proposé(e) pour {poste.nom}", "id": str(cand.id)} + + @router.post("/candidatures/{candidature_id}/retirer") async def retirer_candidature( candidature_id: str, diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py index 823e707..f5ea12c 100644 --- a/backend/app/routers/propositions.py +++ b/backend/app/routers/propositions.py @@ -7,6 +7,7 @@ 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.core.roles import membre_a_poste_ou_executif from app.models.proposition import Proposition from app.models.reunion import Reunion from app.models.membre import Membre @@ -77,6 +78,7 @@ async def creer_proposition( @router.post("/{proposition_id}/seconder", response_model=PropositionOut) async def seconder_proposition( proposition_id: str, + membre_id: str | None = None, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): @@ -92,10 +94,19 @@ async def seconder_proposition( raise HTTPException(status_code=404, detail="Proposition introuvable") if prop.statut != "proposee": raise HTTPException(status_code=400, detail="Seule une proposition en attente peut être secondée") - if prop.proposeur_id == membre.id: + + secondeur = membre + if membre_id and str(membre_id) != str(membre.id): + if not await membre_a_poste_ou_executif(membre.id, "executif", db): + raise HTTPException(status_code=403, detail="Réservé aux exécutifs") + secondeur = await db.get(Membre, membre_id) + if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif: + raise HTTPException(status_code=404, detail="Membre introuvable ou inactif") + + if prop.proposeur_id == secondeur.id: raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition") - prop.secondeur_id = membre.id + prop.secondeur_id = secondeur.id prop.statut = "secondee" await db.flush() return await _to_out(prop, db) @@ -163,8 +174,12 @@ async def voter_proposition( db.add(affectation) elif req.statut == "rejetee": - if req.note: - prop.note = req.note.strip() + etant_donne_que = (req.etant_donne_que or "").strip() + if not etant_donne_que: + raise HTTPException(status_code=400, detail='La section "ÉTANT DONNÉ QUE" est obligatoire pour rejeter une proposition') + prop.etant_donne_que = etant_donne_que + prop.groupe_a_decide_de = None + prop.note = (req.note or "").strip() or None # Si nomination rejetée, mettre la candidature en refusée if prop.type_proposition == "nomination" and prop.candidature_id: @@ -211,10 +226,10 @@ async def registre_resolutions( membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """Registre des résolutions adoptées.""" + """Registre des décisions adoptées ou rejetées.""" query = select(Proposition).where( Proposition.groupe_id == membre.groupe_id, - Proposition.statut == "adoptee", + Proposition.statut.in_(["adoptee", "rejetee"]), ) if annee: query = query.where(extract("year", Proposition.cree_le) == annee) diff --git a/backend/app/schemas/phase7.py b/backend/app/schemas/phase7.py index 9429bbf..74f0d62 100644 --- a/backend/app/schemas/phase7.py +++ b/backend/app/schemas/phase7.py @@ -5,8 +5,9 @@ from uuid import UUID from typing import Optional -DESTINATIONS = ["district", "region", "bsg", "intergroupe"] +DESTINATIONS = ["District 87-16"] DESTINATIONS_LABELS = { + "District 87-16": "District 87-16", "district": "District 87-16", "region": "Région 87", "bsg": "BSG", @@ -18,7 +19,7 @@ METHODES_CONTRIBUTION = ["DU", "VIREMENT", "CHEQUE"] # ── M4 Envoi de contributions ── class EnvoiContributionCreate(BaseModel): date_envoi: date - destination: str # district, region, bsg, intergroupe + destination: str montant: float methode: str # DU, VIREMENT, CHEQUE resolution_id: Optional[UUID] = None @@ -54,6 +55,23 @@ class CalculRepartitionOut(BaseModel): montant: float +class DestinataireContributionCreate(BaseModel): + nom: str + + +class DestinataireContributionUpdate(BaseModel): + nom: str + + +class DestinataireContributionOut(BaseModel): + id: UUID + nom: str + protege: bool = False + actif: bool = True + + model_config = {"from_attributes": True} + + # ── G8 Config répartition ── class ConfigRepartitionItem(BaseModel): destination: str diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index a6996a8..3c0b9c0 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -73,6 +73,15 @@ class PosteCreate(BaseModel): ordre_affichage: Optional[int] = None +class PosteUpdate(BaseModel): + nom: Optional[str] = None + categorie: Optional[str] = None + description: Optional[str] = None + prerequis: Optional[str] = None + informations: Optional[str] = None + ordre_affichage: Optional[int] = None + + class PosteOut(BaseModel): id: UUID nom: str diff --git a/data/import/Liste-de-prix-rev.-Fevrier-2026.csv b/data/import/Liste-de-prix-rev.-Fevrier-2026.csv new file mode 100644 index 0000000..e5adb9d --- /dev/null +++ b/data/import/Liste-de-prix-rev.-Fevrier-2026.csv @@ -0,0 +1,166 @@ +code_bsg,titre,categorie,prix_coutant +M-73,Histoire du Gros Livre en Bref (Trilingue),livre,2.00 +FB-1,Les Alcooliques anonymes (relié),livre,25.00 +FB-30,Les Alcooliques anonymes (souple),livre,23.50 +FB-35,Les Alcooliques anonymes (poche),livre,15.00 +FB-16,Les Alcooliques anonymes (gros caractères),livre,25.00 +FB-24,Les Alcooliques anonymes (gros caractères abrégé),livre,15.00 +FB-2,Les 12 étapes et les 12 traditions (relié),livre,23.50 +FB-15,Les 12 étapes et les 12 traditions (souple),livre,21.50 +FB-17,Les 12 étapes et les 12 traditions (poche),livre,18.50 +FB-14,Les 12 étapes et les 12 traditions (gros caractères),livre,23.50 +FB-3,Le mouvement des AA devient adulte,livre,25.20 +FB-6,Nous en sommes venus à croire,livre,15.10 +FB-7,Vivre... sans alcool,livre,15.10 +FB-8,Dr Bob et les pionniers,livre,25.20 +FB-9,Transmets-le,livre,25.20 +FB-12,Réflexions quotidiennes,livre,26.00 +FB-13,A.A. en prison,livre,9.30 +FB-18,Réflexions de Bill,livre,23.50 +FB-20,"Expérience, Force et Espoir",livre,15.10 +FB-28,Les AA pour l'alcoolique plus âgé,livre,4.30 +FB-70,Notre grande responsabilité,livre,25.20 +FB-86,Un livre de l'amitié - livre souvenir 2025,livre,30.00 +FBM-31,Le manuel du service & 12 concepts,livre,13.40 +L-10,Le langage du cœur,livre,28.50 +L-11,Le groupe d'attache: Battement du coeur,livre,13.80 +FBB-06,Les meilleurs articles de Bill,livre,13.40 +FGV-17,La sobriété émotive,livre,25.20 +FGV-29,"Heureux, joyeux et libres, une sobriété enjouée",livre,25.20 +FGV-30,En Tête à tête - Le parrainage AA en action,livre,25.20 +FGV-32,Citation du jour - Grapevine,livre,25.20 +D-60,L'ivresse mentale,livre,5.00 +D-80,Album souvenir 1944-2024 - 80 ans des AA au Québec,livre,10.00 +D-100,Un cœur rempli de gratitude,livre,15.00 +B-0,Alcoholics Anonymous reprod. 1939 (anglais seul.),livre,3.50 +FP-1,Voici les AA,brochure,0.70 +FP-56,Voici les AA (Gros caractères),brochure,1.90 +FP-24,Un nouveau veut savoir,brochure,0.40 +FP-3,Les AA sont-ils pour vous?,brochure,0.40 +FP-35,Problèmes autres que l'alcoolisme,brochure,0.70 +FP-30,Y a-t-il un alcoolique dans votre vie?,brochure,0.70 +FP-4,Les jeunes et les AA,brochure,1.10 +FP-5,Les femmes des AA,brochure,0.90 +FP-13,Vous croyez-vous différent?,brochure,0.90 +FP-11,Le membre AA face aux méd. et drogue,brochure,0.90 +FP-9,Derrière les murs : un message d’espoir,brochure,1.30 +FP-33,Ça vaut mieux que de poireauter en prison,brochure,4.30 +FP-12,L'article de Jack Alexander sur les AA,brochure,1.30 +FP-6,3 Causeries à des sociétés médicales,brochure,1.00 +FP-2,Foire aux questions sur les AA,brochure,0.80 +FP-57,Foire aux questions sur les AA (Gros caractères),brochure,2.40 +FP-15,Questions et réponses sur le parrainage,brochure,0.70 +FP-38,Jean… face à son problème d'alcool,brochure,1.00 +FP-39,L'histoire de Nicole,brochure,1.00 +FP-36,Les AA sont-ils pour moi?,brochure,0.90 +FP-51,Les Noirs chez les AA,brochure,1.60 +FP-55,Les douze étapes illustrées,brochure,0.90 +FP-21,Les peuples autochtones chez les AA,brochure,3.20 +FP-32,Les alcooliques LGBTQ des AA,brochure,1.00 +FP-10,Notre méthode,brochure,0.50 +FP-83,L’accès aux AA,brochure,1.30 +FP-84,Différentes avenues vers la spiritualité,brochure,0.80 +FP-50,Les AA et les Forces armées,brochure,1.50 +FP-86,Le Mot Dieu : athées et agnostiques,brochure,1.00 +FP-87,Les AA pour les alcooliques atteints de maladie mentale,brochure,1.20 +FP-16,Le groupe des AA,brochure,1.10 +FP-17,La tradition des AA et son développement,brochure,1.90 +FP-18,La structure de l'association des AA,brochure,1.30 +FP-19,Le RSG,brochure,0.50 +FP-43,Les douze traditions illustrées,brochure,1.10 +FP-8,Les douze concepts illustrés,brochure,1.50 +FP-44,L'héritage des services des AA,brochure,1.10 +FP-45,Cercles d'amour et de service,brochure,0.50 +FP-53,Les deux fondateurs d'AA,brochure,1.30 +FP-91,L'expérience nous a appris : introduction à nos 12 traditions,brochure,1.50 +FP-20,Message aux directeurs d'établissements correctionnels,brochure,0.90 +FP-23,Les AA: ressource pour professionnels de la santé,brochure,0.90 +FP-25,Les membres du clergé se renseignent...,brochure,0.90 +FP-26,Les AA dans les centres de détention,brochure,1.50 +FP-27,Les AA dans les centres de traitement,brochure,1.10 +FP-46,Vous vous occupez professionnellement d'alcool.,brochure,1.50 +FP-49,Favoriser le rapprochement,brochure,0.70 +FP-54,Y a-t-il un alcoolique dans votre milieu de travail,brochure,0.50 +FP-29,Collaboration des membres des AA,brochure,1.30 +FP-31,Les AA dans votre milieu,brochure,0.90 +FP-34,Collaborons avec nos amis,brochure,0.90 +FP-40,Causeries à l'extérieur des AA,brochure,1.50 +FP-41,Point de vue d'un membre sur les AA,brochure,1.10 +FP-42,Petit guide pratique sur les AA,brochure,0.90 +FP-47,Le sens de l'anonymat,brochure,0.70 +FP-48,Sondage sur les membres AA,brochure,0.50 +FP-10P,"Notre méthode, plastifiée",depliant,1.50 +D-02,Carte 12 étapes et 12 traditions (8 1/2 X 11),depliant,4.00 +D-03,Carte 12 Concepts plastifiée (8 1/2 X 11),depliant,1.25 +D-05,Les promesses de la 9e étape (plastifié),depliant,1.25 +D-06,Affiche AA avec flèches (pour l'intérieur),depliant,1.00 +D-10,Prière de la sérénité (12 x 16),depliant,3.00 +D-12,Slogans (jeu de 5),depliant,11.10 +D-13R,Déclaration de Responsabilité,depliant,3.00 +D-13U,Déclaration d'Unité,depliant,3.00 +D-14,Prière de la sérénité (23X35),depliant,10.00 +D-25S,Pochette de bienvenue (sans liste de réunions),depliant,3.00 +D-25J,Pochette de bienvenue avec jeton plastique,depliant,3.75 +FM-17,Dernier message de Bill W. & Bob S.,depliant,9.50 +FM-61,Affichette sur l'anonymat,depliant,0.50 +FM-77,Anonymat à l'ère numérique (11x17) carton mousse,depliant,22.40 +FM-78,"Anonymat à l'ère numérique (8,5x11) carton mousse",depliant,17.30 +FMG-20,Lignes de conduites des AA (série),depliant,5.20 +M-30,Affiche réunion AA,depliant,14.30 +FT-01,Affiches noires AA (jeu de 3),depliant,3.45 +D-30,Livret - Lectures de réunions en gros caractères,depliant,2.00 +A-03,Épinglette AA 87,pamphlet,5.00 +A-08,Carte de souhait ou d'anniversaire sobriété,pamphlet,5.00 +A-08M,Carte de souhait « Merci »,pamphlet,7.00 +A-09,Signet,pamphlet,2.50 +A-10,Médaillon Prière de la Sérénité étain antique,pamphlet,17.00 +A-11,Médaillon avec chaîne (fine ou large),pamphlet,23.00 +A-12,Porte-clé Bill et Bob,pamphlet,8.00 +A-16,"Mini-signet, fait main, non plastifié",pamphlet,1.00 +D-08P,Prière de la sérénité (2 1/2 x 3 1/2),depliant,0.30 +D-08M,Prière de la sérénité (2 1/2 x 3 1/2) Aimantée,depliant,2.00 +D-09,Prière de la sérénité (5 x 7),depliant,1.50 +D-12M,Slogans (jeu de 5) 2x2 magnétiques,depliant,5.00 +D-24,Affichette sur l'humilité (8 1/2 X 11),depliant,3.50 +D-26,Trousse Sérénité,pamphlet,5.00 +D-27,Affichette sur l'acceptation (8 1/2 X 11),depliant,3.50 +FM-2,Carte 12 étapes et 12 traditions (3 1/2 X 4 1/2),depliant,0.40 +FM-21,Carte Je suis responsable,depliant,0.40 +FM-22,Carte sur l'anonymat (3 1/2 x 4 1/2),depliant,0.40 +MS-03,Affiche L'homme dans le lit,depliant,8.30 +MS-08,Calendrier Grapevine (trilingue),pamphlet,19.30 +MS-09,Agenda de poche Grapevine (trilingue),pamphlet,11.10 +X-01,"Prière sérénité (collant) (rouge, bleu, vert, noir)",pamphlet,1.25 +AS-01,Stylo encre noire - Avec slogan (choix de 4 diff.),pamphlet,3.00 +D-26P,Carte Promesse (4.5 x 5.5),depliant,0.50 +D-26I,Carte Inventaire quotidien (4.5 x 5.5),depliant,0.50 +D-26M,Carte Méditation (4.5 x 5.5),depliant,0.50 +D-26A,Carte Aujourd'hui seulement (4.5 x 5.5),depliant,0.50 +FM81-A,Les Alcooliques anonymes - version abrégée (CD),livre,12.00 +FM-81,Les Alcooliques anonymes - version intégrale (CD),livre,24.00 +FM-83,Douze et Douze sur CD,livre,22.00 +FCD-23,Le langage du cœur CD,livre,19.30 +MD-1,Annuaire international,pamphlet,2.60 +A-01,Listes de réunions - Montréal et environs,pamphlet,2.00 +A-90,Répertoire de réunions - Région Nord-ouest du Qc,pamphlet,1.85 +A-13,Livret AA Une introduction,brochure,0.75 +FMS-15,Facsimile Bill & Bob,pamphlet,13.80 +D-20,La Vigne AA,pamphlet,4.50 +D-42,Sac réutilisable Un jour à la fois,pamphlet,2.00 +RSMF-1FR,Carte représentant des publications,depliant,0.00 +FF-1,On vous oblige à assister à des réunions des AA ?,depliant,0.00 +FF-2,Aperçu sur les AA,depliant,0.00 +FF-3,Renseignements sur les AA,depliant,0.00 +FF-4,L'autonomie financière…alliance de l'argent et de la spiritualité,depliant,0.00 +FF-5,Et maintenant…que vais-je faire?,depliant,0.00 +FF-6,Transmettre le message dans les centres correctionnels,depliant,0.00 +FF-8,Votre BSG,depliant,0.00 +FF-9,Problèmes autres que l'alcoolisme (citations),depliant,0.00 +FF-12,Un message au moins de vingt ans,depliant,0.00 +FF-17,Votre RDR,depliant,0.00 +FF-42,Cartes de réunions ouvertes et fermées,depliant,0.00 +FF-47,Carte autonomie financière,depliant,0.00 +FF-96,Les Archives des AA,depliant,0.00 +FF-107,Message à un trésorier de groupe des AA,depliant,0.00 +FF-211,Au service de tous les alcooliques,depliant,0.00 +SMF-209,La sécurité chez les AA: notre bien-être commun,depliant,0.00 diff --git a/data/import/Liste-de-prix-rev.-Fevrier-2026.pdf b/data/import/Liste-de-prix-rev.-Fevrier-2026.pdf new file mode 100644 index 0000000..5674ce1 Binary files /dev/null and b/data/import/Liste-de-prix-rev.-Fevrier-2026.pdf differ diff --git a/deploy/seed.py b/deploy/seed.py index b51d63c..7fda784 100644 --- a/deploy/seed.py +++ b/deploy/seed.py @@ -8,7 +8,9 @@ import sys import os import uuid import secrets +import csv from datetime import date, datetime, timedelta, timezone +from pathlib import Path # Ajouter le backend au path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) @@ -42,6 +44,7 @@ from app.models.poste_module import PosteModule from app.models.reserve import Reserve from app.models.transaction_comptable import TransactionComptable from app.models.mouvement_reserve import MouvementReserve +from app.models.destinataire_contribution import DestinataireContribution POSTES = [ @@ -58,6 +61,30 @@ POSTES = [ ] +def charger_catalogue_litterature(): + chemin = Path(__file__).resolve().parents[1] / "data" / "import" / "Liste-de-prix-rev.-Fevrier-2026.csv" + if not chemin.exists(): + return [ + {"titre": "Les Alcooliques anonymes (Gros Livre)", "categorie": "livre", "prix_coutant": 12.00, "code_bsg": "B-1"}, + {"titre": "Les Douze Étapes et les Douze Traditions", "categorie": "livre", "prix_coutant": 11.00, "code_bsg": "B-2"}, + {"titre": "Vivre sans alcool", "categorie": "livre", "prix_coutant": 9.00, "code_bsg": "B-7"}, + {"titre": "Les AA sont-ils pour vous?", "categorie": "brochure", "prix_coutant": 0.00, "code_bsg": "P-3"}, + {"titre": "Un nouveau arrive", "categorie": "depliant", "prix_coutant": 0.00, "code_bsg": "P-16"}, + ] + + with chemin.open(newline="", encoding="utf-8") as fichier: + return [ + { + "code_bsg": row["code_bsg"].strip() or None, + "titre": row["titre"].strip(), + "categorie": row["categorie"].strip(), + "prix_coutant": float(row["prix_coutant"]) if row["prix_coutant"].strip() else None, + } + for row in csv.DictReader(fichier) + if row.get("titre", "").strip() + ] + + async def seed(): # Créer les tables async with engine.begin() as conn: @@ -151,23 +178,26 @@ async def seed(): db.add(invitation) # ── Littérature de base ── - for titre, cat, prix, code_bsg in [ - ("Les Alcooliques anonymes (Gros Livre)", "livre", 12.00, "B-1"), - ("Les Douze Étapes et les Douze Traditions", "livre", 11.00, "B-2"), - ("Vivre sans alcool", "livre", 9.00, "B-7"), - ("Les AA sont-ils pour vous?", "brochure", 0.00, "P-3"), - ("Un nouveau arrive", "depliant", 0.00, "P-16"), - ]: + catalogue_litterature = charger_catalogue_litterature() + for item in catalogue_litterature: db.add(Litterature( groupe_id=groupe.id, - titre=titre, - categorie=cat, - prix_coutant=prix, + titre=item["titre"], + categorie=item["categorie"], + prix_coutant=item["prix_coutant"], quantite_stock=0, seuil_commande=2, - code_bsg=code_bsg, + code_bsg=item["code_bsg"], )) - print(" ✓ Littérature de base : 5 titres") + print(f" ✓ Littérature de base : {len(catalogue_litterature)} titres") + + db.add(DestinataireContribution( + groupe_id=groupe.id, + nom="District 87-16", + protege=True, + actif=True, + )) + print(" ✓ Destinataire de contribution : District 87-16") await db.commit() diff --git a/frontend/src/components/BottomNav.jsx b/frontend/src/components/BottomNav.jsx index d6b7a14..fca50e9 100644 --- a/frontend/src/components/BottomNav.jsx +++ b/frontend/src/components/BottomNav.jsx @@ -3,8 +3,8 @@ import { useStore } from '../stores/store'; const NAV_CONTEXTS = [ { id: 'accueil', label: 'Accueil', icon: '⌂' }, - { id: 'reunion', label: 'Réunion', icon: '□' }, - { id: 'affaires', label: 'Affaires', icon: '◇' }, + { id: 'reunion', label: 'Rencontres', icon: '□' }, + { id: 'affaires', label: 'Assemblées', icon: '◇' }, { id: 'gestion', label: 'Gestion', icon: '○' }, ]; @@ -26,7 +26,7 @@ export default function BottomNav() { key={item.id} type="button" onClick={() => ouvrirContexte(item.id)} - className={`min-h-[56px] rounded-xl text-xs font-bold flex flex-col items-center justify-center gap-0.5 transition ${ + className={`min-h-[58px] rounded-xl text-[11px] font-bold flex flex-col items-center justify-center gap-0.5 transition ${ actif ? 'bg-aa-blue text-white' : 'text-gray-500 active:bg-gray-100' }`} > diff --git a/frontend/src/components/PageHeader.jsx b/frontend/src/components/PageHeader.jsx index 1214f70..e38f2cd 100644 --- a/frontend/src/components/PageHeader.jsx +++ b/frontend/src/components/PageHeader.jsx @@ -4,10 +4,10 @@ import { useStore } from '../stores/store'; export default function PageHeader({ titre, sousTitre }) { const { setPage } = useStore(); return ( -
+
); @@ -82,6 +82,7 @@ export default function Accueil() { const { membre, postes, + logout, modulesConsultation, modulesSaisie, modulesGestion, @@ -122,15 +123,15 @@ export default function Accueil() { return (
-
+

Bonjour {membre?.prenom}

{contexte.titre}

{contexte.description}

-
{postes.length > 0 && ( @@ -144,7 +145,7 @@ export default function Accueil() { )}
-
+
) : ( -
- {resultatsRecherche.map((m) => ouvrirModule(m)} />)} -
+
+ {resultatsRecherche.map((m) => ouvrirModule(m)} />)} +
)} ) : ( @@ -180,7 +181,7 @@ export default function Accueil() {

{section.titre}

-
+
{section.modules.map((m) => ouvrirModule(m)} />)}
diff --git a/frontend/src/pages/Decisions.jsx b/frontend/src/pages/Decisions.jsx index c1fa16f..208a2fc 100644 --- a/frontend/src/pages/Decisions.jsx +++ b/frontend/src/pages/Decisions.jsx @@ -19,9 +19,10 @@ const TYPES = { }; export default function Decisions() { - const { postes } = useStore(); + const { postes, estExecutif } = useStore(); const [propositions, setPropositions] = useState([]); const [resolutions, setResolutions] = useState([]); + const [membres, setMembres] = useState([]); const [tab, setTab] = useState('cours'); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -29,23 +30,25 @@ export default function Decisions() { const [sujet, setSujet] = useState(''); const [description, setDescription] = useState(''); - const [resolutionId, setResolutionId] = useState(''); const [resolutionForm, setResolutionForm] = useState({}); + const [seconderPour, setSeconderPour] = useState({}); + const [decisionOuverte, setDecisionOuverte] = useState(null); - const estExecutif = postes.some((p) => + const peutExecutif = estExecutif || postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p) ); const charger = async () => { setLoading(true); try { - const [props, res] = await Promise.all([ + const [props, res, membresActifs] = await Promise.all([ api.get('/propositions/?limite=100'), api.get('/propositions/resolutions'), + api.get('/membres/actifs'), ]); setPropositions(props); setResolutions(res); - if (res.length > 0 && !resolutionId) setResolutionId(res[0].id); + setMembres(membresActifs); } catch (e) { setError(e.message); } setLoading(false); }; @@ -55,7 +58,7 @@ export default function Decisions() { const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); }; const soumettre = async () => { - if (!sujet.trim()) { setError('Le sujet est obligatoire'); return; } + if (!sujet.trim()) { setError("L'objet est obligatoire"); return; } setError(''); try { await api.post('/propositions/', { @@ -70,10 +73,11 @@ export default function Decisions() { } catch (e) { setError(e.message); } }; - const seconder = async (id) => { + const seconder = async (id, membreId = '') => { setError(''); try { - await api.post(`/propositions/${id}/seconder`); + const query = membreId ? `?membre_id=${membreId}` : ''; + await api.post(`/propositions/${id}/seconder${query}`); flash('Proposition secondée'); await charger(); } catch (e) { setError(e.message); } @@ -110,15 +114,23 @@ export default function Decisions() { const rejeter = async (id) => { setError(''); + const form = resolutionForm[id] || {}; + const etantDonneQue = (form.etant_donne_que || '').trim(); + if (!etantDonneQue) { + setError('La section "ÉTANT DONNÉ QUE" est obligatoire pour rejeter une proposition'); + return; + } try { - await api.patch(`/propositions/${id}/voter`, { statut: 'rejetee' }); + await api.patch(`/propositions/${id}/voter`, { + statut: 'rejetee', + etant_donne_que: etantDonneQue, + }); flash('Proposition rejetée'); await charger(); } catch (e) { setError(e.message); } }; const enCours = propositions.filter((p) => ['proposee', 'secondee'].includes(p.statut)); - const resolutionChoisie = resolutions.find((r) => r.id === resolutionId); if (loading) return

Chargement...

; @@ -160,6 +172,7 @@ export default function Decisions() { const statut = STATUTS[p.statut] || STATUTS.proposee; const form = resolutionForm[p.id] || {}; const resolutionComplete = (form.etant_donne_que || '').trim() && (form.groupe_a_decide_de || '').trim(); + const motifRejetPresent = (form.etant_donne_que || '').trim(); return (
@@ -178,21 +191,44 @@ export default function Decisions() {
{p.statut === 'proposee' && ( -
- {estExecutif && ( -