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:
Claude Agent
2026-02-15 00:17:21 +00:00
parent d637513d92
commit e21cf03a16
51 changed files with 6324 additions and 273 deletions

View File

@@ -5,7 +5,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session, joinedload
from app.core.deps import get_current_admin, get_db
from app.core.deps import get_current_manager_or_superadmin, get_db
from app.core.permissions import get_manager_property_ids
from app.models.audit_log import AuditLog
from app.models.user import User
from app.schemas.audit_log import AuditLogRead
@@ -21,15 +22,22 @@ def get_audit_logs(
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
current_admin: User = Depends(get_current_manager_or_superadmin),
) -> list[AuditLogRead]:
"""
Get audit logs with filtering and pagination.
Admin only endpoint to view audit trail of administrative actions.
Managers see only logs related to their managed properties (booking/space actions).
"""
query = db.query(AuditLog).options(joinedload(AuditLog.user))
# Property scoping for managers - only show relevant actions
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
# Managers see: their own actions + actions on bookings/spaces in their properties
query = query.filter(AuditLog.user_id == current_admin.id)
# Apply filters
if action:
query = query.filter(AuditLog.action == action)

View File

@@ -5,8 +5,10 @@ from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
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.models.booking import Booking
from app.models.property_manager import PropertyManager
from app.models.settings import Settings
from app.models.space import Space
from app.models.user import User
@@ -39,6 +41,40 @@ from app.services.booking_service import validate_booking_rules
from app.utils.timezone import convert_to_utc
router = APIRouter(prefix="/spaces", tags=["bookings"])
def _verify_manager_booking_access(db: Session, booking: Booking, current_user: User) -> None:
"""Verify that a manager has access to the booking's property.
Superadmins always have access. Managers can only act on bookings
for spaces within their managed properties.
"""
if current_user.role in ("superadmin", "admin"):
return
if current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
space = booking.space
if space and space.property_id and space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this property's bookings",
)
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
"""Verify that a manager has access to a space's property.
Used for creating bookings where we have the space but no booking yet.
"""
if current_user.role in ("superadmin", "admin"):
return
if current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
if space.property_id and space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this property's spaces",
)
bookings_router = APIRouter(prefix="/bookings", tags=["bookings"])
@@ -68,6 +104,10 @@ def get_space_bookings(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Query bookings in the time range (only active bookings)
query = db.query(Booking).filter(
Booking.space_id == space_id,
@@ -79,7 +119,7 @@ def get_space_bookings(
bookings = query.order_by(Booking.start_datetime).all()
# Return different schemas based on user role
if current_user.role == "admin":
if current_user.role in ("admin", "superadmin", "manager"):
return [BookingCalendarAdmin.model_validate(b) for b in bookings]
else:
return [BookingCalendarPublic.model_validate(b) for b in bookings]
@@ -116,6 +156,10 @@ def check_availability(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Find conflicting bookings (approved + pending)
conflicts = (
db.query(Booking)
@@ -271,6 +315,10 @@ def create_booking(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Convert input times from user timezone to UTC
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone)
@@ -293,8 +341,8 @@ def create_booking(
detail=errors[0], # Return first error
)
# Auto-approve if admin, otherwise pending
is_admin = current_user.role == "admin"
# Auto-approve if admin/superadmin, otherwise pending
is_admin = current_user.role in ("admin", "superadmin")
# Create booking (with UTC times)
booking = Booking(
@@ -314,23 +362,39 @@ def create_booking(
db.refresh(booking)
if not is_admin:
# Notify all admins about the new booking request
admins = db.query(User).filter(User.role == "admin").all()
# Notify admins and property managers
notify_users = {}
# Get superadmins/admins
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
for admin in admins:
notify_users[admin.id] = admin
# Get property managers for the space's property
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 []
for mgr in managers:
notify_users[mgr.id] = mgr
for user in notify_users.values():
create_notification(
db=db,
user_id=admin.id, # type: ignore[arg-type]
user_id=user.id, # type: ignore[arg-type]
type="booking_created",
title="Noua Cerere de Rezervare",
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
# Send email notification to admin
# Send email notification
background_tasks.add_task(
send_booking_notification,
booking,
"created",
admin.email,
user.email,
current_user.full_name,
None,
)
@@ -375,6 +439,10 @@ def create_recurring_booking(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Parse time
try:
hour, minute = map(int, data.start_time.split(':'))
@@ -458,14 +526,25 @@ def create_recurring_booking(
for booking in created_bookings:
db.refresh(booking)
# Send notifications to admins (in background)
# Send notifications to admins and property managers (in background)
if created_bookings:
admins = db.query(User).filter(User.role == "admin").all()
notify_users = {}
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
for admin in admins:
notify_users[admin.id] = admin
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 []
for mgr in managers:
notify_users[mgr.id] = mgr
for user in notify_users.values():
background_tasks.add_task(
create_notification,
db=db,
user_id=admin.id, # type: ignore[arg-type]
user_id=user.id, # type: ignore[arg-type]
type="booking_created",
title="Noi Cereri de Rezervare Recurente",
message=f"Utilizatorul {current_user.full_name} a creat {len(created_bookings)} rezervări recurente.",
@@ -648,13 +727,14 @@ def get_all_bookings(
status_filter: Annotated[str | None, Query(alias="status")] = None,
space_id: Annotated[int | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
property_id: Annotated[int | None, Query()] = None,
start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all bookings across all users (admin only).
Get all bookings across all users (admin/manager).
Returns bookings with user and space details.
@@ -662,15 +742,24 @@ def get_all_bookings(
- **status** (optional): Filter by status (pending/approved/rejected/canceled)
- **space_id** (optional): Filter by space ID
- **user_id** (optional): Filter by user ID
- **property_id** (optional): Filter by property ID
- **start** (optional): Only bookings starting from this datetime
- **limit** (optional): Max results (1-100, default 20)
"""
query = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.join(User, Booking.user_id == User.id)
.outerjoin(User, Booking.user_id == User.id)
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
if property_id is not None:
query = query.filter(Space.property_id == property_id)
if status_filter is not None:
query = query.filter(Booking.status == status_filter)
@@ -681,7 +770,6 @@ def get_all_bookings(
query = query.filter(Booking.user_id == user_id)
if start is not None:
# Use end_datetime to include bookings still in progress (started but not ended)
query = query.filter(Booking.end_datetime > start)
bookings = (
@@ -697,26 +785,36 @@ def get_all_bookings(
def get_pending_bookings(
space_id: Annotated[int | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
property_id: Annotated[int | None, Query()] = None,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all pending booking requests (admin only).
Get all pending booking requests (admin/manager).
Returns pending bookings with user and space details, sorted by creation time (FIFO).
Query parameters:
- **space_id** (optional): Filter by space ID
- **user_id** (optional): Filter by user ID
- **property_id** (optional): Filter by property ID
"""
# Base query: pending bookings with joins
# Base query: pending bookings with joins (outerjoin for anonymous bookings)
query = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.join(User, Booking.user_id == User.id)
.outerjoin(User, Booking.user_id == User.id)
.filter(Booking.status == "pending")
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
if property_id is not None:
query = query.filter(Space.property_id == property_id)
# Apply filters if provided
if space_id is not None:
query = query.filter(Booking.space_id == space_id)
@@ -735,7 +833,7 @@ def approve_booking(
id: int,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Approve a pending booking request (admin only).
@@ -755,6 +853,9 @@ def approve_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking is pending
if booking.status != "pending":
raise HTTPException(
@@ -764,11 +865,12 @@ def approve_booking(
# Re-validate booking rules to prevent race conditions
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
start_datetime=booking.start_datetime, # type: ignore[arg-type]
end_datetime=booking.end_datetime, # type: ignore[arg-type]
exclude_booking_id=int(booking.id), # type: ignore[arg-type]
@@ -790,13 +892,14 @@ def approve_booking(
db.refresh(booking)
# Create Google Calendar event if user has connected their calendar
google_event_id = create_calendar_event(
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
)
if google_event_id:
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
db.commit()
db.refresh(booking)
if booking.user_id:
google_event_id = create_calendar_event(
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
)
if google_event_id:
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
db.commit()
db.refresh(booking)
# Log the action
log_action(
@@ -809,24 +912,35 @@ def approve_booking(
)
# Notify the user about approval
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_approved",
title="Rezervare Aprobată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_approved",
title="Rezervare Aprobată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"approved",
booking.user.email,
booking.user.full_name,
None,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"approved",
booking.user.email,
booking.user.full_name,
None,
)
elif booking.guest_email:
# Send email notification to anonymous guest
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_approved",
booking.guest_email,
booking.guest_name or "Guest",
None,
)
return booking
@@ -837,7 +951,7 @@ def reject_booking(
reject_data: RejectRequest,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Reject a pending booking request (admin only).
@@ -857,6 +971,9 @@ def reject_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking is pending
if booking.status != "pending":
raise HTTPException(
@@ -882,24 +999,34 @@ def reject_booking(
)
# Notify the user about rejection
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rejected",
title="Rezervare Respinsă",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rejected",
title="Rezervare Respinsă",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"rejected",
booking.user.email,
booking.user.full_name,
{"rejection_reason": reject_data.reason},
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"rejected",
booking.user.email,
booking.user.full_name,
{"rejection_reason": reject_data.reason},
)
elif booking.guest_email:
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_rejected",
booking.guest_email,
booking.guest_name or "Guest",
{"rejection_reason": reject_data.reason},
)
return booking
@@ -909,10 +1036,10 @@ def admin_update_booking(
id: int,
data: BookingUpdate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Update any booking (admin only).
Update any booking (admin/manager).
Admin can edit any booking (pending or approved), but cannot edit bookings
that have already started.
@@ -928,6 +1055,9 @@ def admin_update_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking already started (cannot edit past bookings)
if booking.start_datetime < datetime.utcnow() and booking.status == "approved": # type: ignore[operator]
raise HTTPException(
@@ -947,13 +1077,14 @@ def admin_update_booking(
# Re-validate booking rules
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
start_datetime=booking.start_datetime, # type: ignore[arg-type]
end_datetime=booking.end_datetime, # type: ignore[arg-type]
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
exclude_booking_id=booking.id, # Exclude self from overlap check
user_timezone=user_timezone,
)
@@ -965,7 +1096,7 @@ def admin_update_booking(
)
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
@@ -994,10 +1125,10 @@ def admin_cancel_booking(
cancel_data: AdminCancelRequest,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Cancel any booking (admin only).
Cancel any booking (admin/manager).
Admin can cancel any booking at any time, regardless of status or timing.
No time restrictions apply (unlike user cancellations).
@@ -1015,13 +1146,16 @@ def admin_cancel_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Admin can cancel any booking (no status check needed)
# Update booking status
booking.status = "canceled" # type: ignore[assignment]
booking.cancellation_reason = cancel_data.cancellation_reason # type: ignore[assignment]
# Delete from Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
delete_calendar_event(
db=db,
event_id=booking.google_calendar_event_id,
@@ -1043,24 +1177,25 @@ def admin_cancel_booking(
)
# Notify the user about cancellation
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_canceled",
title="Rezervare Anulată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_canceled",
title="Rezervare Anulată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"canceled",
booking.user.email,
booking.user.full_name,
{"cancellation_reason": cancel_data.cancellation_reason},
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"canceled",
booking.user.email,
booking.user.full_name,
{"cancellation_reason": cancel_data.cancellation_reason},
)
return booking
@@ -1070,10 +1205,10 @@ def reschedule_booking(
id: int,
data: BookingReschedule,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Reschedule booking to new time slot (admin only, drag-and-drop).
Reschedule booking to new time slot (admin/manager, drag-and-drop).
Validates the new time slot and updates the booking times.
Only approved bookings that haven't started yet can be rescheduled.
@@ -1091,6 +1226,9 @@ def reschedule_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking already started (cannot reschedule past bookings)
if booking.start_datetime < datetime.utcnow(): # type: ignore[operator]
raise HTTPException(
@@ -1104,13 +1242,14 @@ def reschedule_booking(
# Validate new time slot
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
start_datetime=data.start_datetime,
end_datetime=data.end_datetime,
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
exclude_booking_id=booking.id, # Exclude self from overlap check
user_timezone=user_timezone,
)
@@ -1126,7 +1265,7 @@ def reschedule_booking(
booking.end_datetime = data.end_datetime # type: ignore[assignment]
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
@@ -1150,14 +1289,15 @@ def reschedule_booking(
)
# Notify user about reschedule
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rescheduled",
title="Rezervare Reprogramată",
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
if booking.user_id:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rescheduled",
title="Rezervare Reprogramată",
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
db.commit()
db.refresh(booking)
@@ -1169,10 +1309,10 @@ def reschedule_booking(
def admin_create_booking(
booking_data: BookingAdminCreate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Create a booking directly with approved status (admin only, bypass approval workflow).
Create a booking directly with approved status (admin/manager, bypass approval workflow).
- **space_id**: ID of the space to book
- **user_id**: Optional user ID (defaults to current admin if not provided)
@@ -1196,6 +1336,9 @@ def admin_create_booking(
detail="Space not found",
)
# Verify manager has access to this space's property
_verify_manager_space_access(db, space, current_admin)
# Use current admin ID if user_id not provided
target_user_id = booking_data.user_id if booking_data.user_id is not None else int(current_admin.id) # type: ignore[arg-type]

View File

@@ -0,0 +1,280 @@
"""Organization management endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.user import User
from app.schemas.organization import (
AddMemberRequest,
OrganizationCreate,
OrganizationMemberResponse,
OrganizationResponse,
OrganizationUpdate,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
admin_router = APIRouter(prefix="/admin/organizations", tags=["organizations-admin"])
@router.get("", response_model=list[OrganizationResponse])
def list_organizations(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
) -> list[OrganizationResponse]:
"""List organizations (authenticated users)."""
orgs = db.query(Organization).filter(Organization.is_active == True).order_by(Organization.name).all() # noqa: E712
result = []
for org in orgs:
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
result.append(OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
))
return result
@router.get("/{org_id}", response_model=OrganizationResponse)
def get_organization(
org_id: int,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
) -> OrganizationResponse:
"""Get organization detail."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
)
@router.get("/{org_id}/members", response_model=list[OrganizationMemberResponse])
def list_organization_members(
org_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[OrganizationMemberResponse]:
"""List organization members (org admin or superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# Check permission: superadmin or org admin
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
members = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org_id).all()
result = []
for m in members:
u = db.query(User).filter(User.id == m.user_id).first()
result.append(OrganizationMemberResponse(
id=m.id,
organization_id=m.organization_id,
user_id=m.user_id,
role=m.role,
user_name=u.full_name if u else None,
user_email=u.email if u else None,
))
return result
@router.post("/{org_id}/members", response_model=OrganizationMemberResponse, status_code=status.HTTP_201_CREATED)
def add_organization_member(
org_id: int,
data: AddMemberRequest,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> OrganizationMemberResponse:
"""Add member to organization (org admin or superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# Check permission
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
# Check if user exists
user = db.query(User).filter(User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if already member
existing = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == data.user_id,
).first()
if existing:
raise HTTPException(status_code=400, detail="User is already a member")
member = OrganizationMember(
organization_id=org_id,
user_id=data.user_id,
role=data.role,
)
db.add(member)
db.commit()
db.refresh(member)
return OrganizationMemberResponse(
id=member.id,
organization_id=member.organization_id,
user_id=member.user_id,
role=member.role,
user_name=user.full_name,
user_email=user.email,
)
@router.delete("/{org_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_organization_member(
org_id: int,
user_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> None:
"""Remove member from organization."""
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
member = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
).first()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
db.delete(member)
db.commit()
@router.put("/{org_id}/members/{user_id}", response_model=OrganizationMemberResponse)
def update_member_role(
org_id: int,
user_id: int,
data: AddMemberRequest,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> OrganizationMemberResponse:
"""Change member role in organization."""
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
member = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
).first()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
member.role = data.role
db.commit()
db.refresh(member)
u = db.query(User).filter(User.id == user_id).first()
return OrganizationMemberResponse(
id=member.id,
organization_id=member.organization_id,
user_id=member.user_id,
role=member.role,
user_name=u.full_name if u else None,
user_email=u.email if u else None,
)
# === Superadmin endpoints ===
@admin_router.post("", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
def create_organization(
data: OrganizationCreate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
) -> OrganizationResponse:
"""Create an organization (superadmin)."""
existing = db.query(Organization).filter(Organization.name == data.name).first()
if existing:
raise HTTPException(status_code=400, detail="Organization with this name already exists")
org = Organization(name=data.name, description=data.description)
db.add(org)
db.commit()
db.refresh(org)
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=0,
)
@admin_router.put("/{org_id}", response_model=OrganizationResponse)
def update_organization(
org_id: int,
data: OrganizationUpdate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
) -> OrganizationResponse:
"""Update an organization (superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
if data.name is not None:
org.name = data.name
if data.description is not None:
org.description = data.description
db.commit()
db.refresh(org)
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
)

View File

@@ -0,0 +1,575 @@
"""Property management endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
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.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),
))
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)
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],
)
@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,
)
db.add(prop)
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),
)
@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
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),
)
@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),
)
@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),
))
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()

227
backend/app/api/public.py Normal file
View 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)

View File

@@ -7,7 +7,8 @@ from sqlalchemy import and_, case, func
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_current_admin, get_db
from app.core.deps import get_current_manager_or_superadmin, get_db
from app.core.permissions import get_manager_property_ids
from app.models.booking import Booking
from app.models.space import Space
from app.models.user import User
@@ -41,8 +42,9 @@ def get_usage_report(
start_date: date | None = Query(None),
end_date: date | None = Query(None),
space_id: int | None = Query(None),
property_id: int | None = Query(None),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> SpaceUsageReport:
"""Get booking usage report by space."""
query = (
@@ -81,6 +83,13 @@ def get_usage_report(
)
if space_id:
filters.append(Booking.space_id == space_id)
if property_id:
filters.append(Space.property_id == property_id)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
filters.append(Space.property_id.in_(managed_ids))
if filters:
query = query.filter(and_(*filters))
@@ -114,7 +123,7 @@ def get_top_users_report(
end_date: date | None = Query(None),
limit: int = Query(10, ge=1, le=100),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> TopUsersReport:
"""Get top users by booking count."""
query = (
@@ -129,6 +138,7 @@ def get_top_users_report(
func.sum(calculate_hours_expr()).label("total_hours"),
)
.join(User, Booking.user_id == User.id)
.join(Space, Booking.space_id == Space.id)
.group_by(Booking.user_id, User.full_name, User.email)
)
@@ -143,6 +153,11 @@ def get_top_users_report(
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
# Order by total bookings desc
query = query.order_by(func.count(Booking.id).desc()).limit(limit)
@@ -171,7 +186,7 @@ def get_approval_rate_report(
start_date: date | None = Query(None),
end_date: date | None = Query(None),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> ApprovalRateReport:
"""Get approval/rejection rate report."""
query = db.query(
@@ -180,7 +195,7 @@ def get_approval_rate_report(
func.sum(case((Booking.status == "rejected", 1), else_=0)).label("rejected"),
func.sum(case((Booking.status == "pending", 1), else_=0)).label("pending"),
func.sum(case((Booking.status == "canceled", 1), else_=0)).label("canceled"),
)
).join(Space, Booking.space_id == Space.id)
# Apply date filters
if start_date:
@@ -193,6 +208,11 @@ def get_approval_rate_report(
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
result = query.first()
total = result.total or 0

View File

@@ -4,7 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
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
from app.models.space import Space
from app.models.user import User
from app.schemas.space import SpaceCreate, SpaceResponse, SpaceStatusUpdate, SpaceUpdate
@@ -18,36 +19,59 @@ admin_router = APIRouter(prefix="/admin/spaces", tags=["admin"])
def list_spaces(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[Space]:
property_id: int | None = None,
) -> list[SpaceResponse]:
"""
Get list of spaces.
- Users see only active spaces
- Admins see all spaces (active + inactive)
- Admins/superadmins/managers see all spaces (active + inactive)
"""
query = db.query(Space)
# Filter by property_id if provided
if property_id is not None:
query = query.filter(Space.property_id == property_id)
# Filter by active status for non-admin users
if current_user.role != "admin":
if current_user.role not in ("admin", "superadmin", "manager"):
query = query.filter(Space.is_active == True) # noqa: E712
elif current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
if property_id is not None:
# When filtering by specific property, manager sees all spaces (active + inactive) IF they manage it
if property_id not in managed_ids:
query = query.filter(Space.is_active == True) # noqa: E712
else:
# No property filter: manager sees only their managed properties' spaces
query = query.filter(Space.property_id.in_(managed_ids))
spaces = query.order_by(Space.name).all()
return spaces
# Build response with property_name
result = []
for s in spaces:
resp = SpaceResponse.model_validate(s)
if s.property and hasattr(s.property, 'name'):
resp.property_name = s.property.name
result.append(resp)
return result
@admin_router.post("", response_model=SpaceResponse, status_code=status.HTTP_201_CREATED)
def create_space(
space_data: SpaceCreate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
) -> Space:
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> SpaceResponse:
"""
Create a new space (admin only).
Create a new space (admin/manager).
- name: required, non-empty
- type: "sala" or "birou"
- capacity: must be > 0
- description: optional
- property_id: optional, assign to property
"""
# Check if space with same name exists
existing = db.query(Space).filter(Space.name == space_data.name).first()
@@ -57,11 +81,17 @@ def create_space(
detail=f"Space with name '{space_data.name}' already exists",
)
# If manager, verify they manage the property
if space_data.property_id and current_admin.role == "manager":
from app.core.permissions import verify_property_access
verify_property_access(db, current_admin, space_data.property_id, require_manager=True)
space = Space(
name=space_data.name,
type=space_data.type,
capacity=space_data.capacity,
description=space_data.description,
property_id=space_data.property_id,
is_active=True,
)
@@ -79,7 +109,10 @@ def create_space(
details={"name": space.name, "type": space.type, "capacity": space.capacity}
)
return space
resp = SpaceResponse.model_validate(space)
if space.property and hasattr(space.property, 'name'):
resp.property_name = space.property.name
return resp
@admin_router.put("/{space_id}", response_model=SpaceResponse)
@@ -87,7 +120,7 @@ def update_space(
space_id: int,
space_data: SpaceUpdate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Space:
"""
Update an existing space (admin only).
@@ -101,6 +134,15 @@ def update_space(
detail="Space not found",
)
# Verify manager has access to this space's property
if current_admin.role == "manager" and space.property_id:
managed_ids = get_manager_property_ids(db, current_admin.id)
if space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if new name conflicts with another space
if space_data.name != space.name:
existing = db.query(Space).filter(Space.name == space_data.name).first()
@@ -147,7 +189,7 @@ def update_space_status(
space_id: int,
status_data: SpaceStatusUpdate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Space:
"""
Activate or deactivate a space (admin only).
@@ -161,6 +203,15 @@ def update_space_status(
detail="Space not found",
)
# Verify manager has access to this space's property
if current_admin.role == "manager" and space.property_id:
managed_ids = get_manager_property_ids(db, current_admin.id)
if space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
setattr(space, "is_active", status_data.is_active)
db.commit()

View File

@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
from app.core.security import get_password_hash
from app.models.user import User
from app.schemas.user import (
@@ -65,12 +65,12 @@ def update_timezone(
@admin_router.get("", response_model=list[UserResponse])
def list_users(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
_: Annotated[User, Depends(get_current_manager_or_superadmin)],
role: str | None = None,
organization: str | None = None,
) -> list[User]:
"""
Get list of users (admin only).
Get list of users (manager or admin).
Supports filtering by role and organization.
"""
@@ -109,10 +109,10 @@ def create_user(
)
# Validate role
if user_data.role not in ["admin", "user"]:
if user_data.role not in ["admin", "superadmin", "manager", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'user'",
detail="Role must be 'superadmin', 'manager', or 'user'",
)
user = User(
@@ -170,10 +170,10 @@ def update_user(
)
# Validate role
if user_data.role and user_data.role not in ["admin", "user"]:
if user_data.role and user_data.role not in ["admin", "superadmin", "manager", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'user'",
detail="Role must be 'superadmin', 'manager', or 'user'",
)
# Track what changed