Files
space-booking/backend/app/schemas/booking.py
Claude Agent e21cf03a16 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>
2026-02-15 00:17:21 +00:00

281 lines
7.8 KiB
Python

"""Booking schemas for request/response."""
from datetime import datetime, date
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class BookingCalendarPublic(BaseModel):
"""Public booking data for regular users (calendar view)."""
id: int
start_datetime: datetime
end_datetime: datetime
status: str
title: str
model_config = {"from_attributes": True}
class BookingCalendarAdmin(BaseModel):
"""Full booking data for admins (calendar view)."""
id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
rejection_reason: str | None
cancellation_reason: str | None
approved_by: int | None
created_at: datetime
model_config = {"from_attributes": True}
class BookingCreate(BaseModel):
"""Schema for creating a new booking."""
space_id: int
start_datetime: datetime
end_datetime: datetime
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
class BookingResponse(BaseModel):
"""Schema for booking response after creation."""
id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
guest_name: str | None = None
guest_email: str | None = None
guest_organization: str | None = None
is_anonymous: bool = False
# Timezone-aware formatted strings (optional, set by endpoint)
start_datetime_tz: Optional[str] = None
end_datetime_tz: Optional[str] = None
model_config = {"from_attributes": True}
@classmethod
def from_booking_with_timezone(cls, booking, user_timezone: str = "UTC"):
"""Create response with timezone conversion."""
from app.utils.timezone import format_datetime_tz
return cls(
id=booking.id,
user_id=booking.user_id,
space_id=booking.space_id,
start_datetime=booking.start_datetime,
end_datetime=booking.end_datetime,
status=booking.status,
title=booking.title,
description=booking.description,
created_at=booking.created_at,
guest_name=booking.guest_name,
guest_email=booking.guest_email,
guest_organization=booking.guest_organization,
is_anonymous=booking.is_anonymous,
start_datetime_tz=format_datetime_tz(booking.start_datetime, user_timezone),
end_datetime_tz=format_datetime_tz(booking.end_datetime, user_timezone)
)
class SpaceInBooking(BaseModel):
"""Space details embedded in booking response."""
id: int
name: str
type: str
property_id: int | None = None
property_name: str | None = None
model_config = {"from_attributes": True}
@model_validator(mode="wrap")
@classmethod
def extract_property_name(cls, data: Any, handler: Any) -> "SpaceInBooking":
"""Extract property_name from ORM relationship."""
instance = handler(data)
if instance.property_name is None and hasattr(data, 'property') and data.property:
instance.property_name = data.property.name
return instance
class BookingWithSpace(BaseModel):
"""Booking with associated space details for user's booking list."""
id: int
space_id: int
space: SpaceInBooking
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
model_config = {"from_attributes": True}
class UserInBooking(BaseModel):
"""User details embedded in booking response."""
id: int
full_name: str
email: str
organization: str | None
model_config = {"from_attributes": True}
class BookingPendingDetail(BaseModel):
"""Detailed booking information for admin pending list."""
id: int
space_id: int
space: SpaceInBooking
user_id: int | None = None
user: UserInBooking | None = None
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
guest_name: str | None = None
guest_email: str | None = None
guest_organization: str | None = None
is_anonymous: bool = False
model_config = {"from_attributes": True}
class RejectRequest(BaseModel):
"""Schema for rejecting a booking."""
reason: str | None = None
class BookingAdminCreate(BaseModel):
"""Schema for admin to create a booking directly (bypass approval)."""
space_id: int
user_id: int | None = None
start_datetime: datetime
end_datetime: datetime
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
class AdminCancelRequest(BaseModel):
"""Schema for admin cancelling a booking."""
cancellation_reason: str | None = None
class BookingUpdate(BaseModel):
"""Schema for updating a booking."""
title: str | None = None
description: str | None = None
start_datetime: datetime | None = None
end_datetime: datetime | None = None
class ConflictingBooking(BaseModel):
"""Schema for a conflicting booking in availability check."""
id: int
user_name: str
title: str
status: str
start_datetime: datetime
end_datetime: datetime
model_config = {"from_attributes": True}
class AvailabilityCheck(BaseModel):
"""Schema for availability check response."""
available: bool
conflicts: list[ConflictingBooking]
message: str
class BookingRecurringCreate(BaseModel):
"""Schema for creating recurring weekly bookings."""
space_id: int
start_time: str # Time only (e.g., "10:00")
duration_minutes: int
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
recurrence_days: list[int] = Field(..., min_length=1, max_length=7) # 0=Monday, 6=Sunday
start_date: date # First occurrence date
end_date: date # Last occurrence date
skip_conflicts: bool = True # Skip conflicted dates or stop
@field_validator('recurrence_days')
@classmethod
def validate_days(cls, v: list[int]) -> list[int]:
"""Validate recurrence days are valid weekdays."""
if not all(0 <= day <= 6 for day in v):
raise ValueError('Days must be 0-6 (0=Monday, 6=Sunday)')
return sorted(list(set(v))) # Remove duplicates and sort
@field_validator('end_date')
@classmethod
def validate_date_range(cls, v: date, info) -> date:
"""Validate end date is after start date and within 1 year."""
if 'start_date' in info.data and v < info.data['start_date']:
raise ValueError('end_date must be after start_date')
# Max 1 year
if 'start_date' in info.data and (v - info.data['start_date']).days > 365:
raise ValueError('Recurrence period cannot exceed 1 year')
return v
class RecurringBookingResult(BaseModel):
"""Schema for recurring booking creation result."""
total_requested: int
total_created: int
total_skipped: int
created_bookings: list[BookingResponse]
skipped_dates: list[dict] # [{"date": "2024-01-01", "reason": "..."}, ...]
class BookingReschedule(BaseModel):
"""Schema for rescheduling a booking (drag-and-drop)."""
start_datetime: datetime
end_datetime: datetime
class AnonymousBookingCreate(BaseModel):
"""Schema for anonymous/guest booking creation."""
space_id: int
start_datetime: datetime
end_datetime: datetime
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
guest_name: str = Field(..., min_length=1)
guest_email: str = Field(..., min_length=1)
guest_organization: str | None = None