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

View File

@@ -11,6 +11,7 @@ from app.db.session import get_db
from app.models.user import User
security = HTTPBearer()
optional_security = HTTPBearer(auto_error=False)
def get_current_user(
@@ -40,13 +41,58 @@ def get_current_user(
return user
def get_optional_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(optional_security)],
db: Annotated[Session, Depends(get_db)],
) -> User | None:
"""Get current user or None for anonymous access."""
if credentials is None:
return None
try:
token = credentials.credentials
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id = payload.get("sub")
if user_id is None:
return None
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None or not user.is_active:
return None
return user
except JWTError:
return None
def get_current_admin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Verify current user is admin."""
if current_user.role != "admin":
"""Verify current user is admin (superadmin or legacy admin)."""
if current_user.role not in ("admin", "superadmin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user
def get_current_superadmin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Verify current user is superadmin."""
if current_user.role not in ("admin", "superadmin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Superadmin access required",
)
return current_user
def get_current_manager_or_superadmin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Verify current user is manager or superadmin."""
if current_user.role not in ("admin", "superadmin", "manager"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Manager or admin access required",
)
return current_user

View File

@@ -0,0 +1,115 @@
"""Property access permission utilities."""
from sqlalchemy.orm import Session
from app.models.organization_member import OrganizationMember
from app.models.property import Property
from app.models.property_access import PropertyAccess
from app.models.property_manager import PropertyManager
from app.models.user import User
from fastapi import HTTPException, status
def verify_property_access(
db: Session, user: User | None, property_id: int, require_manager: bool = False
) -> bool:
"""Verify user has access to a property. Raises HTTPException if denied."""
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Property not found")
if user is None:
# Anonymous - only public properties
if not prop.is_public:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Property is private")
if require_manager:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
return True
# Superadmin always has access
if user.role in ("superadmin", "admin"):
return True
if require_manager:
# Manager must own this property
if user.role == "manager":
pm = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user.id,
).first()
if pm:
return True
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
# Manager has access to managed properties
if user.role == "manager":
pm = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user.id,
).first()
if pm:
return True
# Public property - anyone has access
if prop.is_public:
return True
# Check explicit access (user)
access = db.query(PropertyAccess).filter(
PropertyAccess.property_id == property_id,
PropertyAccess.user_id == user.id,
).first()
if access:
return True
# Check explicit access (organization)
org_ids = [
m.organization_id
for m in db.query(OrganizationMember).filter(OrganizationMember.user_id == user.id).all()
]
if org_ids:
org_access = db.query(PropertyAccess).filter(
PropertyAccess.property_id == property_id,
PropertyAccess.organization_id.in_(org_ids),
).first()
if org_access:
return True
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this property")
def get_manager_property_ids(db: Session, user_id: int) -> list[int]:
"""Get list of property IDs managed by user."""
return [
pm.property_id
for pm in db.query(PropertyManager).filter(PropertyManager.user_id == user_id).all()
]
def get_user_accessible_property_ids(db: Session, user_id: int) -> list[int]:
"""Get all property IDs accessible by user (public + explicitly granted)."""
# Public properties
public_ids = [
p.id
for p in db.query(Property).filter(Property.is_public == True, Property.is_active == True).all() # noqa: E712
]
# Directly granted
direct_ids = [
a.property_id
for a in db.query(PropertyAccess).filter(PropertyAccess.user_id == user_id).all()
]
# Org granted
org_ids = [
m.organization_id
for m in db.query(OrganizationMember).filter(OrganizationMember.user_id == user_id).all()
]
org_property_ids = []
if org_ids:
org_property_ids = [
a.property_id
for a in db.query(PropertyAccess).filter(PropertyAccess.organization_id.in_(org_ids)).all()
]
return list(set(public_ids + direct_ids + org_property_ids))

View File

@@ -11,6 +11,12 @@ from app.api.bookings import bookings_router
from app.api.bookings import router as spaces_bookings_router
from app.api.google_calendar import router as google_calendar_router
from app.api.notifications import router as notifications_router
from app.api.organizations import admin_router as organizations_admin_router
from app.api.organizations import router as organizations_router
from app.api.properties import admin_router as properties_admin_router
from app.api.properties import manager_router as properties_manager_router
from app.api.properties import router as properties_router
from app.api.public import router as public_router
from app.api.reports import router as reports_router
from app.api.settings import router as settings_router
from app.api.spaces import admin_router as spaces_admin_router
@@ -50,6 +56,12 @@ app.include_router(audit_log_router, prefix="/api", tags=["audit-log"])
app.include_router(attachments_router, prefix="/api", tags=["attachments"])
app.include_router(reports_router, prefix="/api", tags=["reports"])
app.include_router(google_calendar_router, prefix="/api", tags=["google-calendar"])
app.include_router(properties_router, prefix="/api")
app.include_router(properties_manager_router, prefix="/api")
app.include_router(properties_admin_router, prefix="/api")
app.include_router(organizations_router, prefix="/api")
app.include_router(organizations_admin_router, prefix="/api")
app.include_router(public_router, prefix="/api")
@app.get("/")

View File

@@ -5,8 +5,19 @@ from app.models.booking import Booking
from app.models.booking_template import BookingTemplate
from app.models.google_calendar_token import GoogleCalendarToken
from app.models.notification import Notification
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
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.settings import Settings
from app.models.space import Space
from app.models.user import User
__all__ = ["User", "Space", "Settings", "Booking", "BookingTemplate", "Notification", "AuditLog", "Attachment", "GoogleCalendarToken"]
__all__ = [
"User", "Space", "Settings", "Booking", "BookingTemplate",
"Notification", "AuditLog", "Attachment", "GoogleCalendarToken",
"Property", "PropertyManager", "PropertyAccess", "PropertySettings",
"Organization", "OrganizationMember",
]

View File

@@ -1,7 +1,7 @@
"""Booking model."""
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.session import Base
@@ -13,8 +13,12 @@ class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
space_id = Column(Integer, ForeignKey("spaces.id"), nullable=False, index=True)
guest_name = Column(String, nullable=True)
guest_email = Column(String, nullable=True)
guest_organization = Column(String, nullable=True)
is_anonymous = Column(Boolean, default=False, nullable=False)
title = Column(String, nullable=False)
description = Column(String, nullable=True)
start_datetime = Column(DateTime, nullable=False, index=True)

View File

@@ -0,0 +1,18 @@
"""Organization model."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from app.db.session import Base
class Organization(Base):
"""Organization model for grouping users."""
__tablename__ = "organizations"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True, index=True)
description = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,17 @@
"""OrganizationMember junction model."""
from sqlalchemy import Column, ForeignKey, Integer, String, UniqueConstraint
from app.db.session import Base
class OrganizationMember(Base):
"""Junction table linking organizations to their members."""
__tablename__ = "organization_members"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
role = Column(String, nullable=False, default="member") # "admin" or "member"
__table_args__ = (UniqueConstraint("organization_id", "user_id", name="uq_org_member"),)

