fix(bookings): add UTC conversion to admin and reschedule endpoints
admin_create_booking, admin_update_booking, and reschedule_booking stored naive datetimes without converting to UTC, causing +2h offset on calendar after the frontend started treating all DB times as UTC. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1066,14 +1066,16 @@ def admin_update_booking(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update fields (only if provided)
|
# Update fields (only if provided)
|
||||||
|
# Convert datetimes from admin timezone to UTC for storage
|
||||||
|
user_timezone = current_admin.timezone or "UTC" # type: ignore[attr-defined]
|
||||||
if data.title is not None:
|
if data.title is not None:
|
||||||
booking.title = data.title # type: ignore[assignment]
|
booking.title = data.title # type: ignore[assignment]
|
||||||
if data.description is not None:
|
if data.description is not None:
|
||||||
booking.description = data.description # type: ignore[assignment]
|
booking.description = data.description # type: ignore[assignment]
|
||||||
if data.start_datetime is not None:
|
if data.start_datetime is not None:
|
||||||
booking.start_datetime = data.start_datetime # type: ignore[assignment]
|
booking.start_datetime = convert_to_utc(data.start_datetime, user_timezone) # type: ignore[assignment]
|
||||||
if data.end_datetime is not None:
|
if data.end_datetime is not None:
|
||||||
booking.end_datetime = data.end_datetime # type: ignore[assignment]
|
booking.end_datetime = convert_to_utc(data.end_datetime, user_timezone) # type: ignore[assignment]
|
||||||
|
|
||||||
# Re-validate booking rules
|
# Re-validate booking rules
|
||||||
# Use booking owner's timezone for validation
|
# Use booking owner's timezone for validation
|
||||||
@@ -1240,15 +1242,18 @@ def reschedule_booking(
|
|||||||
old_start = booking.start_datetime
|
old_start = booking.start_datetime
|
||||||
old_end = booking.end_datetime
|
old_end = booking.end_datetime
|
||||||
|
|
||||||
# Validate new time slot
|
# Convert input times from admin timezone to UTC
|
||||||
# Use booking owner's timezone for validation
|
user_timezone = current_admin.timezone or "UTC" # type: ignore[attr-defined]
|
||||||
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
|
start_datetime_utc = convert_to_utc(data.start_datetime, user_timezone)
|
||||||
|
end_datetime_utc = convert_to_utc(data.end_datetime, user_timezone)
|
||||||
|
|
||||||
|
# Validate new time slot (using UTC times)
|
||||||
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
||||||
errors = validate_booking_rules(
|
errors = validate_booking_rules(
|
||||||
db=db,
|
db=db,
|
||||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||||
start_datetime=data.start_datetime,
|
start_datetime=start_datetime_utc,
|
||||||
end_datetime=data.end_datetime,
|
end_datetime=end_datetime_utc,
|
||||||
user_id=booking_user_id,
|
user_id=booking_user_id,
|
||||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||||
user_timezone=user_timezone,
|
user_timezone=user_timezone,
|
||||||
@@ -1260,9 +1265,9 @@ def reschedule_booking(
|
|||||||
detail=", ".join(errors),
|
detail=", ".join(errors),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update times
|
# Update times (already UTC)
|
||||||
booking.start_datetime = data.start_datetime # type: ignore[assignment]
|
booking.start_datetime = start_datetime_utc # type: ignore[assignment]
|
||||||
booking.end_datetime = data.end_datetime # type: ignore[assignment]
|
booking.end_datetime = end_datetime_utc # type: ignore[assignment]
|
||||||
|
|
||||||
# Sync with Google Calendar if event exists
|
# Sync with Google Calendar if event exists
|
||||||
if booking.google_calendar_event_id and booking.user_id:
|
if booking.google_calendar_event_id and booking.user_id:
|
||||||
@@ -1351,65 +1356,20 @@ def admin_create_booking(
|
|||||||
detail="User not found",
|
detail="User not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate booking rules (we need to check overlap with approved bookings only)
|
# Convert input times from admin timezone to UTC
|
||||||
# For admin direct booking, we need custom validation:
|
user_timezone = current_admin.timezone or "UTC" # type: ignore[attr-defined]
|
||||||
# 1. Duration limits
|
start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone)
|
||||||
# 2. Working hours
|
end_datetime_utc = convert_to_utc(booking_data.end_datetime, user_timezone)
|
||||||
# 3. Overlap with approved bookings only (not pending)
|
|
||||||
from app.models.settings import Settings
|
|
||||||
from sqlalchemy import and_
|
|
||||||
|
|
||||||
errors = []
|
# Validate booking rules (using UTC times, same as create_booking)
|
||||||
|
errors = validate_booking_rules(
|
||||||
# Fetch settings
|
db=db,
|
||||||
settings = db.query(Settings).filter(Settings.id == 1).first()
|
space_id=booking_data.space_id,
|
||||||
if not settings:
|
user_id=target_user_id,
|
||||||
settings = Settings(
|
start_datetime=start_datetime_utc,
|
||||||
id=1,
|
end_datetime=end_datetime_utc,
|
||||||
min_duration_minutes=30,
|
user_timezone=user_timezone,
|
||||||
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)
|
|
||||||
|
|
||||||
# a) Validate duration in range
|
|
||||||
duration_minutes = (booking_data.end_datetime - booking_data.start_datetime).total_seconds() / 60
|
|
||||||
if (
|
|
||||||
duration_minutes < settings.min_duration_minutes
|
|
||||||
or duration_minutes > settings.max_duration_minutes
|
|
||||||
):
|
|
||||||
errors.append(
|
|
||||||
f"Durata rezervării trebuie să fie între {settings.min_duration_minutes} "
|
|
||||||
f"și {settings.max_duration_minutes} minute"
|
|
||||||
)
|
|
||||||
|
|
||||||
# b) Validate working hours
|
|
||||||
if (
|
|
||||||
booking_data.start_datetime.hour < settings.working_hours_start
|
|
||||||
or booking_data.end_datetime.hour > settings.working_hours_end
|
|
||||||
):
|
|
||||||
errors.append(
|
|
||||||
f"Rezervările sunt permise doar între {settings.working_hours_start}:00 "
|
|
||||||
f"și {settings.working_hours_end}:00"
|
|
||||||
)
|
|
||||||
|
|
||||||
# c) Check for overlapping approved bookings only
|
|
||||||
overlapping_bookings = db.query(Booking).filter(
|
|
||||||
Booking.space_id == booking_data.space_id,
|
|
||||||
Booking.status == "approved", # Only check approved bookings
|
|
||||||
and_(
|
|
||||||
Booking.start_datetime < booking_data.end_datetime,
|
|
||||||
Booking.end_datetime > booking_data.start_datetime,
|
|
||||||
),
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if overlapping_bookings:
|
|
||||||
errors.append("Spațiul este deja rezervat în acest interval")
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1417,12 +1377,12 @@ def admin_create_booking(
|
|||||||
detail=errors[0], # Return first error
|
detail=errors[0], # Return first error
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create booking with approved status
|
# Create booking with approved status (UTC times)
|
||||||
booking = Booking(
|
booking = Booking(
|
||||||
user_id=target_user_id,
|
user_id=target_user_id,
|
||||||
space_id=booking_data.space_id,
|
space_id=booking_data.space_id,
|
||||||
start_datetime=booking_data.start_datetime,
|
start_datetime=start_datetime_utc,
|
||||||
end_datetime=booking_data.end_datetime,
|
end_datetime=end_datetime_utc,
|
||||||
title=booking_data.title,
|
title=booking_data.title,
|
||||||
description=booking_data.description,
|
description=booking_data.description,
|
||||||
status="approved", # Direct approval, bypass pending state
|
status="approved", # Direct approval, bypass pending state
|
||||||
|
|||||||
Reference in New Issue
Block a user