- Add timezone configuration per space with fallback to system default - Implement timezone-aware datetime display and editing across frontend - Add migration for per_space_settings table - Update booking service to handle timezone conversions properly - Improve .gitignore to exclude build artifacts - Add comprehensive testing documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
140 lines
4.6 KiB
Python
140 lines
4.6 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.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 (with fallback to global)
|
|
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
|
|
)
|
|
wh_end = (
|
|
space.working_hours_end
|
|
if space and space.working_hours_end is not None
|
|
else 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
|
|
)
|
|
max_dur = (
|
|
space.max_duration_minutes
|
|
if space and space.max_duration_minutes is not None
|
|
else 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
|
|
query = db.query(Booking).filter(
|
|
Booking.space_id == space_id,
|
|
Booking.status.in_(["approved", "pending"]),
|
|
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
|