View File

@@ -0,0 +1,20 @@
"""Property model."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from app.db.session import Base
class Property(Base):
"""Property model for multi-tenant property management."""
__tablename__ = "properties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
description = Column(String, nullable=True)
address = Column(String, nullable=True)
is_public = Column(Boolean, default=True, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,19 @@
"""PropertyAccess model."""
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer
from app.db.session import Base
class PropertyAccess(Base):
"""Tracks which users/organizations have access to private properties."""
__tablename__ = "property_access"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True, index=True)
granted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,16 @@
"""PropertyManager junction model."""
from sqlalchemy import Column, ForeignKey, Integer, UniqueConstraint
from app.db.session import Base
class PropertyManager(Base):
"""Junction table linking properties to their managers."""
__tablename__ = "property_managers"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
__table_args__ = (UniqueConstraint("property_id", "user_id", name="uq_property_manager"),)

View File

@@ -0,0 +1,20 @@
"""PropertySettings model."""
from sqlalchemy import Boolean, Column, ForeignKey, Integer
from app.db.session import Base
class PropertySettings(Base):
"""Per-property scheduling settings."""
__tablename__ = "property_settings"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, unique=True, index=True)
working_hours_start = Column(Integer, nullable=True)
working_hours_end = Column(Integer, nullable=True)
min_duration_minutes = Column(Integer, nullable=True)
max_duration_minutes = Column(Integer, nullable=True)
max_bookings_per_day_per_user = Column(Integer, nullable=True)
require_approval = Column(Boolean, default=True, nullable=False)
min_hours_before_cancel = Column(Integer, nullable=True)

View File

@@ -1,5 +1,6 @@
"""Space model."""
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.session import Base
@@ -15,9 +16,12 @@ class Space(Base):
capacity = Column(Integer, nullable=False)
description = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=True, index=True)
# Per-space scheduling settings (NULL = use global default)
working_hours_start = Column(Integer, nullable=True)
working_hours_end = Column(Integer, nullable=True)
min_duration_minutes = Column(Integer, nullable=True)
max_duration_minutes = Column(Integer, nullable=True)
property = relationship("Property", backref="spaces")

View File

@@ -14,7 +14,7 @@ class User(Base):
email = Column(String, unique=True, index=True, nullable=False)
full_name = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # "admin" or "user"
role = Column(String, nullable=False, default="user") # "superadmin"/"manager"/"user"
organization = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
timezone = Column(String(50), default="UTC", nullable=False) # IANA timezone
@@ -26,3 +26,5 @@ class User(Base):
google_calendar_token = relationship(
"GoogleCalendarToken", back_populates="user", uselist=False
)
managed_properties = relationship("PropertyManager", backref="user", cascade="all, delete-orphan")
organization_memberships = relationship("OrganizationMember", backref="user", cascade="all, delete-orphan")

View File

@@ -1,8 +1,8 @@
"""Booking schemas for request/response."""
from datetime import datetime, date
from typing import Optional
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
class BookingCalendarPublic(BaseModel):
@@ -21,7 +21,7 @@ class BookingCalendarAdmin(BaseModel):
"""Full booking data for admins (calendar view)."""
id: int
user_id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
@@ -50,7 +50,7 @@ class BookingResponse(BaseModel):
"""Schema for booking response after creation."""
id: int
user_id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
@@ -58,6 +58,10 @@ class BookingResponse(BaseModel):
title: str
description: str | None
created_at: datetime
guest_name: str | None = None
guest_email: str | None = None
guest_organization: str | None = None
is_anonymous: bool = False
# Timezone-aware formatted strings (optional, set by endpoint)
start_datetime_tz: Optional[str] = None
end_datetime_tz: Optional[str] = None
@@ -79,6 +83,10 @@ class BookingResponse(BaseModel):
title=booking.title,
description=booking.description,
created_at=booking.created_at,
guest_name=booking.guest_name,
guest_email=booking.guest_email,
guest_organization=booking.guest_organization,
is_anonymous=booking.is_anonymous,
start_datetime_tz=format_datetime_tz(booking.start_datetime, user_timezone),
end_datetime_tz=format_datetime_tz(booking.end_datetime, user_timezone)
)
@@ -90,9 +98,20 @@ class SpaceInBooking(BaseModel):
id: int
name: str
type: str
property_id: int | None = None
property_name: str | None = None
model_config = {"from_attributes": True}
@model_validator(mode="wrap")
@classmethod
def extract_property_name(cls, data: Any, handler: Any) -> "SpaceInBooking":
"""Extract property_name from ORM relationship."""
instance = handler(data)
if instance.property_name is None and hasattr(data, 'property') and data.property:
instance.property_name = data.property.name
return instance
class BookingWithSpace(BaseModel):
"""Booking with associated space details for user's booking list."""
@@ -127,14 +146,18 @@ class BookingPendingDetail(BaseModel):
id: int
space_id: int
space: SpaceInBooking
user_id: int
user: UserInBooking
user_id: int | None = None
user: UserInBooking | None = None
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
guest_name: str | None = None
guest_email: str | None = None
guest_organization: str | None = None
is_anonymous: bool = False
model_config = {"from_attributes": True}
@@ -242,3 +265,16 @@ class BookingReschedule(BaseModel):
start_datetime: datetime
end_datetime: datetime
class AnonymousBookingCreate(BaseModel):
"""Schema for anonymous/guest booking creation."""
space_id: int
start_datetime: datetime
end_datetime: datetime
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
guest_name: str = Field(..., min_length=1)
guest_email: str = Field(..., min_length=1)
guest_organization: str | None = None

