Files
space-booking/backend/app/api/public.py
Claude Agent e21cf03a16 feat: add multi-tenant system with properties, organizations, and public booking
Implement complete multi-property architecture:
- Properties (groups of spaces) with public/private visibility
- Property managers (many-to-many) with role-based permissions
- Organizations with member management
- Anonymous/guest booking support via public API (/api/public/*)
- Property-scoped spaces, bookings, and settings
- Frontend: property selector, organization management, public booking views
- Migration script and updated seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:17:21 +00:00

228 lines
8.2 KiB
Python

"""Public/anonymous endpoints (no auth required)."""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from app.core.deps import get_db
from app.models.booking import Booking
from app.models.property import Property
from app.models.property_manager import PropertyManager
from app.models.space import Space
from app.models.user import User
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"])
@router.get("/properties", response_model=list[PropertyResponse])
def list_public_properties(
db: Annotated[Session, Depends(get_db)],
) -> list[PropertyResponse]:
"""List public properties (no auth required)."""
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,
))
return result
@router.get("/properties/{property_id}/spaces", response_model=list[SpaceResponse])
def list_public_property_spaces(
property_id: int,
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:
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
.order_by(Space.name)
.all()
)
return [SpaceResponse.model_validate(s) for s in spaces]
@router.get("/spaces/{space_id}/availability", response_model=AvailabilityCheck)
def check_public_availability(
space_id: int,
start_datetime: Annotated[datetime, Query()],
end_datetime: Annotated[datetime, Query()],
db: Annotated[Session, Depends(get_db)],
) -> AvailabilityCheck:
"""Check availability for a space (no auth required)."""
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")
# 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()
)
if not conflicts:
return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available")
approved_count = sum(1 for b in conflicts if b.status == "approved")
pending_count = sum(1 for b in conflicts if b.status == "pending")
if approved_count > 0:
message = f"Time slot has {approved_count} approved booking(s)."
else:
message = f"Time slot has {pending_count} pending request(s)."
return AvailabilityCheck(
available=approved_count == 0,
conflicts=[
ConflictingBooking(
id=b.id,
user_name=b.user.full_name if b.user else (b.guest_name or "Anonymous"),
title=b.title,
status=b.status,
start_datetime=b.start_datetime,
end_datetime=b.end_datetime,
)
for b in conflicts
],
message=message,
)
@router.post("/bookings", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
def create_anonymous_booking(
data: AnonymousBookingCreate,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
) -> BookingResponse:
"""Create an anonymous/guest booking (no auth required)."""
# Validate space exists
space = db.query(Space).filter(Space.id == data.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="Cannot book in a private property without authentication")
else:
raise HTTPException(status_code=400, detail="Space is not assigned to any property")
# Basic validation (no user_id needed for anonymous)
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,
),
).first()
if overlapping:
raise HTTPException(status_code=400, detail="Time slot is already booked")
# Create anonymous booking
booking = Booking(
user_id=None,
space_id=data.space_id,
start_datetime=data.start_datetime,
end_datetime=data.end_datetime,
title=data.title,
description=data.description,
status="pending",
guest_name=data.guest_name,
guest_email=data.guest_email,
guest_organization=data.guest_organization,
is_anonymous=True,
created_at=datetime.utcnow(),
)
db.add(booking)
db.commit()
db.refresh(booking)
# Notify property managers
if space.property_id:
manager_ids = [
pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
]
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
# Also notify superadmins
superadmins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
notify_users = {u.id: u for u in list(managers) + list(superadmins)}
for user in notify_users.values():
create_notification(
db=db,
user_id=user.id,
type="booking_created",
title="Cerere Anonimă de Rezervare",
message=f"Persoana {data.guest_name} ({data.guest_email}) a solicitat rezervarea spațiului {space.name}",
booking_id=booking.id,
)
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_created",
user.email,
data.guest_name,
{"guest_email": data.guest_email},
)
return BookingResponse.model_validate(booking)