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

@@ -0,0 +1,119 @@
"""Tests for the idempotent property-slug migration script.
Builds a temporary SQLite DB with the OLD `properties` schema (no slug /
list_on_landing columns) via raw DDL, points the migration module at it,
and verifies the migration adds the columns/index and deterministically
backfills RO-transliterated, collision-safe slugs -- twice, with no error
and no drift the second time.
"""
import sqlite3
import pytest
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker
import migrate_add_property_slug as migrate_mod
def _create_old_schema_db(path: str) -> None:
"""Create a properties table matching the pre-slug schema and seed rows."""
conn = sqlite3.connect(path)
conn.execute(
"""
CREATE TABLE properties (
id INTEGER PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR,
address VARCHAR,
is_public BOOLEAN NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL
)
"""
)
rows = [
("Clădirea Centrală", "2024-01-01T00:00:00"),
# Same (accented) name again -> must collide and get a -2 suffix.
("Clădirea Centrală", "2024-01-02T00:00:00"),
("Sediul Ș.ț Test", "2024-01-03T00:00:00"),
]
for name, created_at in rows:
conn.execute(
"INSERT INTO properties (name, description, address, is_public, is_active, created_at) "
"VALUES (?, NULL, NULL, 1, 1, ?)",
(name, created_at),
)
conn.commit()
conn.close()
@pytest.fixture
def temp_db_path(tmp_path) -> str:
path = tmp_path / "migrate_test.db"
_create_old_schema_db(str(path))
return str(path)
@pytest.fixture
def patched_engine(monkeypatch: pytest.MonkeyPatch, temp_db_path: str):
"""Point the migration module's module-level engine/SessionLocal at the temp DB."""
engine = create_engine(f"sqlite:///{temp_db_path}", connect_args={"check_same_thread": False})
session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
monkeypatch.setattr(migrate_mod, "engine", engine)
monkeypatch.setattr(migrate_mod, "SessionLocal", session_local)
yield engine
engine.dispose()
def _fetch_rows(engine) -> list[tuple]:
with engine.connect() as conn:
return conn.execute(text("SELECT id, name, slug FROM properties ORDER BY id")).fetchall()
def test_migration_adds_columns_and_unique_index(patched_engine) -> None:
migrate_mod.migrate()
inspector = inspect(patched_engine)
columns = {c["name"] for c in inspector.get_columns("properties")}
assert "slug" in columns
assert "list_on_landing" in columns
indexes = inspector.get_indexes("properties")
slug_indexes = [ix for ix in indexes if "slug" in ix["column_names"]]
assert slug_indexes, "expected an index on slug"
assert any(ix["unique"] for ix in slug_indexes), "slug index must be unique"
def test_migration_backfills_deterministic_ro_slugs_with_collision_suffix(patched_engine) -> None:
migrate_mod.migrate()
rows = _fetch_rows(patched_engine)
assert len(rows) == 3
slugs = [r[2] for r in rows]
# RO diacritic transliteration: ă->a, â->a, î->i, ș/ş->s, ț/ţ->t.
assert slugs[0] == "cladirea-centrala"
# Second property has the identical (transliterated) name -> collision -> -2 suffix.
assert slugs[1] == "cladirea-centrala-2"
assert slugs[2] == "sediul-s-t-test"
# All slugs non-null and unique.
assert all(s for s in slugs)
assert len(set(slugs)) == len(slugs)
def test_migration_is_idempotent_and_slugs_stable_on_rerun(patched_engine) -> None:
migrate_mod.migrate()
first = _fetch_rows(patched_engine)
# Running a second time must not raise and must not change existing slugs
# or column/index state.
migrate_mod.migrate()
second = _fetch_rows(patched_engine)
assert first == second
inspector = inspect(patched_engine)
columns = {c["name"] for c in inspector.get_columns("properties")}
assert "slug" in columns
assert "list_on_landing" in columns