View File

@@ -0,0 +1,41 @@
"""Organization schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class OrganizationCreate(BaseModel):
name: str = Field(..., min_length=1)
description: str | None = None
class OrganizationUpdate(BaseModel):
name: str | None = None
description: str | None = None
class OrganizationResponse(BaseModel):
id: int
name: str
description: str | None = None
is_active: bool
created_at: datetime
member_count: int = 0
model_config = {"from_attributes": True}
class OrganizationMemberResponse(BaseModel):
id: int
organization_id: int
user_id: int
role: str
user_name: str | None = None
user_email: str | None = None
model_config = {"from_attributes": True}
class AddMemberRequest(BaseModel):
user_id: int
role: str = "member"

View File

@@ -0,0 +1,82 @@
"""Property schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class PropertyCreate(BaseModel):
name: str = Field(..., min_length=1)
description: str | None = None
address: str | None = None
is_public: bool = True
class PropertyUpdate(BaseModel):
name: str | None = None
description: str | None = None
address: str | None = None
is_public: bool | None = None
class PropertyManagerInfo(BaseModel):
user_id: int
full_name: str
email: str
class PropertyResponse(BaseModel):
id: int
name: str
description: str | None = None
address: str | None = None
is_public: bool
is_active: bool
created_at: datetime
space_count: int = 0
managers: list[PropertyManagerInfo] = []
model_config = {"from_attributes": True}
class PropertyWithSpaces(PropertyResponse):
spaces: list = []
class PropertyAccessCreate(BaseModel):
user_id: int | None = None
organization_id: int | None = None
class PropertyAccessResponse(BaseModel):
id: int
property_id: int
user_id: int | None = None
organization_id: int | None = None
granted_by: int | None = None
user_name: str | None = None
user_email: str | None = None
organization_name: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class PropertySettingsUpdate(BaseModel):
working_hours_start: int | None = None
working_hours_end: int | None = None
min_duration_minutes: int | None = None
max_duration_minutes: int | None = None
max_bookings_per_day_per_user: int | None = None
require_approval: bool = True
min_hours_before_cancel: int | None = None
class PropertySettingsResponse(PropertySettingsUpdate):
id: int
property_id: int
model_config = {"from_attributes": True}
class PropertyStatusUpdate(BaseModel):
is_active: bool

