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>
99 lines
3.1 KiB
Python
99 lines
3.1 KiB
Python
"""Slug generation and validation for public property booking links.
|
|
|
|
Single source of truth for slug rules shared across the API, migrations,
|
|
and the demo seed script.
|
|
"""
|
|
import re
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
# Paths/segments that must never be shadowed by a property slug.
|
|
RESERVED_SLUGS = {
|
|
"api", "admin", "book", "login", "register", "dashboard", "properties",
|
|
"public", "spaces", "organization", "users", "verify", "settings",
|
|
"reports", "confidentialitate", "termeni",
|
|
}
|
|
|
|
# Stable, deterministic slug reserved for the public demo property.
|
|
DEMO_SLUG = "proprietate-demo"
|
|
|
|
# Format accepted for user-supplied (PATCH) slugs: lowercase alnum + hyphen,
|
|
# 3-64 chars total, no leading/trailing/double hyphen (single run of `-`
|
|
# separating alnum groups), enforced by the regex plus extra checks below.
|
|
SLUG_REGEX = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,62}[a-z0-9])$")
|
|
|
|
# RO diacritics -> ASCII transliteration map.
|
|
_RO_CHAR_MAP = {
|
|
"ă": "a", "â": "a", "î": "i", "ș": "s", "ş": "s", "ț": "t", "ţ": "t",
|
|
"Ă": "a", "Â": "a", "Î": "i", "Ș": "s", "Ş": "s", "Ț": "t", "Ţ": "t",
|
|
}
|
|
|
|
_NON_ALNUM_RUN = re.compile(r"[^a-z0-9]+")
|
|
|
|
|
|
def slugify(name: str) -> str:
|
|
"""Transliterate RO diacritics, lowercase, and kebab-case a name.
|
|
|
|
Non `[a-z0-9]` runs collapse to a single `-`; leading/trailing/double
|
|
hyphens are stripped. Pure stdlib, no external dependency.
|
|
"""
|
|
if not name:
|
|
return ""
|
|
out = []
|
|
for ch in name:
|
|
out.append(_RO_CHAR_MAP.get(ch, ch))
|
|
text = "".join(out).lower()
|
|
text = _NON_ALNUM_RUN.sub("-", text)
|
|
return text.strip("-")
|
|
|
|
|
|
def _is_all_digit(slug: str) -> bool:
|
|
return slug.isdigit()
|
|
|
|
|
|
def validate_slug(slug: str) -> bool:
|
|
"""Validate a user-supplied slug against the PATCH format rules.
|
|
|
|
Rules: `[a-z0-9-]{3,64}`, no leading/trailing/double hyphen, not
|
|
all-digit (would shadow numeric property IDs), not reserved.
|
|
"""
|
|
if not slug:
|
|
return False
|
|
if len(slug) < 3 or len(slug) > 64:
|
|
return False
|
|
if not SLUG_REGEX.match(slug):
|
|
return False
|
|
if "--" in slug:
|
|
return False
|
|
if _is_all_digit(slug):
|
|
return False
|
|
if slug in RESERVED_SLUGS:
|
|
return False
|
|
return True
|
|
|
|
|
|
def generate_unique_slug(db: Session, name: str, exclude_id: int | None = None) -> str:
|
|
"""Generate a unique, safe slug for a property from its name.
|
|
|
|
Idempotent: given the same name and existing DB state, always returns
|
|
the same result. Empty/all-digit/reserved base slugs get a `p-` prefix.
|
|
Collisions are resolved by appending `-2`, `-3`, etc.
|
|
"""
|
|
from app.models.property import Property
|
|
|
|
base = slugify(name)
|
|
if not base or _is_all_digit(base) or base in RESERVED_SLUGS:
|
|
base = f"p-{base}" if base else "p"
|
|
|
|
candidate = base
|
|
suffix = 2
|
|
while True:
|
|
if candidate not in RESERVED_SLUGS:
|
|
query = db.query(Property).filter(Property.slug == candidate)
|
|
if exclude_id is not None:
|
|
query = query.filter(Property.id != exclude_id)
|
|
if query.first() is None:
|
|
return candidate
|
|
candidate = f"{base}-{suffix}"
|
|
suffix += 1
|