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:
Claude Agent
2026-07-11 19:04:58 +00:00
parent e38d78ecb8
commit a15951c645
27 changed files with 3311 additions and 375 deletions

View File

@@ -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