| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- import secrets
- from uuid import UUID
- from fastapi import APIRouter, Depends, HTTPException
- from sqlalchemy import select
- from sqlalchemy.exc import IntegrityError
- from sqlalchemy.orm import Session, selectinload
- from app.api import deps
- from app.api.utils import 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 GlobalRole, Organization, OrgRole, User, UserOrganization
- from app.schemas.requests import (
- OrganizationMembershipCreateRequest,
- OrganizationMembershipUpdateRequest,
- OrganizationRequest,
- UserInviteRequest,
- )
- from app.schemas.responses import (
- OrganizationDetailResponse,
- OrganizationMembershipResponse,
- OrganizationResponse,
- )
- router = APIRouter()
- @router.get("/", response_model=list[OrganizationResponse])
- async def list_organizations(
- current_user: User = Depends(deps.get_current_user),
- session: Session = Depends(deps.get_session),
- ):
- """List organizations. super_admin sees all; others see only orgs they belong to."""
- if current_user.global_role == GlobalRole.SUPER_ADMIN:
- results = session.execute(select(Organization))
- else:
- org_ids = [m.organization_id for m in current_user.organizations]
- if not org_ids:
- return []
- results = session.execute(select(Organization).where(Organization.id.in_(org_ids)))
- return results.scalars().all()
- @router.post("/", response_model=OrganizationResponse)
- async def create_organization(
- new_org: OrganizationRequest,
- current_user: User = Depends(deps.require_super_admin),
- session: Session = Depends(deps.get_session),
- ):
- """Create a new organization. super_admin only."""
- org = Organization(**new_org.model_dump())
- session.add(org)
- try:
- session.commit()
- except IntegrityError:
- raise HTTPException(400, "Organization name already exists")
- session.refresh(org)
- return org
- @router.get("/{organization_id}", response_model=OrganizationDetailResponse)
- async def get_organization(
- organization_id: UUID,
- current_user: User = Depends(deps.get_current_user),
- session: Session = Depends(deps.get_session),
- ):
- """Get organization detail including members.
- super_admin, or any member of the organization, may view it."""
- org = (
- session.execute(
- select(Organization)
- .where(Organization.id == str(organization_id))
- .options(selectinload(Organization.memberships).selectinload(UserOrganization.user))
- )
- .scalars()
- .first()
- )
- if org is None:
- raise HTTPException(404, "Organization not found")
- if current_user.global_role != GlobalRole.SUPER_ADMIN:
- is_member = any(m.organization_id == org.id for m in current_user.organizations)
- if not is_member:
- raise HTTPException(403, "Not a member of this organization")
- return org
- @router.post("/{organization_id}", response_model=OrganizationResponse)
- async def update_organization(
- organization_id: UUID,
- edit_org: OrganizationRequest,
- current_user: User = Depends(deps.require_super_admin),
- session: Session = Depends(deps.get_session),
- ):
- """Rename an organization. super_admin only."""
- org = session.get(Organization, str(organization_id))
- if org is None:
- raise HTTPException(404, "Organization not found")
- org.name = edit_org.name
- try:
- session.commit()
- except IntegrityError:
- raise HTTPException(400, "Organization name already exists")
- session.refresh(org)
- return org
- @router.delete("/{organization_id}")
- async def delete_organization(
- organization_id: UUID,
- current_user: User = Depends(deps.require_super_admin),
- session: Session = Depends(deps.get_session),
- ):
- """Delete an organization. Cascades to every project (and everything under
- those projects) in the org — this is intentionally destructive, confirm
- client-side before calling this."""
- org = session.get(Organization, str(organization_id))
- if org is None:
- raise HTTPException(404, "Organization not found")
- session.delete(org)
- session.commit()
- @router.post("/{organization_id}/members", response_model=OrganizationMembershipResponse)
- async def add_member(
- organization_id: UUID,
- payload: OrganizationMembershipCreateRequest,
- current_user: User = Depends(deps.require_super_admin),
- session: Session = Depends(deps.get_session),
- ):
- """Add an existing user to an organization with a role. super_admin only.
- Does NOT create user accounts — the user must already have signed up.
- (User self-registration / invite-by-email is a separate concern for the
- users.py router; this endpoint only manages org membership.)"""
- org = session.get(Organization, organization_id)
- if org is None:
- raise HTTPException(404, "Organization not found")
- user = session.get(User, payload.user_id)
- if user is None:
- raise HTTPException(404, "User not found")
- existing = session.get(UserOrganization, (payload.user_id, organization_id))
- if existing is not None:
- raise HTTPException(400, "User is already a member of this organization")
- membership = UserOrganization(
- user_id=payload.user_id, organization_id=organization_id, role=payload.role
- )
- session.add(membership)
- session.commit()
- session.refresh(membership)
- return membership
- @router.post("/{organization_id}/members/{user_id}", response_model=OrganizationMembershipResponse)
- async def update_member_role(
- organization_id: UUID,
- user_id: UUID,
- payload: OrganizationMembershipUpdateRequest,
- current_user: User = Depends(deps.require_organization_role(OrgRole.ORG_ADMIN)),
- session: Session = Depends(deps.get_session),
- ):
- """Change a member's role within the organization. super_admin only."""
- membership = session.get(UserOrganization, (str(user_id), str(organization_id)))
- if membership is None:
- raise HTTPException(404, "Membership not found")
- membership.role = payload.role
- session.commit()
- session.refresh(membership)
- return membership
- @router.post("/{organization_id}/invite-member", response_model=OrganizationMembershipResponse)
- async def invite_member(
- organization_id: UUID,
- payload: UserInviteRequest,
- current_user: User = Depends(deps.require_organization_role(OrgRole.ORG_ADMIN)),
- session: Session = Depends(deps.get_session),
- email_sender: EmailSender = Depends(get_email_sender),
- ):
- """Invite a user into this organization with a given role.
- If no account exists for the email yet, one is created with an unusable placeholder password and a reset-token email is sent so the person can
- set their own password.
- If they already have an account, they're simply added as a member (no email sent -- they already have credentials).
- Idempotent on membership: inviting someone already in the org is a no-op on the membership row, not an error,
- since re-sending an invite to fix a typo'd name/role shouldn't fail outright."""
- org = session.get(Organization, str(organization_id))
- if org is None:
- raise HTTPException(status_code=404, detail="Organization not found")
- user = session.scalar(select(User).where(User.email == payload.email))
- is_new_user = user is None
- 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()
- membership = session.get(UserOrganization, (user.id, org.id))
- if membership is None:
- membership = UserOrganization(
- user_id=user.id,
- organization_id=org.id,
- role=payload.org_role,
- )
- session.add(membership)
- else:
- membership.role = payload.org_role
- session.commit()
- session.refresh(membership)
- if is_new_user:
- await issue_reset_token(session, user, "invite", email_sender, settings.FRONT_END_URL)
- return membership
- @router.delete("/{organization_id}/members/{user_id}")
- async def remove_member(
- organization_id: UUID,
- user_id: UUID,
- current_user: User = Depends(deps.require_organization_role(OrgRole.ORG_ADMIN)),
- session: Session = Depends(deps.get_session),
- ):
- """Remove a user from an organization. super_admin only."""
- membership = session.get(UserOrganization, (str(user_id), str(organization_id)))
- if membership is None:
- raise HTTPException(404, "Membership not found")
- session.delete(membership)
- session.commit()
|