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:
@@ -5,8 +5,10 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_admin, get_current_user, get_db
|
||||
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
|
||||
from app.core.permissions import get_manager_property_ids, verify_property_access
|
||||
from app.models.booking import Booking
|
||||
from app.models.property_manager import PropertyManager
|
||||
from app.models.settings import Settings
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
@@ -39,6 +41,40 @@ from app.services.booking_service import validate_booking_rules
|
||||
from app.utils.timezone import convert_to_utc
|
||||
|
||||
router = APIRouter(prefix="/spaces", tags=["bookings"])
|
||||
|
||||
|
||||
def _verify_manager_booking_access(db: Session, booking: Booking, current_user: User) -> None:
|
||||
"""Verify that a manager has access to the booking's property.
|
||||
|
||||
Superadmins always have access. Managers can only act on bookings
|
||||
for spaces within their managed properties.
|
||||
"""
|
||||
if current_user.role in ("superadmin", "admin"):
|
||||
return
|
||||
if current_user.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_user.id)
|
||||
space = booking.space
|
||||
if space and space.property_id and space.property_id not in managed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this property's bookings",
|
||||
)
|
||||
|
||||
|
||||
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
|
||||
"""Verify that a manager has access to a space's property.
|
||||
|
||||
Used for creating bookings where we have the space but no booking yet.
|
||||
"""
|
||||
if current_user.role in ("superadmin", "admin"):
|
||||
return
|
||||
if current_user.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_user.id)
|
||||
if space.property_id and space.property_id not in managed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this property's spaces",
|
||||
)
|
||||
bookings_router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||
|
||||
|
||||
@@ -68,6 +104,10 @@ def get_space_bookings(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Query bookings in the time range (only active bookings)
|
||||
query = db.query(Booking).filter(
|
||||
Booking.space_id == space_id,
|
||||
@@ -79,7 +119,7 @@ def get_space_bookings(
|
||||
bookings = query.order_by(Booking.start_datetime).all()
|
||||
|
||||
# Return different schemas based on user role
|
||||
if current_user.role == "admin":
|
||||
if current_user.role in ("admin", "superadmin", "manager"):
|
||||
return [BookingCalendarAdmin.model_validate(b) for b in bookings]
|
||||
else:
|
||||
return [BookingCalendarPublic.model_validate(b) for b in bookings]
|
||||
@@ -116,6 +156,10 @@ def check_availability(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Find conflicting bookings (approved + pending)
|
||||
conflicts = (
|
||||
db.query(Booking)
|
||||
@@ -271,6 +315,10 @@ def create_booking(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Convert input times from user timezone to UTC
|
||||
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
|
||||
start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone)
|
||||
@@ -293,8 +341,8 @@ def create_booking(
|
||||
detail=errors[0], # Return first error
|
||||
)
|
||||
|
||||
# Auto-approve if admin, otherwise pending
|
||||
is_admin = current_user.role == "admin"
|
||||
# Auto-approve if admin/superadmin, otherwise pending
|
||||
is_admin = current_user.role in ("admin", "superadmin")
|
||||
|
||||
# Create booking (with UTC times)
|
||||
booking = Booking(
|
||||
@@ -314,23 +362,39 @@ def create_booking(
|
||||
db.refresh(booking)
|
||||
|
||||
if not is_admin:
|
||||
# Notify all admins about the new booking request
|
||||
admins = db.query(User).filter(User.role == "admin").all()
|
||||
# Notify admins and property managers
|
||||
notify_users = {}
|
||||
|
||||
# Get superadmins/admins
|
||||
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
|
||||
for admin in admins:
|
||||
notify_users[admin.id] = admin
|
||||
|
||||
# Get property managers for the space's property
|
||||
if space.property_id:
|
||||
manager_ids = [
|
||||
pm.user_id
|
||||
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
|
||||
]
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||
for mgr in managers:
|
||||
notify_users[mgr.id] = mgr
|
||||
|
||||
for user in notify_users.values():
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=admin.id, # type: ignore[arg-type]
|
||||
user_id=user.id, # type: ignore[arg-type]
|
||||
type="booking_created",
|
||||
title="Noua Cerere de Rezervare",
|
||||
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
# Send email notification to admin
|
||||
# Send email notification
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"created",
|
||||
admin.email,
|
||||
user.email,
|
||||
current_user.full_name,
|
||||
None,
|
||||
)
|
||||
@@ -375,6 +439,10 @@ def create_recurring_booking(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Parse time
|
||||
try:
|
||||
hour, minute = map(int, data.start_time.split(':'))
|
||||
@@ -458,14 +526,25 @@ def create_recurring_booking(
|
||||
for booking in created_bookings:
|
||||
db.refresh(booking)
|
||||
|
||||
# Send notifications to admins (in background)
|
||||
# Send notifications to admins and property managers (in background)
|
||||
if created_bookings:
|
||||
admins = db.query(User).filter(User.role == "admin").all()
|
||||
notify_users = {}
|
||||
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
|
||||
for admin in admins:
|
||||
notify_users[admin.id] = admin
|
||||
if space.property_id:
|
||||
manager_ids = [
|
||||
pm.user_id
|
||||
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
|
||||
]
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||
for mgr in managers:
|
||||
notify_users[mgr.id] = mgr
|
||||
for user in notify_users.values():
|
||||
background_tasks.add_task(
|
||||
create_notification,
|
||||
db=db,
|
||||
user_id=admin.id, # type: ignore[arg-type]
|
||||
user_id=user.id, # type: ignore[arg-type]
|
||||
type="booking_created",
|
||||
title="Noi Cereri de Rezervare Recurente",
|
||||
message=f"Utilizatorul {current_user.full_name} a creat {len(created_bookings)} rezervări recurente.",
|
||||
@@ -648,13 +727,14 @@ def get_all_bookings(
|
||||
status_filter: Annotated[str | None, Query(alias="status")] = None,
|
||||
space_id: Annotated[int | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
property_id: Annotated[int | None, Query()] = None,
|
||||
start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
|
||||
) -> list[BookingPendingDetail]:
|
||||
"""
|
||||
Get all bookings across all users (admin only).
|
||||
Get all bookings across all users (admin/manager).
|
||||
|
||||
Returns bookings with user and space details.
|
||||
|
||||
@@ -662,15 +742,24 @@ def get_all_bookings(
|
||||
- **status** (optional): Filter by status (pending/approved/rejected/canceled)
|
||||
- **space_id** (optional): Filter by space ID
|
||||
- **user_id** (optional): Filter by user ID
|
||||
- **property_id** (optional): Filter by property ID
|
||||
- **start** (optional): Only bookings starting from this datetime
|
||||
- **limit** (optional): Max results (1-100, default 20)
|
||||
"""
|
||||
query = (
|
||||
db.query(Booking)
|
||||
.join(Space, Booking.space_id == Space.id)
|
||||
.join(User, Booking.user_id == User.id)
|
||||
.outerjoin(User, Booking.user_id == User.id)
|
||||
)
|
||||
|
||||
# Property scoping for managers
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
query = query.filter(Space.property_id.in_(managed_ids))
|
||||
|
||||
if property_id is not None:
|
||||
query = query.filter(Space.property_id == property_id)
|
||||
|
||||
if status_filter is not None:
|
||||
query = query.filter(Booking.status == status_filter)
|
||||
|
||||
@@ -681,7 +770,6 @@ def get_all_bookings(
|
||||
query = query.filter(Booking.user_id == user_id)
|
||||
|
||||
if start is not None:
|
||||
# Use end_datetime to include bookings still in progress (started but not ended)
|
||||
query = query.filter(Booking.end_datetime > start)
|
||||
|
||||
bookings = (
|
||||
@@ -697,26 +785,36 @@ def get_all_bookings(
|
||||
def get_pending_bookings(
|
||||
space_id: Annotated[int | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
property_id: Annotated[int | None, Query()] = None,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
|
||||
) -> list[BookingPendingDetail]:
|
||||
"""
|
||||
Get all pending booking requests (admin only).
|
||||
Get all pending booking requests (admin/manager).
|
||||
|
||||
Returns pending bookings with user and space details, sorted by creation time (FIFO).
|
||||
|
||||
Query parameters:
|
||||
- **space_id** (optional): Filter by space ID
|
||||
- **user_id** (optional): Filter by user ID
|
||||
- **property_id** (optional): Filter by property ID
|
||||
"""
|
||||
# Base query: pending bookings with joins
|
||||
# Base query: pending bookings with joins (outerjoin for anonymous bookings)
|
||||
query = (
|
||||
db.query(Booking)
|
||||
.join(Space, Booking.space_id == Space.id)
|
||||
.join(User, Booking.user_id == User.id)
|
||||
.outerjoin(User, Booking.user_id == User.id)
|
||||
.filter(Booking.status == "pending")
|
||||
)
|
||||
|
||||
# Property scoping for managers
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
query = query.filter(Space.property_id.in_(managed_ids))
|
||||
|
||||
if property_id is not None:
|
||||
query = query.filter(Space.property_id == property_id)
|
||||
|
||||
# Apply filters if provided
|
||||
if space_id is not None:
|
||||
query = query.filter(Booking.space_id == space_id)
|
||||
@@ -735,7 +833,7 @@ def approve_booking(
|
||||
id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Approve a pending booking request (admin only).
|
||||
@@ -755,6 +853,9 @@ def approve_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking is pending
|
||||
if booking.status != "pending":
|
||||
raise HTTPException(
|
||||
@@ -764,11 +865,12 @@ 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"
|
||||
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
|
||||
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
user_id=booking_user_id,
|
||||
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]
|
||||
@@ -790,13 +892,14 @@ def approve_booking(
|
||||
db.refresh(booking)
|
||||
|
||||
# Create Google Calendar event if user has connected their calendar
|
||||
google_event_id = create_calendar_event(
|
||||
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
|
||||
)
|
||||
if google_event_id:
|
||||
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
if booking.user_id:
|
||||
google_event_id = create_calendar_event(
|
||||
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
|
||||
)
|
||||
if google_event_id:
|
||||
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Log the action
|
||||
log_action(
|
||||
@@ -809,24 +912,35 @@ def approve_booking(
|
||||
)
|
||||
|
||||
# Notify the user about approval
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_approved",
|
||||
title="Rezervare Aprobată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id and booking.user:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_approved",
|
||||
title="Rezervare Aprobată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"approved",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
None,
|
||||
)
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"approved",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
None,
|
||||
)
|
||||
elif booking.guest_email:
|
||||
# Send email notification to anonymous guest
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_approved",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
None,
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -837,7 +951,7 @@ def reject_booking(
|
||||
reject_data: RejectRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Reject a pending booking request (admin only).
|
||||
@@ -857,6 +971,9 @@ def reject_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking is pending
|
||||
if booking.status != "pending":
|
||||
raise HTTPException(
|
||||
@@ -882,24 +999,34 @@ def reject_booking(
|
||||
)
|
||||
|
||||
# Notify the user about rejection
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rejected",
|
||||
title="Rezervare Respinsă",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id and booking.user:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rejected",
|
||||
title="Rezervare Respinsă",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"rejected",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"rejected",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
elif booking.guest_email:
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_rejected",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -909,10 +1036,10 @@ def admin_update_booking(
|
||||
id: int,
|
||||
data: BookingUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Update any booking (admin only).
|
||||
Update any booking (admin/manager).
|
||||
|
||||
Admin can edit any booking (pending or approved), but cannot edit bookings
|
||||
that have already started.
|
||||
@@ -928,6 +1055,9 @@ def admin_update_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking already started (cannot edit past bookings)
|
||||
if booking.start_datetime < datetime.utcnow() and booking.status == "approved": # type: ignore[operator]
|
||||
raise HTTPException(
|
||||
@@ -947,13 +1077,14 @@ def admin_update_booking(
|
||||
|
||||
# Re-validate booking rules
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
|
||||
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
start_datetime=booking.start_datetime, # type: ignore[arg-type]
|
||||
end_datetime=booking.end_datetime, # type: ignore[arg-type]
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
user_id=booking_user_id,
|
||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
@@ -965,7 +1096,7 @@ def admin_update_booking(
|
||||
)
|
||||
|
||||
# Sync with Google Calendar if event exists
|
||||
if booking.google_calendar_event_id:
|
||||
if booking.google_calendar_event_id and booking.user_id:
|
||||
update_calendar_event(
|
||||
db=db,
|
||||
booking=booking,
|
||||
@@ -994,10 +1125,10 @@ def admin_cancel_booking(
|
||||
cancel_data: AdminCancelRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Cancel any booking (admin only).
|
||||
Cancel any booking (admin/manager).
|
||||
|
||||
Admin can cancel any booking at any time, regardless of status or timing.
|
||||
No time restrictions apply (unlike user cancellations).
|
||||
@@ -1015,13 +1146,16 @@ def admin_cancel_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Admin can cancel any booking (no status check needed)
|
||||
# Update booking status
|
||||
booking.status = "canceled" # type: ignore[assignment]
|
||||
booking.cancellation_reason = cancel_data.cancellation_reason # type: ignore[assignment]
|
||||
|
||||
# Delete from Google Calendar if event exists
|
||||
if booking.google_calendar_event_id:
|
||||
if booking.google_calendar_event_id and booking.user_id:
|
||||
delete_calendar_event(
|
||||
db=db,
|
||||
event_id=booking.google_calendar_event_id,
|
||||
@@ -1043,24 +1177,25 @@ def admin_cancel_booking(
|
||||
)
|
||||
|
||||
# Notify the user about cancellation
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_canceled",
|
||||
title="Rezervare Anulată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id and booking.user:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_canceled",
|
||||
title="Rezervare Anulată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"canceled",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"cancellation_reason": cancel_data.cancellation_reason},
|
||||
)
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"canceled",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"cancellation_reason": cancel_data.cancellation_reason},
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -1070,10 +1205,10 @@ def reschedule_booking(
|
||||
id: int,
|
||||
data: BookingReschedule,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Reschedule booking to new time slot (admin only, drag-and-drop).
|
||||
Reschedule booking to new time slot (admin/manager, drag-and-drop).
|
||||
|
||||
Validates the new time slot and updates the booking times.
|
||||
Only approved bookings that haven't started yet can be rescheduled.
|
||||
@@ -1091,6 +1226,9 @@ def reschedule_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking already started (cannot reschedule past bookings)
|
||||
if booking.start_datetime < datetime.utcnow(): # type: ignore[operator]
|
||||
raise HTTPException(
|
||||
@@ -1104,13 +1242,14 @@ def reschedule_booking(
|
||||
|
||||
# Validate new time slot
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
|
||||
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
start_datetime=data.start_datetime,
|
||||
end_datetime=data.end_datetime,
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
user_id=booking_user_id,
|
||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
@@ -1126,7 +1265,7 @@ def reschedule_booking(
|
||||
booking.end_datetime = data.end_datetime # type: ignore[assignment]
|
||||
|
||||
# Sync with Google Calendar if event exists
|
||||
if booking.google_calendar_event_id:
|
||||
if booking.google_calendar_event_id and booking.user_id:
|
||||
update_calendar_event(
|
||||
db=db,
|
||||
booking=booking,
|
||||
@@ -1150,14 +1289,15 @@ def reschedule_booking(
|
||||
)
|
||||
|
||||
# Notify user about reschedule
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rescheduled",
|
||||
title="Rezervare Reprogramată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rescheduled",
|
||||
title="Rezervare Reprogramată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
@@ -1169,10 +1309,10 @@ def reschedule_booking(
|
||||
def admin_create_booking(
|
||||
booking_data: BookingAdminCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Create a booking directly with approved status (admin only, bypass approval workflow).
|
||||
Create a booking directly with approved status (admin/manager, bypass approval workflow).
|
||||
|
||||
- **space_id**: ID of the space to book
|
||||
- **user_id**: Optional user ID (defaults to current admin if not provided)
|
||||
@@ -1196,6 +1336,9 @@ def admin_create_booking(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this space's property
|
||||
_verify_manager_space_access(db, space, current_admin)
|
||||
|
||||
# Use current admin ID if user_id not provided
|
||||
target_user_id = booking_data.user_id if booking_data.user_id is not None else int(current_admin.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user