Files
space-booking/backend/app/services/booking_service.py
Claude Agent e21cf03a16 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>
2026-02-15 00:17:21 +00:00

158 lines
5.4 KiB
Python

"""Booking validation service."""
from datetime import datetime
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
def validate_booking_rules(
db: Session,
space_id: int,
user_id: int,
start_datetime: datetime,
end_datetime: datetime,
exclude_booking_id: int | None = None,
user_timezone: str = "UTC",
) -> list[str]:
"""
Validate booking against global and per-space settings rules.
Args:
db: Database session
space_id: ID of the space to book
user_id: ID of the user making the booking
start_datetime: Booking start time (UTC)
end_datetime: Booking end time (UTC)
exclude_booking_id: Optional booking ID to exclude from overlap check
(used when re-validating an existing booking)
user_timezone: User's IANA timezone (e.g., "Europe/Bucharest")
Returns:
List of validation error messages (empty list = validation OK)
"""
errors = []
# Fetch global settings (create default if not exists)
settings = db.query(Settings).filter(Settings.id == 1).first()
if not settings:
settings = Settings(
id=1,
min_duration_minutes=30,
max_duration_minutes=480,
working_hours_start=8,
working_hours_end=20,
max_bookings_per_day_per_user=3,
min_hours_before_cancel=2,
)
db.add(settings)
db.commit()
db.refresh(settings)
# Fetch space and get per-space settings
# Resolution chain: Space → PropertySettings → Global Settings
space = db.query(Space).filter(Space.id == space_id).first()
# 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 = 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 = 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 = 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
local_start = convert_from_utc(start_datetime, user_timezone)
local_end = convert_from_utc(end_datetime, user_timezone)
# a) Validate duration in range
duration_minutes = (end_datetime - start_datetime).total_seconds() / 60
if duration_minutes < min_dur or duration_minutes > max_dur:
errors.append(
f"Durata rezervării trebuie să fie între {min_dur} și {max_dur} minute"
)
# b) Validate working hours (in user's local time)
if local_start.hour < wh_start or local_end.hour > wh_end:
errors.append(
f"Rezervările sunt permise doar între {wh_start}:00 și {wh_end}:00"
)
# c) Check for overlapping bookings (only approved block new bookings;
# admin re-validates at approval time to catch conflicts)
query = db.query(Booking).filter(
Booking.space_id == space_id,
Booking.status == "approved",
and_(
Booking.start_datetime < end_datetime,
Booking.end_datetime > start_datetime,
),
)
# Exclude current booking if re-validating
if exclude_booking_id is not None:
query = query.filter(Booking.id != exclude_booking_id)
overlapping_bookings = query.first()
if overlapping_bookings:
errors.append("Spațiul este deja rezervat în acest interval")
# d) Check max bookings per day per user (using local date)
booking_date_local = local_start.date()
local_start_of_day = datetime.combine(booking_date_local, datetime.min.time())
local_end_of_day = datetime.combine(booking_date_local, datetime.max.time())
# Convert local day boundaries to UTC
start_of_day_utc = convert_to_utc(local_start_of_day, user_timezone)
end_of_day_utc = convert_to_utc(local_end_of_day, user_timezone)
user_bookings_count = (
db.query(Booking)
.filter(
Booking.user_id == user_id,
Booking.status.in_(["approved", "pending"]),
Booking.start_datetime >= start_of_day_utc,
Booking.start_datetime <= end_of_day_utc,
)
.count()
)
if user_bookings_count >= settings.max_bookings_per_day_per_user:
errors.append(
f"Ai atins limita de {settings.max_bookings_per_day_per_user} rezervări pe zi"
)
return errors