Files
space-booking/backend/app/api/organizations.py
Claude Agent e21cf03a16 feat: add multi-tenant system with properties, organizations, and public booking
Implement complete multi-property architecture:
- Properties (groups of spaces) with public/private visibility
- Property managers (many-to-many) with role-based permissions
- Organizations with member management
- Anonymous/guest booking support via public API (/api/public/*)
- Property-scoped spaces, bookings, and settings
- Frontend: property selector, organization management, public booking views
- Migration script and updated seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:17:21 +00:00

281 lines
9.7 KiB
Python

"""Organization management endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.user import User
from app.schemas.organization import (
AddMemberRequest,
OrganizationCreate,
OrganizationMemberResponse,
OrganizationResponse,
OrganizationUpdate,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
admin_router = APIRouter(prefix="/admin/organizations", tags=["organizations-admin"])
@router.get("", response_model=list[OrganizationResponse])
def list_organizations(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
) -> list[OrganizationResponse]:
"""List organizations (authenticated users)."""
orgs = db.query(Organization).filter(Organization.is_active == True).order_by(Organization.name).all() # noqa: E712
result = []
for org in orgs:
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
result.append(OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
))
return result
@router.get("/{org_id}", response_model=OrganizationResponse)
def get_organization(
org_id: int,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
) -> OrganizationResponse:
"""Get organization detail."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
)
@router.get("/{org_id}/members", response_model=list[OrganizationMemberResponse])
def list_organization_members(
org_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[OrganizationMemberResponse]:
"""List organization members (org admin or superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# Check permission: superadmin or org admin
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
members = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org_id).all()
result = []
for m in members:
u = db.query(User).filter(User.id == m.user_id).first()
result.append(OrganizationMemberResponse(
id=m.id,
organization_id=m.organization_id,
user_id=m.user_id,
role=m.role,
user_name=u.full_name if u else None,
user_email=u.email if u else None,
))
return result
@router.post("/{org_id}/members", response_model=OrganizationMemberResponse, status_code=status.HTTP_201_CREATED)
def add_organization_member(
org_id: int,
data: AddMemberRequest,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> OrganizationMemberResponse:
"""Add member to organization (org admin or superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# Check permission
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
# Check if user exists
user = db.query(User).filter(User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if already member
existing = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == data.user_id,
).first()
if existing:
raise HTTPException(status_code=400, detail="User is already a member")
member = OrganizationMember(
organization_id=org_id,
user_id=data.user_id,
role=data.role,
)
db.add(member)
db.commit()
db.refresh(member)
return OrganizationMemberResponse(
id=member.id,
organization_id=member.organization_id,
user_id=member.user_id,
role=member.role,
user_name=user.full_name,
user_email=user.email,
)
@router.delete("/{org_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_organization_member(
org_id: int,
user_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> None:
"""Remove member from organization."""
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
member = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
).first()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
db.delete(member)
db.commit()
@router.put("/{org_id}/members/{user_id}", response_model=OrganizationMemberResponse)
def update_member_role(
org_id: int,
user_id: int,
data: AddMemberRequest,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> OrganizationMemberResponse:
"""Change member role in organization."""
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
member = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
).first()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
member.role = data.role
db.commit()
db.refresh(member)
u = db.query(User).filter(User.id == user_id).first()
return OrganizationMemberResponse(
id=member.id,
organization_id=member.organization_id,
user_id=member.user_id,
role=member.role,
user_name=u.full_name if u else None,
user_email=u.email if u else None,
)
# === Superadmin endpoints ===
@admin_router.post("", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
def create_organization(
data: OrganizationCreate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
) -> OrganizationResponse:
"""Create an organization (superadmin)."""
existing = db.query(Organization).filter(Organization.name == data.name).first()
if existing:
raise HTTPException(status_code=400, detail="Organization with this name already exists")
org = Organization(name=data.name, description=data.description)
db.add(org)
db.commit()
db.refresh(org)
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=0,
)
@admin_router.put("/{org_id}", response_model=OrganizationResponse)
def update_organization(
org_id: int,
data: OrganizationUpdate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
) -> OrganizationResponse:
"""Update an organization (superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
if data.name is not None:
org.name = data.name
if data.description is not None:
org.description = data.description
db.commit()
db.refresh(org)
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
)