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:
575
backend/app/api/properties.py
Normal file
575
backend/app/api/properties.py
Normal 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()
|
||||
Reference in New Issue
Block a user