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()