import secrets from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from app.api import deps from app.api.utils import assert_project_exists_or_404, issue_reset_token from app.core.config import settings from app.core.email import EmailSender, get_email_sender from app.core.security import get_password_hash from app.models import Commission, OrgRole, Project, User, UserOrganization from app.schemas.requests import ( CommissionCreateRequest, CommissionMembershipRequest, CommissionUpdateRequest, UserInviteRequest, ) from app.schemas.responses import CommissionResponse router = APIRouter(prefix="/project/{project_id}", tags=["commissions"]) READ_COMMISSIONS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION) MANAGE_COMMISSIONS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE) def _get_commission_or_404(session: Session, project_id: UUID, commission_id: UUID) -> Commission: commission = session.scalar( select(Commission) .where(Commission.id == commission_id) .options(selectinload(Commission.members)) ) if commission is None or str(commission.project_id) != str(project_id): raise HTTPException(status_code=404, detail="Commission not found") return commission @router.get("/commissions", response_model=list[CommissionResponse]) async def list_project_commissions( project_id: UUID, current_user: User = Depends(deps.require_org_role(*READ_COMMISSIONS)), session: Session = Depends(deps.get_session), ): """List all commissions for a specific project""" assert_project_exists_or_404(session, project_id) results = session.execute(select(Commission).where(Commission.project_id == project_id)) return results.scalars().all() @router.post("/commission", response_model=CommissionResponse) async def create_commission( project_id: UUID, new_commission: CommissionCreateRequest, current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)), session: Session = Depends(deps.get_session), ): """Create a new commission in the project""" assert_project_exists_or_404(session, project_id) commission = Commission(project_id=project_id, **new_commission.model_dump()) session.add(commission) session.commit() session.refresh(commission) return commission @router.get("/commission/{commission_id}", response_model=CommissionResponse) async def get_commission( project_id: UUID, commission_id: UUID, current_user: User = Depends(deps.require_org_role(*READ_COMMISSIONS)), session: Session = Depends(deps.get_session), ): """Get a single commission""" return _get_commission_or_404(session, project_id, commission_id) @router.post("/commission/{commission_id}", response_model=CommissionResponse) async def update_commission( project_id: UUID, commission_id: UUID, edit_commission: CommissionUpdateRequest, current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)), session: Session = Depends(deps.get_session), ): """Edit a commission's details""" commission = _get_commission_or_404(session, project_id, commission_id) commission.name = edit_commission.name session.commit() session.refresh(commission) return commission @router.delete("/commission/{commission_id}") async def delete_commission( project_id: UUID, commission_id: UUID, current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)), session: Session = Depends(deps.get_session), ): """Delete a commission (automatically deletes CommissionMember links via cascade)""" commission = _get_commission_or_404(session, project_id, commission_id) session.delete(commission) session.commit() @router.post("/commission/{commission_id}/members", response_model=CommissionResponse) async def add_members_to_commission( project_id: UUID, commission_id: UUID, payload: CommissionMembershipRequest, current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)), session: Session = Depends(deps.get_session), ): """Bulk-add users as members to a commission (idempotent)""" commission = _get_commission_or_404(session, project_id, commission_id) # Verify users exist valid_users = session.scalars(select(User).where(User.id.in_(payload.user_ids))).all() if len(valid_users) != len(payload.user_ids): raise HTTPException(status_code=400, detail="One or more invalid user IDs") existing_ids = {u.id for u in commission.members} for user in valid_users: if user.id not in existing_ids: commission.members.append(user) session.commit() return _get_commission_or_404(session, project_id, commission_id) @router.delete("/commission/{commission_id}/member/{user_id}", response_model=CommissionResponse) async def remove_member_from_commission( project_id: UUID, commission_id: UUID, user_id: UUID, current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)), session: Session = Depends(deps.get_session), ): """Remove a single user from a commission""" commission = _get_commission_or_404(session, project_id, commission_id) member_to_remove = next((m for m in commission.members if str(m.id) == str(user_id)), None) if member_to_remove: session.delete(member_to_remove) session.commit() return _get_commission_or_404(session, project_id, commission_id) @router.post( "/commission/{commission_id}/invite-member", response_model=CommissionResponse, ) async def invite_commission_member( project_id: UUID, commission_id: UUID, payload: UserInviteRequest, current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)), session: Session = Depends(deps.get_session), email_sender: EmailSender = Depends(get_email_sender), ): commission = _get_commission_or_404(session, project_id, commission_id) project = session.get(Project, project_id) user = session.scalar(select(User).where(User.email == payload.email)) is_new_user = user is None if payload.org_role == OrgRole.ORG_ADMIN: caller_membership = session.get( UserOrganization, (current_user.id, project.organization_id) ) is_caller_org_admin = ( caller_membership is not None and caller_membership.role == OrgRole.ORG_ADMIN ) if not (is_caller_org_admin or current_user.global_role == "super_admin"): raise HTTPException(status_code=403, detail="Only an org_admin can grant org_admin") if is_new_user: user = User( email=payload.email, name=payload.name, phone_number=payload.phone_number, hashed_password=get_password_hash(secrets.token_urlsafe(32)), # unusable placeholder must_change_password=True, ) session.add(user) session.flush() existing_membership = session.get(UserOrganization, (user.id, project.organization_id)) if existing_membership is None: session.add( UserOrganization( user_id=user.id, organization_id=project.organization_id, role=payload.org_role, ) ) if user not in commission.members: commission.members.append(user) session.commit() if is_new_user: await issue_reset_token(session, user, "invite", email_sender, settings.FRONT_END_URL) return _get_commission_or_404(session, project_id, commission_id)