feat: sistem de linkuri publice pentru proprietăți
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>
This commit is contained in:
@@ -7,7 +7,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
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.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.settings import Settings
|
||||
from app.models.space import Space
|
||||
@@ -61,6 +63,18 @@ def _verify_manager_booking_access(db: Session, booking: Booking, current_user:
|
||||
)
|
||||
|
||||
|
||||
def _is_demo_property_space(db: Session, space: Space | None) -> bool:
|
||||
"""Return True if the given space belongs to the demo property.
|
||||
|
||||
Used to suppress guest notification emails for the public demo property
|
||||
(avoids spamming real inboxes from demo/test bookings).
|
||||
"""
|
||||
if space is None or not space.property_id:
|
||||
return False
|
||||
prop = db.query(Property).filter(Property.id == space.property_id).first()
|
||||
return bool(prop and prop.slug == DEMO_SLUG)
|
||||
|
||||
|
||||
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
|
||||
"""Verify that a manager has access to a space's property.
|
||||
|
||||
@@ -931,15 +945,15 @@ def approve_booking(
|
||||
booking.user.full_name,
|
||||
None,
|
||||
)
|
||||
elif booking.guest_email:
|
||||
# Send email notification to anonymous guest
|
||||
elif booking.guest_email and not _is_demo_property_space(db, booking.space):
|
||||
# Send email notification to anonymous guest (skipped for demo property)
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_approved",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
None,
|
||||
{"reference_code": f"RB-{booking.id}"},
|
||||
)
|
||||
|
||||
return booking
|
||||
@@ -1018,14 +1032,14 @@ def reject_booking(
|
||||
booking.user.full_name,
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
elif booking.guest_email:
|
||||
elif booking.guest_email and not _is_demo_property_space(db, booking.space):
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_rejected",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
{"rejection_reason": reject_data.reason},
|
||||
{"rejection_reason": reject_data.reason, "reference_code": f"RB-{booking.id}"},
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import (
|
||||
@@ -12,6 +13,7 @@ from app.core.deps import (
|
||||
get_optional_user,
|
||||
)
|
||||
from app.core.permissions import get_manager_property_ids, verify_property_access
|
||||
from app.core.slug import generate_unique_slug, validate_slug
|
||||
from app.models.organization import Organization
|
||||
from app.models.property import Property
|
||||
from app.models.property_access import PropertyAccess
|
||||
@@ -125,6 +127,8 @@ def list_properties(
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, p.id),
|
||||
slug=p.slug,
|
||||
list_on_landing=p.list_on_landing,
|
||||
))
|
||||
return result
|
||||
|
||||
@@ -141,6 +145,8 @@ def get_property(
|
||||
spaces = db.query(Space).filter(Space.property_id == property_id, Space.is_active == True).all() # noqa: E712
|
||||
space_count = len(spaces)
|
||||
|
||||
settings_row = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
|
||||
|
||||
return PropertyWithSpaces(
|
||||
id=prop.id,
|
||||
name=prop.name,
|
||||
@@ -152,6 +158,9 @@ def get_property(
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
spaces=[SpaceResponse.model_validate(s) for s in spaces],
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
require_approval=settings_row.require_approval if settings_row else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -190,8 +199,11 @@ def create_property(
|
||||
description=data.description,
|
||||
address=data.address,
|
||||
is_public=data.is_public,
|
||||
list_on_landing=data.list_on_landing,
|
||||
)
|
||||
db.add(prop)
|
||||
db.flush()
|
||||
prop.slug = generate_unique_slug(db, data.name, exclude_id=prop.id)
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
|
||||
@@ -219,6 +231,8 @@ def create_property(
|
||||
created_at=prop.created_at,
|
||||
space_count=0,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
)
|
||||
|
||||
|
||||
@@ -241,10 +255,37 @@ def update_property(
|
||||
prop.address = data.address
|
||||
if data.is_public is not None:
|
||||
prop.is_public = data.is_public
|
||||
if data.list_on_landing is not None:
|
||||
prop.list_on_landing = data.list_on_landing
|
||||
|
||||
db.commit()
|
||||
slug_changed = False
|
||||
old_slug = prop.slug
|
||||
if data.slug is not None and data.slug != prop.slug:
|
||||
if not validate_slug(data.slug):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Slug invalid. Folosește doar litere mici, cifre și cratime (3-64 caractere).",
|
||||
)
|
||||
prop.slug = data.slug
|
||||
slug_changed = True
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug deja folosit")
|
||||
db.refresh(prop)
|
||||
|
||||
if slug_changed:
|
||||
log_action(
|
||||
db=db,
|
||||
action="property_slug_changed",
|
||||
user_id=current_user.id,
|
||||
target_type="property",
|
||||
target_id=prop.id,
|
||||
details={"old": old_slug, "new": prop.slug},
|
||||
)
|
||||
|
||||
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
|
||||
return PropertyResponse(
|
||||
id=prop.id,
|
||||
@@ -256,6 +297,8 @@ def update_property(
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +327,8 @@ def update_property_status(
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
)
|
||||
|
||||
|
||||
@@ -517,6 +562,8 @@ def admin_list_all_properties(
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, p.id),
|
||||
slug=p.slug,
|
||||
list_on_landing=p.list_on_landing,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,27 +1,169 @@
|
||||
"""Public/anonymous endpoints (no auth required)."""
|
||||
from datetime import datetime
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
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.booking import (
|
||||
AnonymousBookingCreate,
|
||||
AvailabilityCheck,
|
||||
BookingResponse,
|
||||
ConflictingBooking,
|
||||
)
|
||||
from app.schemas.property import PropertyResponse
|
||||
from app.schemas.space import SpaceResponse
|
||||
from app.services.booking_service import validate_booking_rules
|
||||
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)],
|
||||
@@ -33,43 +175,68 @@ def list_public_properties(
|
||||
.order_by(Property.name)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for p in properties:
|
||||
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
|
||||
result.append(PropertyResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
address=p.address,
|
||||
is_public=p.is_public,
|
||||
is_active=p.is_active,
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
))
|
||||
return result
|
||||
return [_property_to_response(db, p) for p in properties]
|
||||
|
||||
|
||||
@router.get("/properties/{property_id}/spaces", response_model=list[SpaceResponse])
|
||||
@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(
|
||||
property_id: int,
|
||||
slug_or_id: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> list[SpaceResponse]:
|
||||
"""List spaces of a public property (no auth required)."""
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
if not prop:
|
||||
prop = _resolve_property(db, slug_or_id)
|
||||
if not _is_visible(prop):
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
if not prop.is_public:
|
||||
raise HTTPException(status_code=403, detail="Property is private")
|
||||
|
||||
spaces = (
|
||||
db.query(Space)
|
||||
.filter(Space.property_id == property_id, Space.is_active == True) # noqa: E712
|
||||
.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,
|
||||
@@ -81,36 +248,13 @@ def check_public_availability(
|
||||
space = db.query(Space).filter(Space.id == 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="Property is private")
|
||||
if not _resolve_visible_space_property(db, space):
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
|
||||
# Find conflicting bookings
|
||||
conflicts = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.space_id == space_id,
|
||||
Booking.status.in_(["approved", "pending"]),
|
||||
or_(
|
||||
and_(
|
||||
Booking.start_datetime <= start_datetime,
|
||||
Booking.end_datetime > start_datetime,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime < end_datetime,
|
||||
Booking.end_datetime >= end_datetime,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime >= start_datetime,
|
||||
Booking.end_datetime <= end_datetime,
|
||||
),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
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")
|
||||
@@ -126,10 +270,13 @@ def check_public_availability(
|
||||
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=b.user.full_name if b.user else (b.guest_name or "Anonymous"),
|
||||
title=b.title,
|
||||
user_name="",
|
||||
title="",
|
||||
status=b.status,
|
||||
start_datetime=b.start_datetime,
|
||||
end_datetime=b.end_datetime,
|
||||
@@ -144,9 +291,12 @@ def check_public_availability(
|
||||
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:
|
||||
@@ -164,18 +314,36 @@ def create_anonymous_booking(
|
||||
if data.end_datetime <= data.start_datetime:
|
||||
raise HTTPException(status_code=400, detail="End time must be after start time")
|
||||
|
||||
# Check for overlapping approved bookings
|
||||
overlapping = db.query(Booking).filter(
|
||||
Booking.space_id == data.space_id,
|
||||
Booking.status == "approved",
|
||||
and_(
|
||||
Booking.start_datetime < data.end_datetime,
|
||||
Booking.end_datetime > data.start_datetime,
|
||||
),
|
||||
# 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,
|
||||
@@ -195,8 +363,9 @@ def create_anonymous_booking(
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Notify property managers
|
||||
if space.property_id:
|
||||
# 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()
|
||||
@@ -224,4 +393,16 @@ def create_anonymous_booking(
|
||||
{"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)
|
||||
|
||||
Reference in New Issue
Block a user