"""Public/anonymous endpoints (no auth required).""" import os from collections import defaultdict from datetime import datetime, time, timedelta from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status from pydantic import BaseModel from sqlalchemy import and_, or_ from sqlalchemy.orm import Query as SAQuery from sqlalchemy.orm import Session from app.core.deps import get_db from app.core.slug import DEMO_SLUG from app.models.booking import Booking from app.models.property import Property from app.models.property_manager import PropertyManager from app.models.property_settings import PropertySettings from app.models.space import Space from app.models.user import User from app.schemas.booking import ( AnonymousBookingCreate, AvailabilityCheck, BookingResponse, ConflictingBooking, ) from app.schemas.property import PropertyResponse from app.schemas.space import SpaceResponse from app.services.email_service import send_booking_notification from app.services.notification_service import create_notification router = APIRouter(prefix="/public", tags=["public"]) # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def overlapping_bookings_query( db: Session, space_id: int, start: datetime, end: datetime, statuses: tuple[str, ...] = ("approved", "pending"), ) -> SAQuery: """Shared overlap predicate: any booking on `space_id` whose interval intersects [start, end), restricted to `statuses`.""" return db.query(Booking).filter( Booking.space_id == space_id, Booking.status.in_(statuses), or_( and_( Booking.start_datetime <= start, Booking.end_datetime > start, ), and_( Booking.start_datetime < end, Booking.end_datetime >= end, ), and_( Booking.start_datetime >= start, Booking.end_datetime <= end, ), ), ) def _resolve_property(db: Session, slug_or_id: str) -> Property | None: """Resolve a property by numeric id (all-digit segment) or by slug.""" if slug_or_id.isdigit(): return db.query(Property).filter(Property.id == int(slug_or_id)).first() return db.query(Property).filter(Property.slug == slug_or_id).first() def _is_visible(prop: Property | None) -> bool: """Uniform visibility rule for public endpoints: must exist, be public, and be active. Callers turn a False result into a bare 404 (no 403 oracle that would let clients distinguish private-vs-missing).""" return bool(prop and prop.is_public and prop.is_active) def _require_approval_for(db: Session, property_id: int) -> bool | None: settings_row = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first() return settings_row.require_approval if settings_row else None def _property_to_response(db: Session, prop: Property) -> PropertyResponse: space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712 return PropertyResponse( id=prop.id, name=prop.name, description=prop.description, address=prop.address, is_public=prop.is_public, is_active=prop.is_active, created_at=prop.created_at, space_count=space_count, slug=prop.slug, list_on_landing=prop.list_on_landing, require_approval=_require_approval_for(db, prop.id), ) def _resolve_visible_space_property(db: Session, space: Space) -> Property | None: """Return the space's property iff it exists, is public, and active. Returns None (caller raises 404) for orphan spaces or private/inactive properties -- uniform 404, no 403 oracle.""" if not space.property_id: return None prop = db.query(Property).filter(Property.id == space.property_id).first() if not _is_visible(prop): return None return prop # --------------------------------------------------------------------------- # In-process per-IP rate limiter for anonymous booking creation. # # A simple sliding-window counter is used instead of pulling in slowapi: # it needs no app-level wiring (this module cannot touch app/main.py under # the current work split) and is trivially bypassed in tests via the # PYTEST_CURRENT_TEST env var pytest sets automatically, or DISABLE_RATE_LIMIT. # --------------------------------------------------------------------------- _RATE_LIMIT_WINDOW = timedelta(hours=1) _RATE_LIMIT_MAX = 5 _rate_limit_buckets: dict[str, list[datetime]] = defaultdict(list) def _rate_limiting_enabled() -> bool: if os.getenv("PYTEST_CURRENT_TEST"): return False if os.getenv("DISABLE_RATE_LIMIT"): return False return True def _enforce_booking_rate_limit(request: Request) -> None: if not _rate_limiting_enabled(): return ip = request.client.host if request.client else "unknown" now = datetime.utcnow() bucket = _rate_limit_buckets[ip] bucket[:] = [t for t in bucket if now - t < _RATE_LIMIT_WINDOW] if len(bucket) >= _RATE_LIMIT_MAX: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Prea multe cereri de rezervare de la această adresă IP. Încearcă din nou peste o oră.", ) bucket.append(now) class BusyInterval(BaseModel): """Minimal busy-slot payload for the public calendar. No title, name, email, or status is ever exposed here.""" start_time: datetime end_time: datetime # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @router.get("/properties", response_model=list[PropertyResponse]) def list_public_properties( db: Annotated[Session, Depends(get_db)], ) -> list[PropertyResponse]: """List public properties (no auth required).""" properties = ( db.query(Property) .filter(Property.is_public == True, Property.is_active == True) # noqa: E712 .order_by(Property.name) .all() ) return [_property_to_response(db, p) for p in properties] @router.get("/properties/{slug_or_id}", response_model=PropertyResponse) def get_public_property( slug_or_id: str, db: Annotated[Session, Depends(get_db)], ) -> PropertyResponse: """Resolve a public property by slug or numeric id (no auth required).""" prop = _resolve_property(db, slug_or_id) if not _is_visible(prop): raise HTTPException(status_code=404, detail="Property not found") return _property_to_response(db, prop) @router.get("/properties/{slug_or_id}/spaces", response_model=list[SpaceResponse]) def list_public_property_spaces( slug_or_id: str, db: Annotated[Session, Depends(get_db)], ) -> list[SpaceResponse]: """List spaces of a public property (no auth required).""" prop = _resolve_property(db, slug_or_id) if not _is_visible(prop): raise HTTPException(status_code=404, detail="Property not found") spaces = ( db.query(Space) .filter(Space.property_id == prop.id, Space.is_active == True) # noqa: E712 .order_by(Space.name) .all() ) return [SpaceResponse.model_validate(s) for s in spaces] @router.get("/spaces/{space_id}/busy", response_model=list[BusyInterval]) def get_public_space_busy( space_id: int, start: Annotated[datetime, Query()], end: Annotated[datetime, Query()], db: Annotated[Session, Depends(get_db)], ) -> list[BusyInterval]: """Return only the busy intervals for a space (no PII, no auth).""" if start.tzinfo is not None or end.tzinfo is not None: raise HTTPException( status_code=422, detail="start/end must be naive datetimes (property local time), not timezone-aware", ) if end <= start: raise HTTPException(status_code=422, detail="end must be after start") if (end - start) > timedelta(days=60): raise HTTPException(status_code=422, detail="Range cannot exceed 60 days") space = db.query(Space).filter(Space.id == space_id).first() if not space: raise HTTPException(status_code=404, detail="Space not found") if not _resolve_visible_space_property(db, space): raise HTTPException(status_code=404, detail="Space not found") bookings = overlapping_bookings_query(db, space_id, start, end, statuses=("approved", "pending")).all() return [BusyInterval(start_time=b.start_datetime, end_time=b.end_datetime) for b in bookings] @router.get("/spaces/{space_id}/availability", response_model=AvailabilityCheck) def check_public_availability( space_id: int, start_datetime: Annotated[datetime, Query()], end_datetime: Annotated[datetime, Query()], db: Annotated[Session, Depends(get_db)], ) -> AvailabilityCheck: """Check availability for a space (no auth required).""" space = db.query(Space).filter(Space.id == space_id).first() if not space: raise HTTPException(status_code=404, detail="Space not found") if not _resolve_visible_space_property(db, space): raise HTTPException(status_code=404, detail="Space not found") # Find conflicting bookings conflicts = overlapping_bookings_query( db, space_id, start_datetime, end_datetime, statuses=("approved", "pending") ).all() if not conflicts: return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available") approved_count = sum(1 for b in conflicts if b.status == "approved") pending_count = sum(1 for b in conflicts if b.status == "pending") if approved_count > 0: message = f"Time slot has {approved_count} approved booking(s)." else: message = f"Time slot has {pending_count} pending request(s)." return AvailabilityCheck( available=approved_count == 0, conflicts=[ # user_name/title are scrubbed here: this endpoint is reachable # by anonymous callers and must not leak PII about other # bookers (privacy regression fix). ConflictingBooking( id=b.id, user_name="", title="", status=b.status, start_datetime=b.start_datetime, end_datetime=b.end_datetime, ) for b in conflicts ], message=message, ) @router.post("/bookings", response_model=BookingResponse, status_code=status.HTTP_201_CREATED) def create_anonymous_booking( data: AnonymousBookingCreate, background_tasks: BackgroundTasks, request: Request, db: Annotated[Session, Depends(get_db)], ) -> BookingResponse: """Create an anonymous/guest booking (no auth required).""" _enforce_booking_rate_limit(request) # Validate space exists space = db.query(Space).filter(Space.id == data.space_id).first() if not space: raise HTTPException(status_code=404, detail="Space not found") # Verify space belongs to a public property if space.property_id: prop = db.query(Property).filter(Property.id == space.property_id).first() if prop and not prop.is_public: raise HTTPException(status_code=403, detail="Cannot book in a private property without authentication") else: raise HTTPException(status_code=400, detail="Space is not assigned to any property") # Basic validation (no user_id needed for anonymous) if data.end_datetime <= data.start_datetime: raise HTTPException(status_code=400, detail="End time must be after start time") # Reject overlap with any pending OR approved booking (previously # approved-only, which let guests double-book against pending requests). overlapping = overlapping_bookings_query( db, data.space_id, data.start_datetime, data.end_datetime, statuses=("approved", "pending") ).first() if overlapping: raise HTTPException(status_code=400, detail="Time slot is already booked") # Cap pending requests per guest/space/day to deter spam. day_start = datetime.combine(data.start_datetime.date(), time.min) day_end = datetime.combine(data.start_datetime.date(), time.max) pending_today = ( db.query(Booking) .filter( Booking.space_id == data.space_id, Booking.guest_email == data.guest_email, Booking.status == "pending", Booking.start_datetime >= day_start, Booking.start_datetime <= day_end, ) .count() ) if pending_today >= 5: raise HTTPException( status_code=400, detail="Ai atins limita de 5 cereri în așteptare pentru acest spațiu în această zi.", ) is_demo = bool(prop and prop.slug == DEMO_SLUG) # Create anonymous booking booking = Booking( user_id=None, space_id=data.space_id, start_datetime=data.start_datetime, end_datetime=data.end_datetime, title=data.title, description=data.description, status="pending", guest_name=data.guest_name, guest_email=data.guest_email, guest_organization=data.guest_organization, is_anonymous=True, created_at=datetime.utcnow(), ) db.add(booking) db.commit() db.refresh(booking) # Notify property managers/superadmins -- suppressed entirely for the # demo property so the public playground doesn't spam real staff. if space.property_id and not is_demo: 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 [] # Also notify superadmins superadmins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all() notify_users = {u.id: u for u in list(managers) + list(superadmins)} for user in notify_users.values(): create_notification( db=db, user_id=user.id, type="booking_created", title="Cerere Anonimă de Rezervare", message=f"Persoana {data.guest_name} ({data.guest_email}) a solicitat rezervarea spațiului {space.name}", booking_id=booking.id, ) background_tasks.add_task( send_booking_notification, booking, "anonymous_created", user.email, data.guest_name, {"guest_email": data.guest_email}, ) # Confirmation email to the guest themselves -- also suppressed for the # demo property (its data is wiped every few hours; nothing to confirm). if not is_demo: background_tasks.add_task( send_booking_notification, booking, "anonymous_created", data.guest_email, data.guest_name, {"guest_email": data.guest_email}, ) return BookingResponse.model_validate(booking)