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,19 @@ from app.models.booking import Booking
from app.models.booking_template import BookingTemplate
from app.models.google_calendar_token import GoogleCalendarToken
from app.models.notification import Notification
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.property import Property
from app.models.property_access import PropertyAccess
from app.models.property_manager import PropertyManager
from app.models.property_settings import PropertySettings
from app.models.settings import Settings
from app.models.space import Space
from app.models.user import User
__all__ = ["User", "Space", "Settings", "Booking", "BookingTemplate", "Notification", "AuditLog", "Attachment", "GoogleCalendarToken"]
__all__ = [
"User", "Space", "Settings", "Booking", "BookingTemplate",
"Notification", "AuditLog", "Attachment", "GoogleCalendarToken",
"Property", "PropertyManager", "PropertyAccess", "PropertySettings",
"Organization", "OrganizationMember",
]

View File

@@ -1,7 +1,7 @@
"""Booking model."""
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.session import Base
@@ -13,8 +13,12 @@ class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
space_id = Column(Integer, ForeignKey("spaces.id"), nullable=False, index=True)
guest_name = Column(String, nullable=True)
guest_email = Column(String, nullable=True)
guest_organization = Column(String, nullable=True)
is_anonymous = Column(Boolean, default=False, nullable=False)
title = Column(String, nullable=False)
description = Column(String, nullable=True)
start_datetime = Column(DateTime, nullable=False, index=True)

View File

@@ -0,0 +1,18 @@
"""Organization model."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from app.db.session import Base
class Organization(Base):
"""Organization model for grouping users."""
__tablename__ = "organizations"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True, index=True)
description = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,17 @@
"""OrganizationMember junction model."""
from sqlalchemy import Column, ForeignKey, Integer, String, UniqueConstraint
from app.db.session import Base
class OrganizationMember(Base):
"""Junction table linking organizations to their members."""
__tablename__ = "organization_members"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
role = Column(String, nullable=False, default="member") # "admin" or "member"
__table_args__ = (UniqueConstraint("organization_id", "user_id", name="uq_org_member"),)

View File

@@ -0,0 +1,20 @@
"""Property model."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from app.db.session import Base
class Property(Base):
"""Property model for multi-tenant property management."""
__tablename__ = "properties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
description = Column(String, nullable=True)
address = Column(String, nullable=True)
is_public = Column(Boolean, default=True, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,19 @@
"""PropertyAccess model."""
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer
from app.db.session import Base
class PropertyAccess(Base):
"""Tracks which users/organizations have access to private properties."""
__tablename__ = "property_access"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True, index=True)
granted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,16 @@
"""PropertyManager junction model."""
from sqlalchemy import Column, ForeignKey, Integer, UniqueConstraint
from app.db.session import Base
class PropertyManager(Base):
"""Junction table linking properties to their managers."""
__tablename__ = "property_managers"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
__table_args__ = (UniqueConstraint("property_id", "user_id", name="uq_property_manager"),)

View File

@@ -0,0 +1,20 @@
"""PropertySettings model."""
from sqlalchemy import Boolean, Column, ForeignKey, Integer
from app.db.session import Base
class PropertySettings(Base):
"""Per-property scheduling settings."""
__tablename__ = "property_settings"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, unique=True, index=True)
working_hours_start = Column(Integer, nullable=True)
working_hours_end = Column(Integer, nullable=True)
min_duration_minutes = Column(Integer, nullable=True)
max_duration_minutes = Column(Integer, nullable=True)
max_bookings_per_day_per_user = Column(Integer, nullable=True)
require_approval = Column(Boolean, default=True, nullable=False)
min_hours_before_cancel = Column(Integer, nullable=True)

View File

@@ -1,5 +1,6 @@
"""Space model."""
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.session import Base
@@ -15,9 +16,12 @@ class Space(Base):
capacity = Column(Integer, nullable=False)
description = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=True, index=True)
# Per-space scheduling settings (NULL = use global default)
working_hours_start = Column(Integer, nullable=True)
working_hours_end = Column(Integer, nullable=True)
min_duration_minutes = Column(Integer, nullable=True)
max_duration_minutes = Column(Integer, nullable=True)
property = relationship("Property", backref="spaces")

View File

@@ -14,7 +14,7 @@ class User(Base):
email = Column(String, unique=True, index=True, nullable=False)
full_name = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # "admin" or "user"
role = Column(String, nullable=False, default="user") # "superadmin"/"manager"/"user"
organization = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
timezone = Column(String(50), default="UTC", nullable=False) # IANA timezone
@@ -26,3 +26,5 @@ class User(Base):
google_calendar_token = relationship(
"GoogleCalendarToken", back_populates="user", uselist=False
)
managed_properties = relationship("PropertyManager", backref="user", cascade="all, delete-orphan")
organization_memberships = relationship("OrganizationMember", backref="user", cascade="all, delete-orphan")