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:
Claude Agent
2026-02-15 00:17:21 +00:00
parent d637513d92
commit e21cf03a16
51 changed files with 6324 additions and 273 deletions

View File

@@ -0,0 +1,575 @@
"""Property management endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import (
get_current_admin,
get_current_manager_or_superadmin,
get_current_user,
get_db,
get_optional_user,
)
from app.core.permissions import get_manager_property_ids, verify_property_access
from app.models.organization import Organization
from app.models.property import Property
from app.models.property_access import PropertyAccess
from app.models.property_manager import PropertyManager
from app.models.property_settings import PropertySettings
from app.models.space import Space
from app.models.user import User
from app.schemas.property import (
PropertyAccessCreate,
PropertyAccessResponse,
PropertyCreate,
PropertyManagerInfo,
PropertyResponse,
PropertySettingsResponse,
PropertySettingsUpdate,
PropertyStatusUpdate,
PropertyUpdate,
PropertyWithSpaces,
)
from app.schemas.space import SpaceResponse
from app.services.audit_service import log_action
def _get_property_managers(db: Session, property_id: int) -> list[PropertyManagerInfo]:
"""Get manager info for a property."""
managers = (
db.query(User)
.join(PropertyManager, PropertyManager.user_id == User.id)
.filter(PropertyManager.property_id == property_id)
.all()
)
return [
PropertyManagerInfo(user_id=m.id, full_name=m.full_name, email=m.email)
for m in managers
]
router = APIRouter(prefix="/properties", tags=["properties"])
manager_router = APIRouter(prefix="/manager/properties", tags=["properties-manager"])
admin_router = APIRouter(prefix="/admin/properties", tags=["properties-admin"])
# === User-facing endpoints ===
@router.get("", response_model=list[PropertyResponse])
def list_properties(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
managed_only: bool = False,
) -> list[PropertyResponse]:
"""List visible properties based on user role.
Query params:
- managed_only: If true, managers only see properties they manage (for management pages)
"""
if current_user and current_user.role in ("admin", "superadmin"):
# Superadmin sees all
properties = db.query(Property).filter(Property.is_active == True).order_by(Property.name).all() # noqa: E712
elif current_user and current_user.role == "manager":
# Manager sees managed properties (+ public if not managed_only)
managed_ids = get_manager_property_ids(db, current_user.id)
if managed_only:
properties = (
db.query(Property)
.filter(
Property.is_active == True, # noqa: E712
Property.id.in_(managed_ids),
)
.order_by(Property.name)
.all()
)
else:
properties = (
db.query(Property)
.filter(
Property.is_active == True, # noqa: E712
(Property.is_public == True) | (Property.id.in_(managed_ids)), # noqa: E712
)
.order_by(Property.name)
.all()
)
elif current_user:
# Regular user sees public + explicitly granted
from app.core.permissions import get_user_accessible_property_ids
accessible_ids = get_user_accessible_property_ids(db, current_user.id)
properties = (
db.query(Property)
.filter(Property.is_active == True, Property.id.in_(accessible_ids)) # noqa: E712
.order_by(Property.name)
.all()
)
else:
# Anonymous sees only public
properties = (
db.query(Property)
.filter(Property.is_public == True, Property.is_active == True) # noqa: E712
.order_by(Property.name)
.all()
)
result = []
for p in properties:
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
result.append(PropertyResponse(
id=p.id,
name=p.name,
description=p.description,
address=p.address,
is_public=p.is_public,
is_active=p.is_active,
created_at=p.created_at,
space_count=space_count,
managers=_get_property_managers(db, p.id),
))
return result
@router.get("/{property_id}", response_model=PropertyWithSpaces)
def get_property(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
) -> PropertyWithSpaces:
"""Get property detail with visibility check."""
verify_property_access(db, current_user, property_id)
prop = db.query(Property).filter(Property.id == property_id).first()
spaces = db.query(Space).filter(Space.property_id == property_id, Space.is_active == True).all() # noqa: E712
space_count = len(spaces)
return PropertyWithSpaces(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=space_count,
managers=_get_property_managers(db, prop.id),
spaces=[SpaceResponse.model_validate(s) for s in spaces],
)
@router.get("/{property_id}/spaces", response_model=list[SpaceResponse])
def get_property_spaces(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
include_inactive: bool = False,
) -> list[SpaceResponse]:
"""List spaces in a property."""
verify_property_access(db, current_user, property_id)
query = db.query(Space).filter(Space.property_id == property_id)
# Managers/admins can see inactive spaces, regular users cannot
is_admin_like = current_user and current_user.role in ("admin", "superadmin", "manager")
if not (include_inactive and is_admin_like):
query = query.filter(Space.is_active == True) # noqa: E712
spaces = query.order_by(Space.name).all()
return [SpaceResponse.model_validate(s) for s in spaces]
# === Manager endpoints ===
@manager_router.post("", response_model=PropertyResponse, status_code=status.HTTP_201_CREATED)
def create_property(
data: PropertyCreate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Create a property. Creator becomes manager."""
prop = Property(
name=data.name,
description=data.description,
address=data.address,
is_public=data.is_public,
)
db.add(prop)
db.commit()
db.refresh(prop)
# Creator becomes manager
pm = PropertyManager(property_id=prop.id, user_id=current_user.id)
db.add(pm)
db.commit()
log_action(
db=db,
action="property_created",
user_id=current_user.id,
target_type="property",
target_id=prop.id,
details={"name": prop.name},
)
return PropertyResponse(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=0,
managers=_get_property_managers(db, prop.id),
)
@manager_router.put("/{property_id}", response_model=PropertyResponse)
def update_property(
property_id: int,
data: PropertyUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Update a property (ownership check)."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
if data.name is not None:
prop.name = data.name
if data.description is not None:
prop.description = data.description
if data.address is not None:
prop.address = data.address
if data.is_public is not None:
prop.is_public = data.is_public
db.commit()
db.refresh(prop)
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
return PropertyResponse(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=space_count,
managers=_get_property_managers(db, prop.id),
)
@manager_router.patch("/{property_id}/status", response_model=PropertyResponse)
def update_property_status(
property_id: int,
data: PropertyStatusUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Activate/deactivate a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
prop.is_active = data.is_active
db.commit()
db.refresh(prop)
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
return PropertyResponse(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=space_count,
managers=_get_property_managers(db, prop.id),
)
@manager_router.delete("/{property_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_property(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> None:
"""Delete a property (only if it has no active bookings)."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
from app.models.booking import Booking
# Check for active bookings (pending or approved) in this property's spaces
space_ids = [s.id for s in db.query(Space).filter(Space.property_id == property_id).all()]
if space_ids:
active_bookings = (
db.query(Booking)
.filter(
Booking.space_id.in_(space_ids),
Booking.status.in_(["pending", "approved"]),
)
.count()
)
if active_bookings > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete property with {active_bookings} active booking(s). Cancel or reject them first.",
)
# Delete related data
db.query(PropertyManager).filter(PropertyManager.property_id == property_id).delete()
db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).delete()
db.query(PropertySettings).filter(PropertySettings.property_id == property_id).delete()
# Unlink spaces (set property_id to None) rather than deleting them
db.query(Space).filter(Space.property_id == property_id).update({"property_id": None})
db.delete(prop)
db.commit()
log_action(
db=db,
action="property_deleted",
user_id=current_user.id,
target_type="property",
target_id=property_id,
details={"name": prop.name},
)
@manager_router.get("/{property_id}/access", response_model=list[PropertyAccessResponse])
def list_property_access(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> list[PropertyAccessResponse]:
"""List access grants for a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
accesses = db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).all()
result = []
for a in accesses:
user_name = None
user_email = None
org_name = None
if a.user_id:
u = db.query(User).filter(User.id == a.user_id).first()
if u:
user_name = u.full_name
user_email = u.email
if a.organization_id:
org = db.query(Organization).filter(Organization.id == a.organization_id).first()
if org:
org_name = org.name
result.append(PropertyAccessResponse(
id=a.id,
property_id=a.property_id,
user_id=a.user_id,
organization_id=a.organization_id,
granted_by=a.granted_by,
user_name=user_name,
user_email=user_email,
organization_name=org_name,
created_at=a.created_at,
))
return result
@manager_router.post("/{property_id}/access", response_model=PropertyAccessResponse, status_code=status.HTTP_201_CREATED)
def grant_property_access(
property_id: int,
data: PropertyAccessCreate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyAccessResponse:
"""Grant access to a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
if not data.user_id and not data.organization_id:
raise HTTPException(status_code=400, detail="Must provide user_id or organization_id")
access = PropertyAccess(
property_id=property_id,
user_id=data.user_id,
organization_id=data.organization_id,
granted_by=current_user.id,
)
db.add(access)
db.commit()
db.refresh(access)
user_name = None
user_email = None
org_name = None
if access.user_id:
u = db.query(User).filter(User.id == access.user_id).first()
if u:
user_name = u.full_name
user_email = u.email
if access.organization_id:
org = db.query(Organization).filter(Organization.id == access.organization_id).first()
if org:
org_name = org.name
return PropertyAccessResponse(
id=access.id,
property_id=access.property_id,
user_id=access.user_id,
organization_id=access.organization_id,
granted_by=access.granted_by,
user_name=user_name,
user_email=user_email,
organization_name=org_name,
created_at=access.created_at,
)
@manager_router.delete("/{property_id}/access/{access_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_property_access(
property_id: int,
access_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> None:
"""Revoke access to a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
access = db.query(PropertyAccess).filter(
PropertyAccess.id == access_id,
PropertyAccess.property_id == property_id,
).first()
if not access:
raise HTTPException(status_code=404, detail="Access grant not found")
db.delete(access)
db.commit()
@manager_router.get("/{property_id}/settings", response_model=PropertySettingsResponse)
def get_property_settings(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertySettingsResponse:
"""Get property settings."""
verify_property_access(db, current_user, property_id, require_manager=True)
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
if not ps:
# Create default settings
ps = PropertySettings(property_id=property_id, require_approval=True)
db.add(ps)
db.commit()
db.refresh(ps)
return PropertySettingsResponse.model_validate(ps)
@manager_router.put("/{property_id}/settings", response_model=PropertySettingsResponse)
def update_property_settings(
property_id: int,
data: PropertySettingsUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertySettingsResponse:
"""Update property settings."""
verify_property_access(db, current_user, property_id, require_manager=True)
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
if not ps:
ps = PropertySettings(property_id=property_id)
db.add(ps)
db.commit()
db.refresh(ps)
for field in data.model_fields:
value = getattr(data, field)
if value is not None or field == "require_approval":
setattr(ps, field, value)
db.commit()
db.refresh(ps)
return PropertySettingsResponse.model_validate(ps)
# === Superadmin endpoints ===
@admin_router.get("", response_model=list[PropertyResponse])
def admin_list_all_properties(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
include_inactive: bool = Query(False),
) -> list[PropertyResponse]:
"""Superadmin: list all properties."""
query = db.query(Property)
if not include_inactive:
query = query.filter(Property.is_active == True) # noqa: E712
properties = query.order_by(Property.name).all()
result = []
for p in properties:
space_count = db.query(Space).filter(Space.property_id == p.id).count()
result.append(PropertyResponse(
id=p.id,
name=p.name,
description=p.description,
address=p.address,
is_public=p.is_public,
is_active=p.is_active,
created_at=p.created_at,
space_count=space_count,
managers=_get_property_managers(db, p.id),
))
return result
@admin_router.post("/{property_id}/managers", status_code=status.HTTP_201_CREATED)
def assign_property_manager(
property_id: int,
user_id: int = Query(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
) -> dict:
"""Superadmin: assign a manager to a property."""
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user_id,
).first()
if existing:
raise HTTPException(status_code=400, detail="User is already a manager of this property")
pm = PropertyManager(property_id=property_id, user_id=user_id)
db.add(pm)
# Ensure user has manager role
if user.role == "user":
user.role = "manager"
db.commit()
return {"message": f"User {user.full_name} assigned as manager of {prop.name}"}
@admin_router.delete("/{property_id}/managers/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_property_manager(
property_id: int,
user_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
) -> None:
"""Superadmin: remove a manager from a property."""
pm = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user_id,
).first()
if not pm:
raise HTTPException(status_code=404, detail="Manager assignment not found")
db.delete(pm)
db.commit()