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