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

@@ -1,8 +1,8 @@
"""Booking schemas for request/response."""
from datetime import datetime, date
from typing import Optional
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
class BookingCalendarPublic(BaseModel):
@@ -21,7 +21,7 @@ class BookingCalendarAdmin(BaseModel):
"""Full booking data for admins (calendar view)."""
id: int
user_id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
@@ -50,7 +50,7 @@ class BookingResponse(BaseModel):
"""Schema for booking response after creation."""
id: int
user_id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
@@ -58,6 +58,10 @@ class BookingResponse(BaseModel):
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
@@ -79,6 +83,10 @@ class BookingResponse(BaseModel):
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)
)
@@ -90,9 +98,20 @@ class SpaceInBooking(BaseModel):
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."""
@@ -127,14 +146,18 @@ class BookingPendingDetail(BaseModel):
id: int
space_id: int
space: SpaceInBooking
user_id: int
user: UserInBooking
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}
@@ -242,3 +265,16 @@ class BookingReschedule(BaseModel):
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

View File

@@ -0,0 +1,41 @@
"""Organization schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class OrganizationCreate(BaseModel):
name: str = Field(..., min_length=1)
description: str | None = None
class OrganizationUpdate(BaseModel):
name: str | None = None
description: str | None = None
class OrganizationResponse(BaseModel):
id: int
name: str
description: str | None = None
is_active: bool
created_at: datetime
member_count: int = 0
model_config = {"from_attributes": True}
class OrganizationMemberResponse(BaseModel):
id: int
organization_id: int
user_id: int
role: str
user_name: str | None = None
user_email: str | None = None
model_config = {"from_attributes": True}
class AddMemberRequest(BaseModel):
user_id: int
role: str = "member"

View File

@@ -0,0 +1,82 @@
"""Property schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class PropertyCreate(BaseModel):
name: str = Field(..., min_length=1)
description: str | None = None
address: str | None = None
is_public: bool = True
class PropertyUpdate(BaseModel):
name: str | None = None
description: str | None = None
address: str | None = None
is_public: bool | None = None
class PropertyManagerInfo(BaseModel):
user_id: int
full_name: str
email: str
class PropertyResponse(BaseModel):
id: int
name: str
description: str | None = None
address: str | None = None
is_public: bool
is_active: bool
created_at: datetime
space_count: int = 0
managers: list[PropertyManagerInfo] = []
model_config = {"from_attributes": True}
class PropertyWithSpaces(PropertyResponse):
spaces: list = []
class PropertyAccessCreate(BaseModel):
user_id: int | None = None
organization_id: int | None = None
class PropertyAccessResponse(BaseModel):
id: int
property_id: int
user_id: int | None = None
organization_id: int | None = None
granted_by: int | None = None
user_name: str | None = None
user_email: str | None = None
organization_name: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class PropertySettingsUpdate(BaseModel):
working_hours_start: int | None = None
working_hours_end: int | None = None
min_duration_minutes: int | None = None
max_duration_minutes: int | None = None
max_bookings_per_day_per_user: int | None = None
require_approval: bool = True
min_hours_before_cancel: int | None = None
class PropertySettingsResponse(PropertySettingsUpdate):
id: int
property_id: int
model_config = {"from_attributes": True}
class PropertyStatusUpdate(BaseModel):
is_active: bool

View File

@@ -20,7 +20,7 @@ class SpaceBase(BaseModel):
class SpaceCreate(SpaceBase):
"""Space creation schema."""
pass
property_id: int | None = None
class SpaceUpdate(SpaceBase):
@@ -40,6 +40,8 @@ class SpaceResponse(SpaceBase):
id: int
is_active: bool
property_id: int | None = None
property_name: str | None = None
working_hours_start: int | None = None
working_hours_end: int | None = None
min_duration_minutes: int | None = None