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

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