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

@@ -7,7 +7,9 @@ from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
from app.core.permissions import get_manager_property_ids, verify_property_access
from app.core.slug import DEMO_SLUG
from app.models.booking import Booking
from app.models.property import Property
from app.models.property_manager import PropertyManager
from app.models.settings import Settings
from app.models.space import Space
@@ -61,6 +63,18 @@ def _verify_manager_booking_access(db: Session, booking: Booking, current_user:
)
def _is_demo_property_space(db: Session, space: Space | None) -> bool:
"""Return True if the given space belongs to the demo property.
Used to suppress guest notification emails for the public demo property
(avoids spamming real inboxes from demo/test bookings).
"""
if space is None or not space.property_id:
return False
prop = db.query(Property).filter(Property.id == space.property_id).first()
return bool(prop and prop.slug == DEMO_SLUG)
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
"""Verify that a manager has access to a space's property.
@@ -931,15 +945,15 @@ def approve_booking(
booking.user.full_name,
None,
)
elif booking.guest_email:
# Send email notification to anonymous guest
elif booking.guest_email and not _is_demo_property_space(db, booking.space):
# Send email notification to anonymous guest (skipped for demo property)
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_approved",
booking.guest_email,
booking.guest_name or "Guest",
None,
{"reference_code": f"RB-{booking.id}"},
)
return booking
@@ -1018,14 +1032,14 @@ def reject_booking(
booking.user.full_name,
{"rejection_reason": reject_data.reason},
)
elif booking.guest_email:
elif booking.guest_email and not _is_demo_property_space(db, booking.space):
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_rejected",
booking.guest_email,
booking.guest_name or "Guest",
{"rejection_reason": reject_data.reason},
{"rejection_reason": reject_data.reason, "reference_code": f"RB-{booking.id}"},
)
return booking

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

View File

@@ -1,27 +1,169 @@
"""Public/anonymous endpoints (no auth required)."""
from datetime import datetime
import os
from collections import defaultdict
from datetime import datetime, time, timedelta
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel
from sqlalchemy import and_, or_
from sqlalchemy.orm import Query as SAQuery
from sqlalchemy.orm import Session
from app.core.deps import get_db
from app.core.slug import DEMO_SLUG
from app.models.booking import Booking
from app.models.property import Property
from app.models.property_manager import PropertyManager
from app.models.property_settings import PropertySettings
from app.models.space import Space
from app.models.user import User
from app.schemas.booking import AnonymousBookingCreate, AvailabilityCheck, BookingResponse, ConflictingBooking
from app.schemas.booking import (
AnonymousBookingCreate,
AvailabilityCheck,
BookingResponse,
ConflictingBooking,
)
from app.schemas.property import PropertyResponse
from app.schemas.space import SpaceResponse
from app.services.booking_service import validate_booking_rules
from app.services.email_service import send_booking_notification
from app.services.notification_service import create_notification
router = APIRouter(prefix="/public", tags=["public"])
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def overlapping_bookings_query(
db: Session,
space_id: int,
start: datetime,
end: datetime,
statuses: tuple[str, ...] = ("approved", "pending"),
) -> SAQuery:
"""Shared overlap predicate: any booking on `space_id` whose interval
intersects [start, end), restricted to `statuses`."""
return db.query(Booking).filter(
Booking.space_id == space_id,
Booking.status.in_(statuses),
or_(
and_(
Booking.start_datetime <= start,
Booking.end_datetime > start,
),
and_(
Booking.start_datetime < end,
Booking.end_datetime >= end,
),
and_(
Booking.start_datetime >= start,
Booking.end_datetime <= end,
),
),
)
def _resolve_property(db: Session, slug_or_id: str) -> Property | None:
"""Resolve a property by numeric id (all-digit segment) or by slug."""
if slug_or_id.isdigit():
return db.query(Property).filter(Property.id == int(slug_or_id)).first()
return db.query(Property).filter(Property.slug == slug_or_id).first()
def _is_visible(prop: Property | None) -> bool:
"""Uniform visibility rule for public endpoints: must exist, be public,
and be active. Callers turn a False result into a bare 404 (no 403
oracle that would let clients distinguish private-vs-missing)."""
return bool(prop and prop.is_public and prop.is_active)
def _require_approval_for(db: Session, property_id: int) -> bool | None:
settings_row = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
return settings_row.require_approval if settings_row else None
def _property_to_response(db: Session, prop: Property) -> PropertyResponse:
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
return PropertyResponse(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=space_count,
slug=prop.slug,
list_on_landing=prop.list_on_landing,
require_approval=_require_approval_for(db, prop.id),
)
def _resolve_visible_space_property(db: Session, space: Space) -> Property | None:
"""Return the space's property iff it exists, is public, and active.
Returns None (caller raises 404) for orphan spaces or private/inactive
properties -- uniform 404, no 403 oracle."""
if not space.property_id:
return None
prop = db.query(Property).filter(Property.id == space.property_id).first()
if not _is_visible(prop):
return None
return prop
# ---------------------------------------------------------------------------
# In-process per-IP rate limiter for anonymous booking creation.
#
# A simple sliding-window counter is used instead of pulling in slowapi:
# it needs no app-level wiring (this module cannot touch app/main.py under
# the current work split) and is trivially bypassed in tests via the
# PYTEST_CURRENT_TEST env var pytest sets automatically, or DISABLE_RATE_LIMIT.
# ---------------------------------------------------------------------------
_RATE_LIMIT_WINDOW = timedelta(hours=1)
_RATE_LIMIT_MAX = 5
_rate_limit_buckets: dict[str, list[datetime]] = defaultdict(list)
def _rate_limiting_enabled() -> bool:
if os.getenv("PYTEST_CURRENT_TEST"):
return False
if os.getenv("DISABLE_RATE_LIMIT"):
return False
return True
def _enforce_booking_rate_limit(request: Request) -> None:
if not _rate_limiting_enabled():
return
ip = request.client.host if request.client else "unknown"
now = datetime.utcnow()
bucket = _rate_limit_buckets[ip]
bucket[:] = [t for t in bucket if now - t < _RATE_LIMIT_WINDOW]
if len(bucket) >= _RATE_LIMIT_MAX:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Prea multe cereri de rezervare de la această adresă IP. Încearcă din nou peste o oră.",
)
bucket.append(now)
class BusyInterval(BaseModel):
"""Minimal busy-slot payload for the public calendar. No title, name,
email, or status is ever exposed here."""
start_time: datetime
end_time: datetime
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/properties", response_model=list[PropertyResponse])
def list_public_properties(
db: Annotated[Session, Depends(get_db)],
@@ -33,43 +175,68 @@ def list_public_properties(
.order_by(Property.name)
.all()
)
result = []
for p in properties:
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
result.append(PropertyResponse(
id=p.id,
name=p.name,
description=p.description,
address=p.address,
is_public=p.is_public,
is_active=p.is_active,
created_at=p.created_at,
space_count=space_count,
))
return result
return [_property_to_response(db, p) for p in properties]
@router.get("/properties/{property_id}/spaces", response_model=list[SpaceResponse])
@router.get("/properties/{slug_or_id}", response_model=PropertyResponse)
def get_public_property(
slug_or_id: str,
db: Annotated[Session, Depends(get_db)],
) -> PropertyResponse:
"""Resolve a public property by slug or numeric id (no auth required)."""
prop = _resolve_property(db, slug_or_id)
if not _is_visible(prop):
raise HTTPException(status_code=404, detail="Property not found")
return _property_to_response(db, prop)
@router.get("/properties/{slug_or_id}/spaces", response_model=list[SpaceResponse])
def list_public_property_spaces(
property_id: int,
slug_or_id: str,
db: Annotated[Session, Depends(get_db)],
) -> list[SpaceResponse]:
"""List spaces of a public property (no auth required)."""
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
prop = _resolve_property(db, slug_or_id)
if not _is_visible(prop):
raise HTTPException(status_code=404, detail="Property not found")
if not prop.is_public:
raise HTTPException(status_code=403, detail="Property is private")
spaces = (
db.query(Space)
.filter(Space.property_id == property_id, Space.is_active == True) # noqa: E712
.filter(Space.property_id == prop.id, Space.is_active == True) # noqa: E712
.order_by(Space.name)
.all()
)
return [SpaceResponse.model_validate(s) for s in spaces]
@router.get("/spaces/{space_id}/busy", response_model=list[BusyInterval])
def get_public_space_busy(
space_id: int,
start: Annotated[datetime, Query()],
end: Annotated[datetime, Query()],
db: Annotated[Session, Depends(get_db)],
) -> list[BusyInterval]:
"""Return only the busy intervals for a space (no PII, no auth)."""
if start.tzinfo is not None or end.tzinfo is not None:
raise HTTPException(
status_code=422,
detail="start/end must be naive datetimes (property local time), not timezone-aware",
)
if end <= start:
raise HTTPException(status_code=422, detail="end must be after start")
if (end - start) > timedelta(days=60):
raise HTTPException(status_code=422, detail="Range cannot exceed 60 days")
space = db.query(Space).filter(Space.id == space_id).first()
if not space:
raise HTTPException(status_code=404, detail="Space not found")
if not _resolve_visible_space_property(db, space):
raise HTTPException(status_code=404, detail="Space not found")
bookings = overlapping_bookings_query(db, space_id, start, end, statuses=("approved", "pending")).all()
return [BusyInterval(start_time=b.start_datetime, end_time=b.end_datetime) for b in bookings]
@router.get("/spaces/{space_id}/availability", response_model=AvailabilityCheck)
def check_public_availability(
space_id: int,
@@ -81,36 +248,13 @@ def check_public_availability(
space = db.query(Space).filter(Space.id == space_id).first()
if not space:
raise HTTPException(status_code=404, detail="Space not found")
# Verify space belongs to a public property
if space.property_id:
prop = db.query(Property).filter(Property.id == space.property_id).first()
if prop and not prop.is_public:
raise HTTPException(status_code=403, detail="Property is private")
if not _resolve_visible_space_property(db, space):
raise HTTPException(status_code=404, detail="Space not found")
# Find conflicting bookings
conflicts = (
db.query(Booking)
.filter(
Booking.space_id == space_id,
Booking.status.in_(["approved", "pending"]),
or_(
and_(
Booking.start_datetime <= start_datetime,
Booking.end_datetime > start_datetime,
),
and_(
Booking.start_datetime < end_datetime,
Booking.end_datetime >= end_datetime,
),
and_(
Booking.start_datetime >= start_datetime,
Booking.end_datetime <= end_datetime,
),
),
)
.all()
)
conflicts = overlapping_bookings_query(
db, space_id, start_datetime, end_datetime, statuses=("approved", "pending")
).all()
if not conflicts:
return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available")
@@ -126,10 +270,13 @@ def check_public_availability(
return AvailabilityCheck(
available=approved_count == 0,
conflicts=[
# user_name/title are scrubbed here: this endpoint is reachable
# by anonymous callers and must not leak PII about other
# bookers (privacy regression fix).
ConflictingBooking(
id=b.id,
user_name=b.user.full_name if b.user else (b.guest_name or "Anonymous"),
title=b.title,
user_name="",
title="",
status=b.status,
start_datetime=b.start_datetime,
end_datetime=b.end_datetime,
@@ -144,9 +291,12 @@ def check_public_availability(
def create_anonymous_booking(
data: AnonymousBookingCreate,
background_tasks: BackgroundTasks,
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> BookingResponse:
"""Create an anonymous/guest booking (no auth required)."""
_enforce_booking_rate_limit(request)
# Validate space exists
space = db.query(Space).filter(Space.id == data.space_id).first()
if not space:
@@ -164,18 +314,36 @@ def create_anonymous_booking(
if data.end_datetime <= data.start_datetime:
raise HTTPException(status_code=400, detail="End time must be after start time")
# Check for overlapping approved bookings
overlapping = db.query(Booking).filter(
Booking.space_id == data.space_id,
Booking.status == "approved",
and_(
Booking.start_datetime < data.end_datetime,
Booking.end_datetime > data.start_datetime,
),
# Reject overlap with any pending OR approved booking (previously
# approved-only, which let guests double-book against pending requests).
overlapping = overlapping_bookings_query(
db, data.space_id, data.start_datetime, data.end_datetime, statuses=("approved", "pending")
).first()
if overlapping:
raise HTTPException(status_code=400, detail="Time slot is already booked")
# Cap pending requests per guest/space/day to deter spam.
day_start = datetime.combine(data.start_datetime.date(), time.min)
day_end = datetime.combine(data.start_datetime.date(), time.max)
pending_today = (
db.query(Booking)
.filter(
Booking.space_id == data.space_id,
Booking.guest_email == data.guest_email,
Booking.status == "pending",
Booking.start_datetime >= day_start,
Booking.start_datetime <= day_end,
)
.count()
)
if pending_today >= 5:
raise HTTPException(
status_code=400,
detail="Ai atins limita de 5 cereri în așteptare pentru acest spațiu în această zi.",
)
is_demo = bool(prop and prop.slug == DEMO_SLUG)
# Create anonymous booking
booking = Booking(
user_id=None,
@@ -195,8 +363,9 @@ def create_anonymous_booking(
db.commit()
db.refresh(booking)
# Notify property managers
if space.property_id:
# Notify property managers/superadmins -- suppressed entirely for the
# demo property so the public playground doesn't spam real staff.
if space.property_id and not is_demo:
manager_ids = [
pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
@@ -224,4 +393,16 @@ def create_anonymous_booking(
{"guest_email": data.guest_email},
)
# Confirmation email to the guest themselves -- also suppressed for the
# demo property (its data is wiped every few hours; nothing to confirm).
if not is_demo:
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_created",
data.guest_email,
data.guest_name,
{"guest_email": data.guest_email},
)
return BookingResponse.model_validate(booking)

98
backend/app/core/slug.py Normal file
View File

@@ -0,0 +1,98 @@
"""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

