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,6 +5,7 @@ from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.property_settings import PropertySettings
from app.models.settings import Settings
from app.models.space import Space
from app.utils.timezone import convert_from_utc, convert_to_utc
@@ -53,27 +54,43 @@ def validate_booking_rules(
db.commit()
db.refresh(settings)
# Fetch space and get per-space settings (with fallback to global)
# Fetch space and get per-space settings
# Resolution chain: Space → PropertySettings → Global Settings
space = db.query(Space).filter(Space.id == space_id).first()
wh_start = (
space.working_hours_start
if space and space.working_hours_start is not None
else settings.working_hours_start
# Fetch property settings if space has a property
prop_settings = None
if space and space.property_id:
prop_settings = db.query(PropertySettings).filter(
PropertySettings.property_id == space.property_id
).first()
def resolve(space_val, prop_val, global_val):
if space_val is not None:
return space_val
if prop_val is not None:
return prop_val
return global_val
wh_start = resolve(
space.working_hours_start if space else None,
prop_settings.working_hours_start if prop_settings else None,
settings.working_hours_start,
)
wh_end = (
space.working_hours_end
if space and space.working_hours_end is not None
else settings.working_hours_end
wh_end = resolve(
space.working_hours_end if space else None,
prop_settings.working_hours_end if prop_settings else None,
settings.working_hours_end,
)
min_dur = (
space.min_duration_minutes
if space and space.min_duration_minutes is not None
else settings.min_duration_minutes
min_dur = resolve(
space.min_duration_minutes if space else None,
prop_settings.min_duration_minutes if prop_settings else None,
settings.min_duration_minutes,
)
max_dur = (
space.max_duration_minutes
if space and space.max_duration_minutes is not None
else settings.max_duration_minutes
max_dur = resolve(
space.max_duration_minutes if space else None,
prop_settings.max_duration_minutes if prop_settings else None,
settings.max_duration_minutes,
)
# Convert UTC times to user timezone for validation

View File

@@ -128,6 +128,58 @@ Motiv: {reason}
Vă rugăm să contactați administratorul pentru detalii.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_created":
guest_email = extra_data.get("guest_email", "N/A") if extra_data else "N/A"
subject = "Cerere Anonimă de Rezervare"
body = f"""Bună ziua,
O nouă cerere anonimă de rezervare necesită aprobarea dumneavoastră:
Persoana: {user_name}
Email: {guest_email}
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Descriere: {booking.description or 'N/A'}
Vă rugăm să accesați panoul de administrare pentru a aproba sau respinge această cerere.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_approved":
subject = "Rezervare Aprobată"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost aprobată:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Vă așteptăm!
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_rejected":
reason = extra_data.get("rejection_reason", "Nu a fost specificat") if extra_data else "Nu a fost specificat"
subject = "Rezervare Respinsă"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost respinsă:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Motiv: {reason}
Cu stimă,
Sistemul de Rezervări
"""