Expune fluxul de rezervare anonimă printr-un link public partajabil
(/book/<slug>), cu calendar public, cod QR și proprietate demo publică.
Backend:
- slug + list_on_landing pe Property; utilitar slugify (diacritice RO,
cuvinte rezervate, ban all-digit) + migrare idempotentă raw-DDL rulată
din entrypoint; SQLite WAL + busy_timeout
- GET /public/properties/{slug_or_id}(+/spaces): rezolvare slug/ID, 404 uniform
- GET /public/spaces/{id}/busy: doar {start_time,end_time} (fără PII),
pending+approved, tz-aware/interval>60z → 422, orfan/privat → 404
- availability: scrub user_name/title (fix scurgere PII)
- POST /public/bookings: overlap pending+approved, cap 5/email/spațiu/zi,
rate-limit per-IP, suprimare notificări+email pe demo, email confirmare guest
- PATCH slug (409 duplicat / 422 format / 403 non-manager); guest_email EmailStr
- email guest la aprobare/respingere cu cod #RB-<id>
- teste noi: test_public.py + test_migrate_slug.py (33 teste)
Frontend:
- rută /book/:slug; PublicBooking rescris cu calendar grid manual
(PublicCalendar + GuestBookingForm), 30min, 08-20, mobil day-view, RO
- card „Link public" în PropertyDetail: copy, edit slug, QR pe URL-ul cu ID
- secțiune proprietăți publice + CTA pe Landing; buton copy în Properties
Gate aprobat (U1-U3, G1 grid manual, G2 list_on_landing); G3-G5 în TODOS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
409 lines
15 KiB
Python
409 lines
15 KiB
Python
"""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)
|