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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
backend/app/models/organization.py
Normal file
18
backend/app/models/organization.py
Normal 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)
|
||||
17
backend/app/models/organization_member.py
Normal file
17
backend/app/models/organization_member.py
Normal 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"),)
|
||||
20
backend/app/models/property.py
Normal file
20
backend/app/models/property.py
Normal 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)
|
||||
19
backend/app/models/property_access.py
Normal file
19
backend/app/models/property_access.py
Normal 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)
|
||||
16
backend/app/models/property_manager.py
Normal file
16
backend/app/models/property_manager.py
Normal 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"),)
|
||||
20
backend/app/models/property_settings.py
Normal file
20
backend/app/models/property_settings.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user