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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user