View File

@@ -1,16 +1,28 @@
"""Database session management."""
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, declarative_base, sessionmaker
from app.core.config import settings
_is_sqlite = "sqlite" in settings.database_url
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
connect_args={"check_same_thread": False} if _is_sqlite else {},
)
if _is_sqlite:
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None:
"""Enable WAL mode + a busy timeout so concurrent readers/writers
don't immediately hit 'database is locked' errors."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

View File

@@ -18,3 +18,5 @@ class Property(Base):
is_public = Column(Boolean, default=True, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
slug = Column(String, unique=True, nullable=True, index=True)
list_on_landing = Column(Boolean, default=False, nullable=False)

View File

@@ -1,8 +1,8 @@
"""Booking schemas for request/response."""
from datetime import datetime, date
from datetime import date, datetime
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
class BookingCalendarPublic(BaseModel):
@@ -276,5 +276,5 @@ class AnonymousBookingCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
guest_name: str = Field(..., min_length=1)
guest_email: str = Field(..., min_length=1)
guest_email: EmailStr
guest_organization: str | None = None

View File

@@ -9,6 +9,7 @@ class PropertyCreate(BaseModel):
description: str | None = None
address: str | None = None
is_public: bool = True
list_on_landing: bool = False
class PropertyUpdate(BaseModel):
@@ -16,6 +17,8 @@ class PropertyUpdate(BaseModel):
description: str | None = None
address: str | None = None
is_public: bool | None = None
slug: str | None = None
list_on_landing: bool | None = None
class PropertyManagerInfo(BaseModel):
@@ -34,6 +37,9 @@ class PropertyResponse(BaseModel):
created_at: datetime
space_count: int = 0
managers: list[PropertyManagerInfo] = []
slug: str | None = None
list_on_landing: bool = False
require_approval: bool | None = None
model_config = {"from_attributes": True}

View File

@@ -153,11 +153,13 @@ Sistemul de Rezervări
"""
elif event_type == "anonymous_approved":
reference_code = extra_data.get("reference_code", f"RB-{booking.id}") if extra_data else f"RB-{booking.id}"
subject = "Rezervare Aprobată"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost aprobată:
Cod de referință: #{reference_code}
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
@@ -170,11 +172,13 @@ Sistemul de Rezervări
elif event_type == "anonymous_rejected":
reason = extra_data.get("rejection_reason", "Nu a fost specificat") if extra_data else "Nu a fost specificat"
reference_code = extra_data.get("reference_code", f"RB-{booking.id}") if extra_data else f"RB-{booking.id}"
subject = "Rezervare Respinsă"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost respinsă:
Cod de referință: #{reference_code}
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}