commissions.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import secrets
  2. from uuid import UUID
  3. from fastapi import APIRouter, Depends, HTTPException
  4. from sqlalchemy import select
  5. from sqlalchemy.orm import Session, selectinload
  6. from app.api import deps
  7. from app.api.utils import assert_project_exists_or_404, issue_reset_token
  8. from app.core.config import settings
  9. from app.core.email import EmailSender, get_email_sender
  10. from app.core.security import get_password_hash
  11. from app.models import Commission, OrgRole, Project, User, UserOrganization
  12. from app.schemas.requests import (
  13. CommissionCreateRequest,
  14. CommissionMembershipRequest,
  15. CommissionUpdateRequest,
  16. UserInviteRequest,
  17. )
  18. from app.schemas.responses import CommissionResponse
  19. router = APIRouter(prefix="/project/{project_id}", tags=["commissions"])
  20. READ_COMMISSIONS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
  21. MANAGE_COMMISSIONS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
  22. def _get_commission_or_404(session: Session, project_id: UUID, commission_id: UUID) -> Commission:
  23. commission = session.scalar(
  24. select(Commission)
  25. .where(Commission.id == commission_id)
  26. .options(selectinload(Commission.members))
  27. )
  28. if commission is None or str(commission.project_id) != str(project_id):
  29. raise HTTPException(status_code=404, detail="Commission not found")
  30. return commission
  31. @router.get("/commissions", response_model=list[CommissionResponse])
  32. async def list_project_commissions(
  33. project_id: UUID,
  34. current_user: User = Depends(deps.require_org_role(*READ_COMMISSIONS)),
  35. session: Session = Depends(deps.get_session),
  36. ):
  37. """List all commissions for a specific project"""
  38. assert_project_exists_or_404(session, project_id)
  39. results = session.execute(select(Commission).where(Commission.project_id == project_id))
  40. return results.scalars().all()
  41. @router.post("/commission", response_model=CommissionResponse)
  42. async def create_commission(
  43. project_id: UUID,
  44. new_commission: CommissionCreateRequest,
  45. current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
  46. session: Session = Depends(deps.get_session),
  47. ):
  48. """Create a new commission in the project"""
  49. assert_project_exists_or_404(session, project_id)
  50. commission = Commission(project_id=project_id, **new_commission.model_dump())
  51. session.add(commission)
  52. session.commit()
  53. session.refresh(commission)
  54. return commission
  55. @router.get("/commission/{commission_id}", response_model=CommissionResponse)
  56. async def get_commission(
  57. project_id: UUID,
  58. commission_id: UUID,
  59. current_user: User = Depends(deps.require_org_role(*READ_COMMISSIONS)),
  60. session: Session = Depends(deps.get_session),
  61. ):
  62. """Get a single commission"""
  63. return _get_commission_or_404(session, project_id, commission_id)
  64. @router.post("/commission/{commission_id}", response_model=CommissionResponse)
  65. async def update_commission(
  66. project_id: UUID,
  67. commission_id: UUID,
  68. edit_commission: CommissionUpdateRequest,
  69. current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
  70. session: Session = Depends(deps.get_session),
  71. ):
  72. """Edit a commission's details"""
  73. commission = _get_commission_or_404(session, project_id, commission_id)
  74. commission.name = edit_commission.name
  75. session.commit()
  76. session.refresh(commission)
  77. return commission
  78. @router.delete("/commission/{commission_id}")
  79. async def delete_commission(
  80. project_id: UUID,
  81. commission_id: UUID,
  82. current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
  83. session: Session = Depends(deps.get_session),
  84. ):
  85. """Delete a commission (automatically deletes CommissionMember links via cascade)"""
  86. commission = _get_commission_or_404(session, project_id, commission_id)
  87. session.delete(commission)
  88. session.commit()
  89. @router.post("/commission/{commission_id}/members", response_model=CommissionResponse)
  90. async def add_members_to_commission(
  91. project_id: UUID,
  92. commission_id: UUID,
  93. payload: CommissionMembershipRequest,
  94. current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
  95. session: Session = Depends(deps.get_session),
  96. ):
  97. """Bulk-add users as members to a commission (idempotent)"""
  98. commission = _get_commission_or_404(session, project_id, commission_id)
  99. # Verify users exist
  100. valid_users = session.scalars(select(User).where(User.id.in_(payload.user_ids))).all()
  101. if len(valid_users) != len(payload.user_ids):
  102. raise HTTPException(status_code=400, detail="One or more invalid user IDs")
  103. existing_ids = {u.id for u in commission.members}
  104. for user in valid_users:
  105. if user.id not in existing_ids:
  106. commission.members.append(user)
  107. session.commit()
  108. return _get_commission_or_404(session, project_id, commission_id)
  109. @router.delete("/commission/{commission_id}/member/{user_id}", response_model=CommissionResponse)
  110. async def remove_member_from_commission(
  111. project_id: UUID,
  112. commission_id: UUID,
  113. user_id: UUID,
  114. current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
  115. session: Session = Depends(deps.get_session),
  116. ):
  117. """Remove a single user from a commission"""
  118. commission = _get_commission_or_404(session, project_id, commission_id)
  119. member_to_remove = next((m for m in commission.members if str(m.id) == str(user_id)), None)
  120. if member_to_remove:
  121. session.delete(member_to_remove)
  122. session.commit()
  123. return _get_commission_or_404(session, project_id, commission_id)
  124. @router.post(
  125. "/commission/{commission_id}/invite-member",
  126. response_model=CommissionResponse,
  127. )
  128. async def invite_commission_member(
  129. project_id: UUID,
  130. commission_id: UUID,
  131. payload: UserInviteRequest,
  132. current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
  133. session: Session = Depends(deps.get_session),
  134. email_sender: EmailSender = Depends(get_email_sender),
  135. ):
  136. commission = _get_commission_or_404(session, project_id, commission_id)
  137. project = session.get(Project, project_id)
  138. user = session.scalar(select(User).where(User.email == payload.email))
  139. is_new_user = user is None
  140. if payload.org_role == OrgRole.ORG_ADMIN:
  141. caller_membership = session.get(
  142. UserOrganization, (current_user.id, project.organization_id)
  143. )
  144. is_caller_org_admin = (
  145. caller_membership is not None and caller_membership.role == OrgRole.ORG_ADMIN
  146. )
  147. if not (is_caller_org_admin or current_user.global_role == "super_admin"):
  148. raise HTTPException(status_code=403, detail="Only an org_admin can grant org_admin")
  149. if is_new_user:
  150. user = User(
  151. email=payload.email,
  152. name=payload.name,
  153. phone_number=payload.phone_number,
  154. hashed_password=get_password_hash(secrets.token_urlsafe(32)), # unusable placeholder
  155. must_change_password=True,
  156. )
  157. session.add(user)
  158. session.flush()
  159. existing_membership = session.get(UserOrganization, (user.id, project.organization_id))
  160. if existing_membership is None:
  161. session.add(
  162. UserOrganization(
  163. user_id=user.id,
  164. organization_id=project.organization_id,
  165. role=payload.org_role,
  166. )
  167. )
  168. if user not in commission.members:
  169. commission.members.append(user)
  170. session.commit()
  171. if is_new_user:
  172. await issue_reset_token(session, user, "invite", email_sender, settings.FRONT_END_URL)
  173. return _get_commission_or_404(session, project_id, commission_id)