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

@@ -5,8 +5,10 @@ from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
from app.core.permissions import get_manager_property_ids, verify_property_access
from app.models.booking import Booking
from app.models.property_manager import PropertyManager
from app.models.settings import Settings
from app.models.space import Space
from app.models.user import User
@@ -39,6 +41,40 @@ from app.services.booking_service import validate_booking_rules
from app.utils.timezone import convert_to_utc
router = APIRouter(prefix="/spaces", tags=["bookings"])
def _verify_manager_booking_access(db: Session, booking: Booking, current_user: User) -> None:
"""Verify that a manager has access to the booking's property.
Superadmins always have access. Managers can only act on bookings
for spaces within their managed properties.
"""
if current_user.role in ("superadmin", "admin"):
return
if current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
space = booking.space
if space and space.property_id and space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this property's bookings",
)
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
"""Verify that a manager has access to a space's property.
Used for creating bookings where we have the space but no booking yet.
"""
if current_user.role in ("superadmin", "admin"):
return
if current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
if space.property_id and space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this property's spaces",
)
bookings_router = APIRouter(prefix="/bookings", tags=["bookings"])
@@ -68,6 +104,10 @@ def get_space_bookings(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Query bookings in the time range (only active bookings)
query = db.query(Booking).filter(
Booking.space_id == space_id,
@@ -79,7 +119,7 @@ def get_space_bookings(
bookings = query.order_by(Booking.start_datetime).all()
# Return different schemas based on user role
if current_user.role == "admin":
if current_user.role in ("admin", "superadmin", "manager"):
return [BookingCalendarAdmin.model_validate(b) for b in bookings]
else:
return [BookingCalendarPublic.model_validate(b) for b in bookings]
@@ -116,6 +156,10 @@ def check_availability(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Find conflicting bookings (approved + pending)
conflicts = (
db.query(Booking)
@@ -271,6 +315,10 @@ def create_booking(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Convert input times from user timezone to UTC
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone)
@@ -293,8 +341,8 @@ def create_booking(
detail=errors[0], # Return first error
)
# Auto-approve if admin, otherwise pending
is_admin = current_user.role == "admin"
# Auto-approve if admin/superadmin, otherwise pending
is_admin = current_user.role in ("admin", "superadmin")
# Create booking (with UTC times)
booking = Booking(
@@ -314,23 +362,39 @@ def create_booking(
db.refresh(booking)
if not is_admin:
# Notify all admins about the new booking request
admins = db.query(User).filter(User.role == "admin").all()
# Notify admins and property managers
notify_users = {}
# Get superadmins/admins
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
for admin in admins:
notify_users[admin.id] = admin
# Get property managers for the space's property
if space.property_id:
manager_ids = [
pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
]
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
for mgr in managers:
notify_users[mgr.id] = mgr
for user in notify_users.values():
create_notification(
db=db,
user_id=admin.id, # type: ignore[arg-type]
user_id=user.id, # type: ignore[arg-type]
type="booking_created",
title="Noua Cerere de Rezervare",
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
# Send email notification to admin
# Send email notification
background_tasks.add_task(
send_booking_notification,
booking,
"created",
admin.email,
user.email,
current_user.full_name,
None,
)
@@ -375,6 +439,10 @@ def create_recurring_booking(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Parse time
try:
hour, minute = map(int, data.start_time.split(':'))
@@ -458,14 +526,25 @@ def create_recurring_booking(
for booking in created_bookings:
db.refresh(booking)
# Send notifications to admins (in background)
# Send notifications to admins and property managers (in background)
if created_bookings:
admins = db.query(User).filter(User.role == "admin").all()
notify_users = {}
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
for admin in admins:
notify_users[admin.id] = admin
if space.property_id:
manager_ids = [
pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
]
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
for mgr in managers:
notify_users[mgr.id] = mgr
for user in notify_users.values():
background_tasks.add_task(
create_notification,
db=db,
user_id=admin.id, # type: ignore[arg-type]
user_id=user.id, # type: ignore[arg-type]
type="booking_created",
title="Noi Cereri de Rezervare Recurente",
message=f"Utilizatorul {current_user.full_name} a creat {len(created_bookings)} rezervări recurente.",
@@ -648,13 +727,14 @@ def get_all_bookings(
status_filter: Annotated[str | None, Query(alias="status")] = None,
space_id: Annotated[int | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
property_id: Annotated[int | None, Query()] = None,
start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all bookings across all users (admin only).
Get all bookings across all users (admin/manager).
Returns bookings with user and space details.
@@ -662,15 +742,24 @@ def get_all_bookings(
- **status** (optional): Filter by status (pending/approved/rejected/canceled)
- **space_id** (optional): Filter by space ID
- **user_id** (optional): Filter by user ID
- **property_id** (optional): Filter by property ID
- **start** (optional): Only bookings starting from this datetime
- **limit** (optional): Max results (1-100, default 20)
"""
query = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.join(User, Booking.user_id == User.id)
.outerjoin(User, Booking.user_id == User.id)
)
# 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))
if property_id is not None:
query = query.filter(Space.property_id == property_id)
if status_filter is not None:
query = query.filter(Booking.status == status_filter)
@@ -681,7 +770,6 @@ def get_all_bookings(
query = query.filter(Booking.user_id == user_id)
if start is not None:
# Use end_datetime to include bookings still in progress (started but not ended)
query = query.filter(Booking.end_datetime > start)
bookings = (
@@ -697,26 +785,36 @@ def get_all_bookings(
def get_pending_bookings(
space_id: Annotated[int | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
property_id: Annotated[int | None, Query()] = None,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all pending booking requests (admin only).
Get all pending booking requests (admin/manager).
Returns pending bookings with user and space details, sorted by creation time (FIFO).
Query parameters:
- **space_id** (optional): Filter by space ID
- **user_id** (optional): Filter by user ID
- **property_id** (optional): Filter by property ID
"""
# Base query: pending bookings with joins
# Base query: pending bookings with joins (outerjoin for anonymous bookings)
query = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.join(User, Booking.user_id == User.id)
.outerjoin(User, Booking.user_id == User.id)
.filter(Booking.status == "pending")
)
# 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))
if property_id is not None:
query = query.filter(Space.property_id == property_id)
# Apply filters if provided
if space_id is not None:
query = query.filter(Booking.space_id == space_id)
@@ -735,7 +833,7 @@ def approve_booking(
id: int,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Approve a pending booking request (admin only).
@@ -755,6 +853,9 @@ def approve_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking is pending
if booking.status != "pending":
raise HTTPException(
@@ -764,11 +865,12 @@ def approve_booking(
# Re-validate booking rules to prevent race conditions
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
start_datetime=booking.start_datetime, # type: ignore[arg-type]
end_datetime=booking.end_datetime, # type: ignore[arg-type]
exclude_booking_id=int(booking.id), # type: ignore[arg-type]
@@ -790,13 +892,14 @@ def approve_booking(
db.refresh(booking)
# Create Google Calendar event if user has connected their calendar
google_event_id = create_calendar_event(
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
)
if google_event_id:
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
db.commit()
db.refresh(booking)
if booking.user_id:
google_event_id = create_calendar_event(
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
)
if google_event_id:
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
db.commit()
db.refresh(booking)
# Log the action
log_action(
@@ -809,24 +912,35 @@ def approve_booking(
)
# Notify the user about approval
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_approved",
title="Rezervare Aprobată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_approved",
title="Rezervare Aprobată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"approved",
booking.user.email,
booking.user.full_name,
None,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"approved",
booking.user.email,
booking.user.full_name,
None,
)
elif booking.guest_email:
# Send email notification to anonymous guest
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_approved",
booking.guest_email,
booking.guest_name or "Guest",
None,
)
return booking
@@ -837,7 +951,7 @@ def reject_booking(
reject_data: RejectRequest,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Reject a pending booking request (admin only).
@@ -857,6 +971,9 @@ def reject_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking is pending
if booking.status != "pending":
raise HTTPException(
@@ -882,24 +999,34 @@ def reject_booking(
)
# Notify the user about rejection
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rejected",
title="Rezervare Respinsă",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rejected",
title="Rezervare Respinsă",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"rejected",
booking.user.email,
booking.user.full_name,
{"rejection_reason": reject_data.reason},
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"rejected",
booking.user.email,
booking.user.full_name,
{"rejection_reason": reject_data.reason},
)
elif booking.guest_email:
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_rejected",
booking.guest_email,
booking.guest_name or "Guest",
{"rejection_reason": reject_data.reason},
)
return booking
@@ -909,10 +1036,10 @@ def admin_update_booking(
id: int,
data: BookingUpdate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Update any booking (admin only).
Update any booking (admin/manager).
Admin can edit any booking (pending or approved), but cannot edit bookings
that have already started.
@@ -928,6 +1055,9 @@ def admin_update_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking already started (cannot edit past bookings)
if booking.start_datetime < datetime.utcnow() and booking.status == "approved": # type: ignore[operator]
raise HTTPException(
@@ -947,13 +1077,14 @@ def admin_update_booking(
# Re-validate booking rules
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
start_datetime=booking.start_datetime, # type: ignore[arg-type]
end_datetime=booking.end_datetime, # type: ignore[arg-type]
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
exclude_booking_id=booking.id, # Exclude self from overlap check
user_timezone=user_timezone,
)
@@ -965,7 +1096,7 @@ def admin_update_booking(
)
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
@@ -994,10 +1125,10 @@ def admin_cancel_booking(
cancel_data: AdminCancelRequest,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Cancel any booking (admin only).
Cancel any booking (admin/manager).
Admin can cancel any booking at any time, regardless of status or timing.
No time restrictions apply (unlike user cancellations).
@@ -1015,13 +1146,16 @@ def admin_cancel_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Admin can cancel any booking (no status check needed)
# Update booking status
booking.status = "canceled" # type: ignore[assignment]
booking.cancellation_reason = cancel_data.cancellation_reason # type: ignore[assignment]
# Delete from Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
delete_calendar_event(
db=db,
event_id=booking.google_calendar_event_id,
@@ -1043,24 +1177,25 @@ def admin_cancel_booking(
)
# Notify the user about cancellation
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_canceled",
title="Rezervare Anulată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_canceled",
title="Rezervare Anulată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"canceled",
booking.user.email,
booking.user.full_name,
{"cancellation_reason": cancel_data.cancellation_reason},
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"canceled",
booking.user.email,
booking.user.full_name,
{"cancellation_reason": cancel_data.cancellation_reason},
)
return booking
@@ -1070,10 +1205,10 @@ def reschedule_booking(
id: int,
data: BookingReschedule,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Reschedule booking to new time slot (admin only, drag-and-drop).
Reschedule booking to new time slot (admin/manager, drag-and-drop).
Validates the new time slot and updates the booking times.
Only approved bookings that haven't started yet can be rescheduled.
@@ -1091,6 +1226,9 @@ def reschedule_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking already started (cannot reschedule past bookings)
if booking.start_datetime < datetime.utcnow(): # type: ignore[operator]
raise HTTPException(
@@ -1104,13 +1242,14 @@ def reschedule_booking(
# Validate new time slot
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
start_datetime=data.start_datetime,
end_datetime=data.end_datetime,
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
exclude_booking_id=booking.id, # Exclude self from overlap check
user_timezone=user_timezone,
)
@@ -1126,7 +1265,7 @@ def reschedule_booking(
booking.end_datetime = data.end_datetime # type: ignore[assignment]
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
@@ -1150,14 +1289,15 @@ def reschedule_booking(
)
# Notify user about reschedule
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rescheduled",
title="Rezervare Reprogramată",
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
if booking.user_id:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rescheduled",
title="Rezervare Reprogramată",
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
db.commit()
db.refresh(booking)
@@ -1169,10 +1309,10 @@ def reschedule_booking(
def admin_create_booking(
booking_data: BookingAdminCreate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Create a booking directly with approved status (admin only, bypass approval workflow).
Create a booking directly with approved status (admin/manager, bypass approval workflow).
- **space_id**: ID of the space to book
- **user_id**: Optional user ID (defaults to current admin if not provided)
@@ -1196,6 +1336,9 @@ def admin_create_booking(
detail="Space not found",
)
# Verify manager has access to this space's property
_verify_manager_space_access(db, space, current_admin)
# Use current admin ID if user_id not provided
target_user_id = booking_data.user_id if booking_data.user_id is not None else int(current_admin.id) # type: ignore[arg-type]