Files
space-booking/backend/app/api/properties.py
Claude Agent a15951c645 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>
2026-07-11 19:04:58 +00:00

623 lines
22 KiB
Python

"""Property management endpoints."""
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 (
get_current_admin,
get_current_manager_or_superadmin,
get_current_user,
get_db,
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
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.property import (
PropertyAccessCreate,
PropertyAccessResponse,
PropertyCreate,
PropertyManagerInfo,
PropertyResponse,
PropertySettingsResponse,
PropertySettingsUpdate,
PropertyStatusUpdate,
PropertyUpdate,
PropertyWithSpaces,
)
from app.schemas.space import SpaceResponse
from app.services.audit_service import log_action
def _get_property_managers(db: Session, property_id: int) -> list[PropertyManagerInfo]:
"""Get manager info for a property."""
managers = (
db.query(User)
.join(PropertyManager, PropertyManager.user_id == User.id)
.filter(PropertyManager.property_id == property_id)
.all()
)
return [
PropertyManagerInfo(user_id=m.id, full_name=m.full_name, email=m.email)
for m in managers
]
router = APIRouter(prefix="/properties", tags=["properties"])
manager_router = APIRouter(prefix="/manager/properties", tags=["properties-manager"])
admin_router = APIRouter(prefix="/admin/properties", tags=["properties-admin"])
# === User-facing endpoints ===
@router.get("", response_model=list[PropertyResponse])
def list_properties(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
managed_only: bool = False,
) -> list[PropertyResponse]:
"""List visible properties based on user role.
Query params:
- managed_only: If true, managers only see properties they manage (for management pages)
"""
if current_user and current_user.role in ("admin", "superadmin"):
# Superadmin sees all
properties = db.query(Property).filter(Property.is_active == True).order_by(Property.name).all() # noqa: E712
elif current_user and current_user.role == "manager":
# Manager sees managed properties (+ public if not managed_only)
managed_ids = get_manager_property_ids(db, current_user.id)
if managed_only:
properties = (
db.query(Property)
.filter(
Property.is_active == True, # noqa: E712
Property.id.in_(managed_ids),
)
.order_by(Property.name)
.all()
)
else:
properties = (
db.query(Property)
.filter(
Property.is_active == True, # noqa: E712
(Property.is_public == True) | (Property.id.in_(managed_ids)), # noqa: E712
)
.order_by(Property.name)
.all()
)
elif current_user:
# Regular user sees public + explicitly granted
from app.core.permissions import get_user_accessible_property_ids
accessible_ids = get_user_accessible_property_ids(db, current_user.id)
properties = (
db.query(Property)
.filter(Property.is_active == True, Property.id.in_(accessible_ids)) # noqa: E712
.order_by(Property.name)
.all()
)
else:
# Anonymous sees only public
properties = (
db.query(Property)
.filter(Property.is_public == True, Property.is_active == True) # noqa: E712
.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,
managers=_get_property_managers(db, p.id),
slug=p.slug,
list_on_landing=p.list_on_landing,
))
return result
@router.get("/{property_id}", response_model=PropertyWithSpaces)
def get_property(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
) -> PropertyWithSpaces:
"""Get property detail with visibility check."""
verify_property_access(db, current_user, property_id)
prop = db.query(Property).filter(Property.id == property_id).first()
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,
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,
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,
)
@router.get("/{property_id}/spaces", response_model=list[SpaceResponse])
def get_property_spaces(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
include_inactive: bool = False,
) -> list[SpaceResponse]:
"""List spaces in a property."""
verify_property_access(db, current_user, property_id)
query = db.query(Space).filter(Space.property_id == property_id)
# Managers/admins can see inactive spaces, regular users cannot
is_admin_like = current_user and current_user.role in ("admin", "superadmin", "manager")
if not (include_inactive and is_admin_like):
query = query.filter(Space.is_active == True) # noqa: E712
spaces = query.order_by(Space.name).all()
return [SpaceResponse.model_validate(s) for s in spaces]
# === Manager endpoints ===
@manager_router.post("", response_model=PropertyResponse, status_code=status.HTTP_201_CREATED)
def create_property(
data: PropertyCreate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Create a property. Creator becomes manager."""
prop = Property(
name=data.name,
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)
# Creator becomes manager
pm = PropertyManager(property_id=prop.id, user_id=current_user.id)
db.add(pm)
db.commit()
log_action(
db=db,
action="property_created",
user_id=current_user.id,
target_type="property",
target_id=prop.id,
details={"name": prop.name},
)
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=0,
managers=_get_property_managers(db, prop.id),
slug=prop.slug,
list_on_landing=prop.list_on_landing,
)
@manager_router.put("/{property_id}", response_model=PropertyResponse)
def update_property(
property_id: int,
data: PropertyUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Update a property (ownership check)."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
if data.name is not None:
prop.name = data.name
if data.description is not None:
prop.description = data.description
if data.address is not None:
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
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,
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,
managers=_get_property_managers(db, prop.id),
slug=prop.slug,
list_on_landing=prop.list_on_landing,
)
@manager_router.patch("/{property_id}/status", response_model=PropertyResponse)
def update_property_status(
property_id: int,
data: PropertyStatusUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Activate/deactivate a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
prop.is_active = data.is_active
db.commit()
db.refresh(prop)
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,
managers=_get_property_managers(db, prop.id),
slug=prop.slug,
list_on_landing=prop.list_on_landing,
)
@manager_router.delete("/{property_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_property(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> None:
"""Delete a property (only if it has no active bookings)."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
from app.models.booking import Booking
# Check for active bookings (pending or approved) in this property's spaces
space_ids = [s.id for s in db.query(Space).filter(Space.property_id == property_id).all()]
if space_ids:
active_bookings = (
db.query(Booking)
.filter(
Booking.space_id.in_(space_ids),
Booking.status.in_(["pending", "approved"]),
)
.count()
)
if active_bookings > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete property with {active_bookings} active booking(s). Cancel or reject them first.",
)
# Delete related data
db.query(PropertyManager).filter(PropertyManager.property_id == property_id).delete()
db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).delete()
db.query(PropertySettings).filter(PropertySettings.property_id == property_id).delete()
# Unlink spaces (set property_id to None) rather than deleting them
db.query(Space).filter(Space.property_id == property_id).update({"property_id": None})
db.delete(prop)
db.commit()
log_action(
db=db,
action="property_deleted",
user_id=current_user.id,
target_type="property",
target_id=property_id,
details={"name": prop.name},
)
@manager_router.get("/{property_id}/access", response_model=list[PropertyAccessResponse])
def list_property_access(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> list[PropertyAccessResponse]:
"""List access grants for a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
accesses = db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).all()
result = []
for a in accesses:
user_name = None
user_email = None
org_name = None
if a.user_id:
u = db.query(User).filter(User.id == a.user_id).first()
if u:
user_name = u.full_name
user_email = u.email
if a.organization_id:
org = db.query(Organization).filter(Organization.id == a.organization_id).first()
if org:
org_name = org.name
result.append(PropertyAccessResponse(
id=a.id,
property_id=a.property_id,
user_id=a.user_id,
organization_id=a.organization_id,
granted_by=a.granted_by,
user_name=user_name,
user_email=user_email,
organization_name=org_name,
created_at=a.created_at,
))
return result
@manager_router.post("/{property_id}/access", response_model=PropertyAccessResponse, status_code=status.HTTP_201_CREATED)
def grant_property_access(
property_id: int,
data: PropertyAccessCreate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyAccessResponse:
"""Grant access to a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
if not data.user_id and not data.organization_id:
raise HTTPException(status_code=400, detail="Must provide user_id or organization_id")
access = PropertyAccess(
property_id=property_id,
user_id=data.user_id,
organization_id=data.organization_id,
granted_by=current_user.id,
)
db.add(access)
db.commit()
db.refresh(access)
user_name = None
user_email = None
org_name = None
if access.user_id:
u = db.query(User).filter(User.id == access.user_id).first()
if u:
user_name = u.full_name
user_email = u.email
if access.organization_id:
org = db.query(Organization).filter(Organization.id == access.organization_id).first()
if org:
org_name = org.name
return PropertyAccessResponse(
id=access.id,
property_id=access.property_id,
user_id=access.user_id,
organization_id=access.organization_id,
granted_by=access.granted_by,
user_name=user_name,
user_email=user_email,
organization_name=org_name,
created_at=access.created_at,
)
@manager_router.delete("/{property_id}/access/{access_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_property_access(
property_id: int,
access_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> None:
"""Revoke access to a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
access = db.query(PropertyAccess).filter(
PropertyAccess.id == access_id,
PropertyAccess.property_id == property_id,
).first()
if not access:
raise HTTPException(status_code=404, detail="Access grant not found")
db.delete(access)
db.commit()
@manager_router.get("/{property_id}/settings", response_model=PropertySettingsResponse)
def get_property_settings(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertySettingsResponse:
"""Get property settings."""
verify_property_access(db, current_user, property_id, require_manager=True)
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
if not ps:
# Create default settings
ps = PropertySettings(property_id=property_id, require_approval=True)
db.add(ps)
db.commit()
db.refresh(ps)
return PropertySettingsResponse.model_validate(ps)
@manager_router.put("/{property_id}/settings", response_model=PropertySettingsResponse)
def update_property_settings(
property_id: int,
data: PropertySettingsUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertySettingsResponse:
"""Update property settings."""
verify_property_access(db, current_user, property_id, require_manager=True)
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
if not ps:
ps = PropertySettings(property_id=property_id)
db.add(ps)
db.commit()
db.refresh(ps)
for field in data.model_fields:
value = getattr(data, field)
if value is not None or field == "require_approval":
setattr(ps, field, value)
db.commit()
db.refresh(ps)
return PropertySettingsResponse.model_validate(ps)
# === Superadmin endpoints ===
@admin_router.get("", response_model=list[PropertyResponse])
def admin_list_all_properties(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
include_inactive: bool = Query(False),
) -> list[PropertyResponse]:
"""Superadmin: list all properties."""
query = db.query(Property)
if not include_inactive:
query = query.filter(Property.is_active == True) # noqa: E712
properties = query.order_by(Property.name).all()
result = []
for p in properties:
space_count = db.query(Space).filter(Space.property_id == p.id).count()
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,
managers=_get_property_managers(db, p.id),
slug=p.slug,
list_on_landing=p.list_on_landing,
))
return result
@admin_router.post("/{property_id}/managers", status_code=status.HTTP_201_CREATED)
def assign_property_manager(
property_id: int,
user_id: int = Query(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
) -> dict:
"""Superadmin: assign a manager to a property."""
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user_id,
).first()
if existing:
raise HTTPException(status_code=400, detail="User is already a manager of this property")
pm = PropertyManager(property_id=property_id, user_id=user_id)
db.add(pm)
# Ensure user has manager role
if user.role == "user":
user.role = "manager"
db.commit()
return {"message": f"User {user.full_name} assigned as manager of {prop.name}"}
@admin_router.delete("/{property_id}/managers/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_property_manager(
property_id: int,
user_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
) -> None:
"""Superadmin: remove a manager from a property."""
pm = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user_id,
).first()
if not pm:
raise HTTPException(status_code=404, detail="Manager assignment not found")
db.delete(pm)
db.commit()