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>
This commit is contained in:
280
backend/app/api/organizations.py
Normal file
280
backend/app/api/organizations.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user