diff --git a/backend/app/routers/decisions.py b/backend/app/routers/decisions.py index d3eaa2c..a41e618 100644 --- a/backend/app/routers/decisions.py +++ b/backend/app/routers/decisions.py @@ -31,13 +31,20 @@ router = APIRouter() # ── Helpers ───────────────────────────────────────────────────────────────── -async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision: - """Fetch a decision by ID with its steps eagerly loaded, or raise 404.""" - result = await db.execute( +async def _get_decision( + db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None +) -> Decision: + """Fetch a decision by ID within the active org scope, or raise 404.""" + stmt = ( select(Decision) .options(selectinload(Decision.steps)) .where(Decision.id == decision_id) ) + if org_id is not None: + stmt = stmt.where(Decision.organization_id == org_id) + else: + stmt = stmt.where(Decision.organization_id.is_(None)) + result = await db.execute(stmt) decision = result.scalar_one_or_none() if decision is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable") @@ -61,6 +68,8 @@ async def list_decisions( if org_id is not None: stmt = stmt.where(Decision.organization_id == org_id) + else: + stmt = stmt.where(Decision.organization_id.is_(None)) if decision_type is not None: stmt = stmt.where(Decision.decision_type == decision_type) if status_filter is not None: @@ -91,7 +100,7 @@ async def create_decision( await db.refresh(decision) # Reload with steps (empty at creation) - decision = await _get_decision(db, decision.id) + decision = await _get_decision(db, decision.id, org_id) return DecisionOut.model_validate(decision) @@ -99,9 +108,10 @@ async def create_decision( async def get_decision( id: uuid.UUID, db: AsyncSession = Depends(get_db), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> DecisionOut: """Get a single decision with all its steps.""" - decision = await _get_decision(db, id) + decision = await _get_decision(db, id, org_id) return DecisionOut.model_validate(decision) @@ -111,9 +121,10 @@ async def update_decision( payload: DecisionUpdate, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> DecisionOut: """Update a decision's metadata (title, description, status, protocol).""" - decision = await _get_decision(db, id) + decision = await _get_decision(db, id, org_id) update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): @@ -123,7 +134,7 @@ async def update_decision( await db.refresh(decision) # Reload with steps - decision = await _get_decision(db, decision.id) + decision = await _get_decision(db, decision.id, org_id) return DecisionOut.model_validate(decision) @@ -136,10 +147,11 @@ async def add_step( payload: DecisionStepCreate, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> DecisionStepOut: """Add a step to a decision process.""" # Verify decision exists - decision = await _get_decision(db, id) + decision = await _get_decision(db, id, org_id) step = DecisionStep( decision_id=decision.id, @@ -160,6 +172,7 @@ async def advance_decision_endpoint( id: uuid.UUID, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> DecisionAdvanceOut: """Advance a decision to its next step or status.""" try: @@ -168,7 +181,7 @@ async def advance_decision_endpoint( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) # Reload with steps for complete output - decision = await _get_decision(db, decision.id) + decision = await _get_decision(db, decision.id, org_id) data = DecisionOut.model_validate(decision).model_dump() data["message"] = f"Decision avancee au statut : {decision.status}" return DecisionAdvanceOut(**data) @@ -184,6 +197,7 @@ async def create_vote_session_for_step_endpoint( step_id: uuid.UUID, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> VoteSessionOut: """Create a vote session linked to a decision step.""" try: @@ -199,9 +213,10 @@ async def delete_decision( id: uuid.UUID, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> None: """Delete a decision (only if in draft status).""" - decision = await _get_decision(db, id) + decision = await _get_decision(db, id, org_id) if decision.status != "draft": raise HTTPException( diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index 7d64b9f..4ac08cc 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -77,6 +77,8 @@ async def list_documents( if org_id is not None: stmt = stmt.where(Document.organization_id == org_id) + else: + stmt = stmt.where(Document.organization_id.is_(None)) if doc_type is not None: stmt = stmt.where(Document.doc_type == doc_type) if status_filter is not None: diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 0989607..897918d 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db +from app.dependencies.org import get_active_org_id from app.models.group import Group, GroupMember from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary from app.services.auth_service import get_current_identity @@ -18,24 +19,18 @@ from app.services.auth_service import get_current_identity router = APIRouter() -def _org_id_from_header(request_headers) -> uuid.UUID | None: - raw = request_headers.get("x-organization") - if not raw: - return None - try: - return uuid.UUID(raw) - except ValueError: - return None - - @router.get("/", response_model=list[GroupSummary]) async def list_groups( db: AsyncSession = Depends(get_db), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> list[GroupSummary]: - """List all groups. No auth required — groups are public within the workspace.""" - result = await db.execute( - select(Group).options(selectinload(Group.members)).order_by(Group.name) - ) + """List groups within the active workspace.""" + stmt = select(Group).options(selectinload(Group.members)).order_by(Group.name) + if org_id is not None: + stmt = stmt.where(Group.organization_id == org_id) + else: + stmt = stmt.where(Group.organization_id.is_(None)) + result = await db.execute(stmt) groups = result.scalars().all() return [ GroupSummary( @@ -54,8 +49,9 @@ async def create_group( payload: GroupCreate, db: AsyncSession = Depends(get_db), _identity=Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> GroupOut: - group = Group(name=payload.name, description=payload.description) + group = Group(name=payload.name, description=payload.description, organization_id=org_id) db.add(group) await db.commit() await db.refresh(group) diff --git a/backend/app/routers/mandates.py b/backend/app/routers/mandates.py index dd96f1f..794e95c 100644 --- a/backend/app/routers/mandates.py +++ b/backend/app/routers/mandates.py @@ -37,8 +37,10 @@ router = APIRouter() # ── Helpers ───────────────────────────────────────────────────────────────── -async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: - result = await db.execute( +async def _get_mandate( + db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None +) -> Mandate: + stmt = ( select(Mandate) .options( selectinload(Mandate.steps), @@ -47,6 +49,11 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: ) .where(Mandate.id == mandate_id) ) + if org_id is not None: + stmt = stmt.where(Mandate.organization_id == org_id) + else: + stmt = stmt.where(Mandate.organization_id.is_(None)) + result = await db.execute(stmt) mandate = result.scalar_one_or_none() if mandate is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable") @@ -80,6 +87,8 @@ async def list_mandates( if org_id is not None: stmt = stmt.where(Mandate.organization_id == org_id) + else: + stmt = stmt.where(Mandate.organization_id.is_(None)) if mandate_type is not None: stmt = stmt.where(Mandate.mandate_type == mandate_type) if status_filter is not None: @@ -111,7 +120,7 @@ async def create_mandate( await db.commit() await db.refresh(mandate) - mandate = await _get_mandate(db, mandate.id) + mandate = await _get_mandate(db, mandate.id, org_id) return _mandate_out(mandate) @@ -119,8 +128,9 @@ async def create_mandate( async def get_mandate( id: uuid.UUID, db: AsyncSession = Depends(get_db), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> MandateOut: - mandate = await _get_mandate(db, id) + mandate = await _get_mandate(db, id, org_id) return _mandate_out(mandate) @@ -130,14 +140,15 @@ async def update_mandate( payload: MandateUpdate, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> MandateOut: - mandate = await _get_mandate(db, id) + mandate = await _get_mandate(db, id, org_id) for field, value in payload.model_dump(exclude_unset=True).items(): setattr(mandate, field, value) await db.commit() - mandate = await _get_mandate(db, mandate.id) + mandate = await _get_mandate(db, mandate.id, org_id) return _mandate_out(mandate) @@ -146,8 +157,9 @@ async def delete_mandate( id: uuid.UUID, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> None: - mandate = await _get_mandate(db, id) + mandate = await _get_mandate(db, id, org_id) if mandate.status != "draft": raise HTTPException( @@ -168,8 +180,9 @@ async def add_step( payload: MandateStepCreate, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> MandateStepOut: - mandate = await _get_mandate(db, id) + mandate = await _get_mandate(db, id, org_id) step = MandateStep(mandate_id=mandate.id, **payload.model_dump()) db.add(step) @@ -183,8 +196,9 @@ async def add_step( async def list_steps( id: uuid.UUID, db: AsyncSession = Depends(get_db), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> list[MandateStepOut]: - mandate = await _get_mandate(db, id) + mandate = await _get_mandate(db, id, org_id) return [MandateStepOut.model_validate(s) for s in mandate.steps] @@ -196,13 +210,14 @@ async def advance_mandate_endpoint( id: uuid.UUID, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> MandateAdvanceOut: try: mandate = await advance_mandate(id, db) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) - mandate = await _get_mandate(db, mandate.id) + mandate = await _get_mandate(db, mandate.id, org_id) out = _mandate_out(mandate) return MandateAdvanceOut( **out.model_dump(), @@ -216,13 +231,14 @@ async def assign_mandatee_endpoint( payload: MandateAssignRequest, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> MandateOut: try: mandate = await assign_mandatee(id, payload.mandatee_id, db) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) - mandate = await _get_mandate(db, mandate.id) + mandate = await _get_mandate(db, mandate.id, org_id) return _mandate_out(mandate) @@ -231,13 +247,14 @@ async def revoke_mandate_endpoint( id: uuid.UUID, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> MandateOut: try: mandate = await revoke_mandate(id, db) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) - mandate = await _get_mandate(db, mandate.id) + mandate = await _get_mandate(db, mandate.id, org_id) return _mandate_out(mandate) @@ -251,6 +268,7 @@ async def create_vote_session_for_step_endpoint( step_id: uuid.UUID, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> VoteSessionOut: try: session = await create_vote_session_for_step(id, step_id, db) diff --git a/backend/app/routers/protocols.py b/backend/app/routers/protocols.py index ace6d2f..91ea783 100644 --- a/backend/app/routers/protocols.py +++ b/backend/app/routers/protocols.py @@ -34,13 +34,20 @@ router = APIRouter() # ── Helpers ───────────────────────────────────────────────────────────────── -async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol: - """Fetch a voting protocol by ID with its formula config, or raise 404.""" - result = await db.execute( +async def _get_protocol( + db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None +) -> VotingProtocol: + """Fetch a voting protocol by ID within the active org scope, or raise 404.""" + stmt = ( select(VotingProtocol) .options(selectinload(VotingProtocol.formula_config)) .where(VotingProtocol.id == protocol_id) ) + if org_id is not None: + stmt = stmt.where(VotingProtocol.organization_id == org_id) + else: + stmt = stmt.where(VotingProtocol.organization_id.is_(None)) + result = await db.execute(stmt) protocol = result.scalar_one_or_none() if protocol is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable") @@ -74,6 +81,8 @@ async def list_protocols( if org_id is not None: stmt = stmt.where(VotingProtocol.organization_id == org_id) + else: + stmt = stmt.where(VotingProtocol.organization_id.is_(None)) if vote_type is not None: stmt = stmt.where(VotingProtocol.vote_type == vote_type) @@ -111,7 +120,7 @@ async def create_protocol( await db.refresh(protocol) # Reload with formula config - protocol = await _get_protocol(db, protocol.id) + protocol = await _get_protocol(db, protocol.id, org_id) return VotingProtocolOut.model_validate(protocol) @@ -119,9 +128,10 @@ async def create_protocol( async def get_protocol( id: uuid.UUID, db: AsyncSession = Depends(get_db), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> VotingProtocolOut: """Get a single voting protocol with its formula configuration.""" - protocol = await _get_protocol(db, id) + protocol = await _get_protocol(db, id, org_id) return VotingProtocolOut.model_validate(protocol) @@ -131,12 +141,13 @@ async def update_protocol( payload: VotingProtocolUpdate, db: AsyncSession = Depends(get_db), identity: DuniterIdentity = Depends(get_current_identity), + org_id: uuid.UUID | None = Depends(get_active_org_id), ) -> VotingProtocolOut: """Update a voting protocol (meta-governance). Only provided fields will be updated. Requires authentication. """ - protocol = await _get_protocol(db, id) + protocol = await _get_protocol(db, id, org_id) update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): @@ -145,7 +156,7 @@ async def update_protocol( await db.commit() # Reload with formula config - protocol = await _get_protocol(db, protocol.id) + protocol = await _get_protocol(db, protocol.id, org_id) return VotingProtocolOut.model_validate(protocol) diff --git a/backend/app/routers/votes.py b/backend/app/routers/votes.py index c8f9d5b..0d15255 100644 --- a/backend/app/routers/votes.py +++ b/backend/app/routers/votes.py @@ -25,6 +25,7 @@ from app.schemas.vote import ( VoteSessionListOut, VoteSessionOut, ) +from app.dependencies.org import get_active_org_id from app.services.auth_service import get_current_identity from app.services.vote_service import ( close_session as svc_close_session, @@ -170,14 +171,22 @@ def _compute_result( @router.get("/sessions", response_model=list[VoteSessionListOut]) async def list_vote_sessions( db: AsyncSession = Depends(get_db), + org_id: uuid.UUID | None = Depends(get_active_org_id), session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"), decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"), skip: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=200), ) -> list[VoteSessionListOut]: """List all vote sessions with optional filters by status and decision_id.""" - stmt = select(VoteSession) + stmt = ( + select(VoteSession) + .join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id) + ) + if org_id is not None: + stmt = stmt.where(VotingProtocol.organization_id == org_id) + else: + stmt = stmt.where(VotingProtocol.organization_id.is_(None)) if session_status is not None: stmt = stmt.where(VoteSession.status == session_status) if decision_id is not None: