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>
228 lines
8.2 KiB
Python
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)
|