View File

@@ -20,7 +20,7 @@ class SpaceBase(BaseModel):
class SpaceCreate(SpaceBase):
"""Space creation schema."""
pass
property_id: int | None = None
class SpaceUpdate(SpaceBase):
@@ -40,6 +40,8 @@ class SpaceResponse(SpaceBase):
id: int
is_active: bool
property_id: int | None = None
property_name: str | None = None
working_hours_start: int | None = None
working_hours_end: int | None = None
min_duration_minutes: int | None = None

View File

@@ -5,6 +5,7 @@ from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.property_settings import PropertySettings
from app.models.settings import Settings
from app.models.space import Space
from app.utils.timezone import convert_from_utc, convert_to_utc
@@ -53,27 +54,43 @@ def validate_booking_rules(
db.commit()
db.refresh(settings)
# Fetch space and get per-space settings (with fallback to global)
# Fetch space and get per-space settings
# Resolution chain: Space → PropertySettings → Global Settings
space = db.query(Space).filter(Space.id == space_id).first()
wh_start = (
space.working_hours_start
if space and space.working_hours_start is not None
else settings.working_hours_start
# Fetch property settings if space has a property
prop_settings = None
if space and space.property_id:
prop_settings = db.query(PropertySettings).filter(
PropertySettings.property_id == space.property_id
).first()
def resolve(space_val, prop_val, global_val):
if space_val is not None:
return space_val
if prop_val is not None:
return prop_val
return global_val
wh_start = resolve(
space.working_hours_start if space else None,
prop_settings.working_hours_start if prop_settings else None,
settings.working_hours_start,
)
wh_end = (
space.working_hours_end
if space and space.working_hours_end is not None
else settings.working_hours_end
wh_end = resolve(
space.working_hours_end if space else None,
prop_settings.working_hours_end if prop_settings else None,
settings.working_hours_end,
)
min_dur = (
space.min_duration_minutes
if space and space.min_duration_minutes is not None
else settings.min_duration_minutes
min_dur = resolve(
space.min_duration_minutes if space else None,
prop_settings.min_duration_minutes if prop_settings else None,
settings.min_duration_minutes,
)
max_dur = (
space.max_duration_minutes
if space and space.max_duration_minutes is not None
else settings.max_duration_minutes
max_dur = resolve(
space.max_duration_minutes if space else None,
prop_settings.max_duration_minutes if prop_settings else None,
settings.max_duration_minutes,
)
# Convert UTC times to user timezone for validation

View File

@@ -128,6 +128,58 @@ Motiv: {reason}
Vă rugăm să contactați administratorul pentru detalii.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_created":
guest_email = extra_data.get("guest_email", "N/A") if extra_data else "N/A"
subject = "Cerere Anonimă de Rezervare"
body = f"""Bună ziua,
O nouă cerere anonimă de rezervare necesită aprobarea dumneavoastră:
Persoana: {user_name}
Email: {guest_email}
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Descriere: {booking.description or 'N/A'}
Vă rugăm să accesați panoul de administrare pentru a aproba sau respinge această cerere.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_approved":
subject = "Rezervare Aprobată"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost aprobată:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Vă așteptăm!
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_rejected":
reason = extra_data.get("rejection_reason", "Nu a fost specificat") if extra_data else "Nu a fost specificat"
subject = "Rezervare Respinsă"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost respinsă:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Motiv: {reason}
Cu stimă,
Sistemul de Rezervări
"""

View File

@@ -0,0 +1,106 @@
"""Migration script to add multi-property support to existing database."""
import sys
import os
# Add backend to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy import inspect, text
from app.db.session import Base, SessionLocal, engine
from app.models import (
Organization, OrganizationMember, Property, PropertyAccess,
PropertyManager, PropertySettings, User, Space,
)
def migrate():
"""Run migration to add multi-property tables and data."""
db = SessionLocal()
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
print("Starting multi-property migration...")
# Step 1: Create all new tables
print("1. Creating new tables...")
Base.metadata.create_all(bind=engine)
print(" Tables created successfully.")
# Step 2: Add property_id column to spaces if not exists
space_columns = [col["name"] for col in inspector.get_columns("spaces")]
if "property_id" not in space_columns:
print("2. Adding property_id column to spaces...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE spaces ADD COLUMN property_id INTEGER REFERENCES properties(id)"))
conn.commit()
print(" Column added.")
else:
print("2. property_id column already exists in spaces.")
# Step 3: Add guest columns to bookings if not exists
booking_columns = [col["name"] for col in inspector.get_columns("bookings")]
with engine.connect() as conn:
if "guest_name" not in booking_columns:
print("3. Adding guest columns to bookings...")
conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_name VARCHAR"))
conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_email VARCHAR"))
conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_organization VARCHAR"))
conn.execute(text("ALTER TABLE bookings ADD COLUMN is_anonymous BOOLEAN DEFAULT 0 NOT NULL"))
conn.commit()
print(" Guest columns added.")
else:
print("3. Guest columns already exist in bookings.")
# Step 4: Create "Default Property"
print("4. Creating Default Property...")
existing_default = db.query(Property).filter(Property.name == "Default Property").first()
if not existing_default:
default_prop = Property(
name="Default Property",
description="Default property for migrated spaces",
is_public=True,
is_active=True,
)
db.add(default_prop)
db.flush()
# Step 5: Migrate existing spaces to Default Property
print("5. Migrating existing spaces to Default Property...")
spaces_without_property = db.query(Space).filter(Space.property_id == None).all() # noqa: E711
for space in spaces_without_property:
space.property_id = default_prop.id
db.flush()
print(f" Migrated {len(spaces_without_property)} spaces.")
# Step 6: Rename admin users to superadmin
print("6. Updating admin roles to superadmin...")
admin_users = db.query(User).filter(User.role == "admin").all()
for u in admin_users:
u.role = "superadmin"
db.flush()
print(f" Updated {len(admin_users)} users.")
# Step 7: Create PropertyManager entries for superadmins
print("7. Creating PropertyManager entries for superadmins...")
superadmins = db.query(User).filter(User.role == "superadmin").all()
for sa in superadmins:
existing_pm = db.query(PropertyManager).filter(
PropertyManager.property_id == default_prop.id,
PropertyManager.user_id == sa.id,
).first()
if not existing_pm:
db.add(PropertyManager(property_id=default_prop.id, user_id=sa.id))
db.flush()
print(f" Created entries for {len(superadmins)} superadmins.")
db.commit()
print("\nMigration completed successfully!")
else:
print(" Default Property already exists. Skipping data migration.")
print("\nMigration already applied.")
db.close()
if __name__ == "__main__":
migrate()

View File

@@ -1,12 +1,18 @@
"""Seed database with initial data."""
"""Seed database with initial data for multi-property system."""
from app.core.security import get_password_hash
from app.db.session import Base, SessionLocal, engine
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.property import Property
from app.models.property_access import PropertyAccess
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
def seed_database() -> None:
"""Create initial users for testing."""
"""Create initial data for testing multi-property system."""
# Create tables
Base.metadata.create_all(bind=engine)
@@ -18,16 +24,27 @@ def seed_database() -> None:
print("Database already seeded. Skipping...")
return
# Create admin user
admin = User(
# Create superadmin user
superadmin = User(
email="admin@example.com",
full_name="Admin User",
full_name="Super Admin",
hashed_password=get_password_hash("adminpassword"),
role="admin",
role="superadmin",
organization="Management",
is_active=True,
)
db.add(admin)
db.add(superadmin)
# Create manager user
manager = User(
email="manager@example.com",
full_name="Property Manager",
hashed_password=get_password_hash("managerpassword"),
role="manager",
organization="Management",
is_active=True,
)
db.add(manager)
# Create regular user
user = User(
@@ -40,6 +57,93 @@ def seed_database() -> None:
)
db.add(user)
db.flush() # Get IDs
# Create properties
prop1 = Property(
name="Clădirea Centrală",
description="Clădirea principală din centru",
address="Str. Principală nr. 1",
is_public=True,
is_active=True,
)
db.add(prop1)
prop2 = Property(
name="Biroul Privat",
description="Spațiu privat pentru echipă",
address="Str. Secundară nr. 5",
is_public=False,
is_active=True,
)
db.add(prop2)
db.flush() # Get property IDs
# Assign manager to both properties
db.add(PropertyManager(property_id=prop1.id, user_id=manager.id))
db.add(PropertyManager(property_id=prop2.id, user_id=manager.id))
# Create spaces (2 in first property, 1 in second)
space1 = Space(
name="Sala Mare",
type="sala",
capacity=20,
description="Sală de conferințe mare",
is_active=True,
property_id=prop1.id,
)
db.add(space1)
space2 = Space(
name="Birou A1",
type="birou",
capacity=4,
description="Birou deschis",
is_active=True,
property_id=prop1.id,
)
db.add(space2)
space3 = Space(
name="Sala Privată",
type="sala",
capacity=10,
description="Sală privată pentru echipă",
is_active=True,
property_id=prop2.id,
)
db.add(space3)
# Create organizations
org1 = Organization(
name="Engineering",
description="Echipa de dezvoltare",
is_active=True,
)
db.add(org1)
org2 = Organization(
name="Management",
description="Echipa de management",
is_active=True,
)
db.add(org2)
db.flush() # Get org IDs
# Create organization members
db.add(OrganizationMember(organization_id=org1.id, user_id=user.id, role="member"))
db.add(OrganizationMember(organization_id=org2.id, user_id=manager.id, role="admin"))
db.add(OrganizationMember(organization_id=org2.id, user_id=superadmin.id, role="admin"))
# Grant user access to private property
db.add(PropertyAccess(
property_id=prop2.id,
user_id=user.id,
granted_by=manager.id,
))
# Create default settings if not exist
existing_settings = db.query(Settings).filter(Settings.id == 1).first()
if not existing_settings:
@@ -55,9 +159,12 @@ def seed_database() -> None:
db.add(default_settings)
db.commit()
print("Database seeded successfully!")
print("Admin: admin@example.com / adminpassword")
print("Database seeded successfully!")
print("Superadmin: admin@example.com / adminpassword")
print("Manager: manager@example.com / managerpassword")
print("User: user@example.com / userpassword")
print(f"Properties: '{prop1.name}' (public), '{prop2.name}' (private)")
print(f"Organizations: '{org1.name}', '{org2.name}'")
except Exception as e:
print(f"Error seeding database: {e}")
db.rollback()