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>
This commit is contained in:
227
backend/app/api/public.py
Normal file
227
backend/app/api/public.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user