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

@@ -7,7 +7,8 @@ from sqlalchemy import and_, case, func
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_current_admin, get_db
from app.core.deps import get_current_manager_or_superadmin, get_db
from app.core.permissions import get_manager_property_ids
from app.models.booking import Booking
from app.models.space import Space
from app.models.user import User
@@ -41,8 +42,9 @@ def get_usage_report(
start_date: date | None = Query(None),
end_date: date | None = Query(None),
space_id: int | None = Query(None),
property_id: int | None = Query(None),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> SpaceUsageReport:
"""Get booking usage report by space."""
query = (
@@ -81,6 +83,13 @@ def get_usage_report(
)
if space_id:
filters.append(Booking.space_id == space_id)
if property_id:
filters.append(Space.property_id == property_id)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
filters.append(Space.property_id.in_(managed_ids))
if filters:
query = query.filter(and_(*filters))
@@ -114,7 +123,7 @@ def get_top_users_report(
end_date: date | None = Query(None),
limit: int = Query(10, ge=1, le=100),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> TopUsersReport:
"""Get top users by booking count."""
query = (
@@ -129,6 +138,7 @@ def get_top_users_report(
func.sum(calculate_hours_expr()).label("total_hours"),
)
.join(User, Booking.user_id == User.id)
.join(Space, Booking.space_id == Space.id)
.group_by(Booking.user_id, User.full_name, User.email)
)
@@ -143,6 +153,11 @@ def get_top_users_report(
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
# Order by total bookings desc
query = query.order_by(func.count(Booking.id).desc()).limit(limit)
@@ -171,7 +186,7 @@ def get_approval_rate_report(
start_date: date | None = Query(None),
end_date: date | None = Query(None),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> ApprovalRateReport:
"""Get approval/rejection rate report."""
query = db.query(
@@ -180,7 +195,7 @@ def get_approval_rate_report(
func.sum(case((Booking.status == "rejected", 1), else_=0)).label("rejected"),
func.sum(case((Booking.status == "pending", 1), else_=0)).label("pending"),
func.sum(case((Booking.status == "canceled", 1), else_=0)).label("canceled"),
)
).join(Space, Booking.space_id == Space.id)
# Apply date filters
if start_date:
@@ -193,6 +208,11 @@ def get_approval_rate_report(
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
result = query.first()
total = result.total or 0