feat: add per-space timezone settings and improve booking management
- 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>
This commit is contained in:
@@ -248,6 +248,7 @@ def create_booking(
|
||||
user_id=user_id,
|
||||
start_datetime=start_datetime_utc,
|
||||
end_datetime=end_datetime_utc,
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
|
||||
if errors:
|
||||
@@ -344,6 +345,9 @@ def create_recurring_booking(
|
||||
|
||||
duration = timedelta(minutes=data.duration_minutes)
|
||||
|
||||
# Get user timezone
|
||||
user_timezone = current_user.timezone or "UTC"
|
||||
|
||||
# Generate occurrence dates
|
||||
occurrences = []
|
||||
current_date = data.start_date
|
||||
@@ -360,18 +364,23 @@ def create_recurring_booking(
|
||||
|
||||
# Create bookings for each occurrence
|
||||
for occurrence_date in occurrences:
|
||||
# Build datetime
|
||||
# Build datetime in user timezone
|
||||
start_datetime = datetime.combine(occurrence_date, time(hour, minute))
|
||||
end_datetime = start_datetime + duration
|
||||
|
||||
# Convert to UTC for validation and storage
|
||||
start_datetime_utc = convert_to_utc(start_datetime, user_timezone)
|
||||
end_datetime_utc = convert_to_utc(end_datetime, user_timezone)
|
||||
|
||||
# Validate
|
||||
user_id = int(current_user.id) # type: ignore[arg-type]
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=data.space_id,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
start_datetime=start_datetime_utc,
|
||||
end_datetime=end_datetime_utc,
|
||||
user_id=user_id,
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
|
||||
if errors:
|
||||
@@ -387,14 +396,14 @@ def create_recurring_booking(
|
||||
# Skip and continue
|
||||
continue
|
||||
|
||||
# Create booking
|
||||
# Create booking (store UTC times)
|
||||
booking = Booking(
|
||||
user_id=user_id,
|
||||
space_id=data.space_id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
start_datetime=start_datetime_utc,
|
||||
end_datetime=end_datetime_utc,
|
||||
status="pending",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
@@ -491,6 +500,7 @@ def update_booking(
|
||||
end_datetime=updated_end, # type: ignore[arg-type]
|
||||
user_id=user_id,
|
||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
|
||||
if errors:
|
||||
@@ -662,6 +672,8 @@ def approve_booking(
|
||||
)
|
||||
|
||||
# Re-validate booking rules to prevent race conditions
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
@@ -669,6 +681,7 @@ def approve_booking(
|
||||
start_datetime=booking.start_datetime, # type: ignore[arg-type]
|
||||
end_datetime=booking.end_datetime, # type: ignore[arg-type]
|
||||
exclude_booking_id=int(booking.id), # type: ignore[arg-type]
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
|
||||
if errors:
|
||||
@@ -842,6 +855,8 @@ def admin_update_booking(
|
||||
booking.end_datetime = data.end_datetime # type: ignore[assignment]
|
||||
|
||||
# Re-validate booking rules
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
@@ -849,6 +864,7 @@ def admin_update_booking(
|
||||
end_datetime=booking.end_datetime, # type: ignore[arg-type]
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
|
||||
if errors:
|
||||
@@ -987,6 +1003,8 @@ def reschedule_booking(
|
||||
old_end = booking.end_datetime
|
||||
|
||||
# Validate new time slot
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
@@ -994,6 +1012,7 @@ def reschedule_booking(
|
||||
end_datetime=data.end_datetime,
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
|
||||
if errors:
|
||||
|
||||
@@ -3,9 +3,10 @@ from app.models.attachment import Attachment
|
||||
from app.models.audit_log import AuditLog
|
||||
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.settings import Settings
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["User", "Space", "Settings", "Booking", "BookingTemplate", "Notification", "AuditLog", "Attachment"]
|
||||
__all__ = ["User", "Space", "Settings", "Booking", "BookingTemplate", "Notification", "AuditLog", "Attachment", "GoogleCalendarToken"]
|
||||
|
||||
@@ -15,3 +15,9 @@ class Space(Base):
|
||||
capacity = Column(Integer, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -10,6 +10,12 @@ class SpaceBase(BaseModel):
|
||||
capacity: int = Field(..., gt=0)
|
||||
description: str | None = None
|
||||
|
||||
# Per-space scheduling settings (None = use global default)
|
||||
working_hours_start: int | None = None
|
||||
working_hours_end: int | None = None
|
||||
min_duration_minutes: int | None = None
|
||||
max_duration_minutes: int | None = None
|
||||
|
||||
|
||||
class SpaceCreate(SpaceBase):
|
||||
"""Space creation schema."""
|
||||
@@ -34,5 +40,9 @@ class SpaceResponse(SpaceBase):
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
working_hours_start: int | None = None
|
||||
working_hours_end: int | None = None
|
||||
min_duration_minutes: int | None = None
|
||||
max_duration_minutes: int | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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(
|
||||
@@ -15,25 +17,27 @@ def validate_booking_rules(
|
||||
start_datetime: datetime,
|
||||
end_datetime: datetime,
|
||||
exclude_booking_id: int | None = None,
|
||||
user_timezone: str = "UTC",
|
||||
) -> list[str]:
|
||||
"""
|
||||
Validate booking against global settings rules.
|
||||
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
|
||||
end_datetime: Booking end time
|
||||
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 settings (create default if not exists)
|
||||
# Fetch global settings (create default if not exists)
|
||||
settings = db.query(Settings).filter(Settings.id == 1).first()
|
||||
if not settings:
|
||||
settings = Settings(
|
||||
@@ -49,25 +53,44 @@ def validate_booking_rules(
|
||||
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 < settings.min_duration_minutes
|
||||
or duration_minutes > settings.max_duration_minutes
|
||||
):
|
||||
if duration_minutes < min_dur or duration_minutes > max_dur:
|
||||
errors.append(
|
||||
f"Durata rezervării trebuie să fie între {settings.min_duration_minutes} "
|
||||
f"și {settings.max_duration_minutes} minute"
|
||||
f"Durata rezervării trebuie să fie între {min_dur} și {max_dur} minute"
|
||||
)
|
||||
|
||||
# b) Validate working hours
|
||||
if (
|
||||
start_datetime.hour < settings.working_hours_start
|
||||
or end_datetime.hour > settings.working_hours_end
|
||||
):
|
||||
# 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 {settings.working_hours_start}:00 "
|
||||
f"și {settings.working_hours_end}:00"
|
||||
f"Rezervările sunt permise doar între {wh_start}:00 și {wh_end}:00"
|
||||
)
|
||||
|
||||
# c) Check for overlapping bookings
|
||||
@@ -88,18 +111,22 @@ def validate_booking_rules(
|
||||
if overlapping_bookings:
|
||||
errors.append("Spațiul este deja rezervat în acest interval")
|
||||
|
||||
# d) Check max bookings per day per user
|
||||
booking_date = start_datetime.date()
|
||||
start_of_day = datetime.combine(booking_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(booking_date, datetime.max.time())
|
||||
# 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,
|
||||
Booking.start_datetime <= end_of_day,
|
||||
Booking.start_datetime >= start_of_day_utc,
|
||||
Booking.start_datetime <= end_of_day_utc,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user