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:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
280
backend/app/api/organizations.py
Normal file
280
backend/app/api/organizations.py
Normal 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,
|
||||
)
|
||||
575
backend/app/api/properties.py
Normal file
575
backend/app/api/properties.py
Normal 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
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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user