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:
@@ -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
|
||||
|
||||
41
backend/app/schemas/organization.py
Normal file
41
backend/app/schemas/organization.py
Normal 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"
|
||||
82
backend/app/schemas/property.py
Normal file
82
backend/app/schemas/property.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user