feat: add multi-tenant system with properties, organizations, and public booking
Implement complete multi-property architecture: - Properties (groups of spaces) with public/private visibility - Property managers (many-to-many) with role-based permissions - Organizations with member management - Anonymous/guest booking support via public API (/api/public/*) - Property-scoped spaces, bookings, and settings - Frontend: property selector, organization management, public booking views - Migration script and updated seed data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@ from typing import Annotated, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.deps import get_current_admin, get_db
|
||||
from app.core.deps import get_current_manager_or_superadmin, get_db
|
||||
from app.core.permissions import get_manager_property_ids
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
from app.schemas.audit_log import AuditLogRead
|
||||
@@ -21,15 +22,22 @@ def get_audit_logs(
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
current_admin: User = Depends(get_current_manager_or_superadmin),
|
||||
) -> list[AuditLogRead]:
|
||||
"""
|
||||
Get audit logs with filtering and pagination.
|
||||
|
||||
Admin only endpoint to view audit trail of administrative actions.
|
||||
Managers see only logs related to their managed properties (booking/space actions).
|
||||
"""
|
||||
query = db.query(AuditLog).options(joinedload(AuditLog.user))
|
||||
|
||||
# Property scoping for managers - only show relevant actions
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
# Managers see: their own actions + actions on bookings/spaces in their properties
|
||||
query = query.filter(AuditLog.user_id == current_admin.id)
|
||||
|
||||
# Apply filters
|
||||
if action:
|
||||
query = query.filter(AuditLog.action == action)
|
||||
|
||||
@@ -5,8 +5,10 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_admin, get_current_user, get_db
|
||||
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
|
||||
from app.core.permissions import get_manager_property_ids, verify_property_access
|
||||
from app.models.booking import Booking
|
||||
from app.models.property_manager import PropertyManager
|
||||
from app.models.settings import Settings
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
@@ -39,6 +41,40 @@ from app.services.booking_service import validate_booking_rules
|
||||
from app.utils.timezone import convert_to_utc
|
||||
|
||||
router = APIRouter(prefix="/spaces", tags=["bookings"])
|
||||
|
||||
|
||||
def _verify_manager_booking_access(db: Session, booking: Booking, current_user: User) -> None:
|
||||
"""Verify that a manager has access to the booking's property.
|
||||
|
||||
Superadmins always have access. Managers can only act on bookings
|
||||
for spaces within their managed properties.
|
||||
"""
|
||||
if current_user.role in ("superadmin", "admin"):
|
||||
return
|
||||
if current_user.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_user.id)
|
||||
space = booking.space
|
||||
if space and space.property_id and space.property_id not in managed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this property's bookings",
|
||||
)
|
||||
|
||||
|
||||
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
|
||||
"""Verify that a manager has access to a space's property.
|
||||
|
||||
Used for creating bookings where we have the space but no booking yet.
|
||||
"""
|
||||
if current_user.role in ("superadmin", "admin"):
|
||||
return
|
||||
if current_user.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_user.id)
|
||||
if space.property_id and space.property_id not in managed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this property's spaces",
|
||||
)
|
||||
bookings_router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||
|
||||
|
||||
@@ -68,6 +104,10 @@ def get_space_bookings(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Query bookings in the time range (only active bookings)
|
||||
query = db.query(Booking).filter(
|
||||
Booking.space_id == space_id,
|
||||
@@ -79,7 +119,7 @@ def get_space_bookings(
|
||||
bookings = query.order_by(Booking.start_datetime).all()
|
||||
|
||||
# Return different schemas based on user role
|
||||
if current_user.role == "admin":
|
||||
if current_user.role in ("admin", "superadmin", "manager"):
|
||||
return [BookingCalendarAdmin.model_validate(b) for b in bookings]
|
||||
else:
|
||||
return [BookingCalendarPublic.model_validate(b) for b in bookings]
|
||||
@@ -116,6 +156,10 @@ def check_availability(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Find conflicting bookings (approved + pending)
|
||||
conflicts = (
|
||||
db.query(Booking)
|
||||
@@ -271,6 +315,10 @@ def create_booking(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Convert input times from user timezone to UTC
|
||||
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
|
||||
start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone)
|
||||
@@ -293,8 +341,8 @@ def create_booking(
|
||||
detail=errors[0], # Return first error
|
||||
)
|
||||
|
||||
# Auto-approve if admin, otherwise pending
|
||||
is_admin = current_user.role == "admin"
|
||||
# Auto-approve if admin/superadmin, otherwise pending
|
||||
is_admin = current_user.role in ("admin", "superadmin")
|
||||
|
||||
# Create booking (with UTC times)
|
||||
booking = Booking(
|
||||
@@ -314,23 +362,39 @@ def create_booking(
|
||||
db.refresh(booking)
|
||||
|
||||
if not is_admin:
|
||||
# Notify all admins about the new booking request
|
||||
admins = db.query(User).filter(User.role == "admin").all()
|
||||
# Notify admins and property managers
|
||||
notify_users = {}
|
||||
|
||||
# Get superadmins/admins
|
||||
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
|
||||
for admin in admins:
|
||||
notify_users[admin.id] = admin
|
||||
|
||||
# Get property managers for the space's property
|
||||
if space.property_id:
|
||||
manager_ids = [
|
||||
pm.user_id
|
||||
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
|
||||
]
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||
for mgr in managers:
|
||||
notify_users[mgr.id] = mgr
|
||||
|
||||
for user in notify_users.values():
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=admin.id, # type: ignore[arg-type]
|
||||
user_id=user.id, # type: ignore[arg-type]
|
||||
type="booking_created",
|
||||
title="Noua Cerere de Rezervare",
|
||||
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
# Send email notification to admin
|
||||
# Send email notification
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"created",
|
||||
admin.email,
|
||||
user.email,
|
||||
current_user.full_name,
|
||||
None,
|
||||
)
|
||||
@@ -375,6 +439,10 @@ def create_recurring_booking(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify user has access to the space's property
|
||||
if space.property_id:
|
||||
verify_property_access(db, current_user, space.property_id)
|
||||
|
||||
# Parse time
|
||||
try:
|
||||
hour, minute = map(int, data.start_time.split(':'))
|
||||
@@ -458,14 +526,25 @@ def create_recurring_booking(
|
||||
for booking in created_bookings:
|
||||
db.refresh(booking)
|
||||
|
||||
# Send notifications to admins (in background)
|
||||
# Send notifications to admins and property managers (in background)
|
||||
if created_bookings:
|
||||
admins = db.query(User).filter(User.role == "admin").all()
|
||||
notify_users = {}
|
||||
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
|
||||
for admin in admins:
|
||||
notify_users[admin.id] = admin
|
||||
if space.property_id:
|
||||
manager_ids = [
|
||||
pm.user_id
|
||||
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
|
||||
]
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||
for mgr in managers:
|
||||
notify_users[mgr.id] = mgr
|
||||
for user in notify_users.values():
|
||||
background_tasks.add_task(
|
||||
create_notification,
|
||||
db=db,
|
||||
user_id=admin.id, # type: ignore[arg-type]
|
||||
user_id=user.id, # type: ignore[arg-type]
|
||||
type="booking_created",
|
||||
title="Noi Cereri de Rezervare Recurente",
|
||||
message=f"Utilizatorul {current_user.full_name} a creat {len(created_bookings)} rezervări recurente.",
|
||||
@@ -648,13 +727,14 @@ def get_all_bookings(
|
||||
status_filter: Annotated[str | None, Query(alias="status")] = None,
|
||||
space_id: Annotated[int | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
property_id: Annotated[int | None, Query()] = None,
|
||||
start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
|
||||
) -> list[BookingPendingDetail]:
|
||||
"""
|
||||
Get all bookings across all users (admin only).
|
||||
Get all bookings across all users (admin/manager).
|
||||
|
||||
Returns bookings with user and space details.
|
||||
|
||||
@@ -662,15 +742,24 @@ def get_all_bookings(
|
||||
- **status** (optional): Filter by status (pending/approved/rejected/canceled)
|
||||
- **space_id** (optional): Filter by space ID
|
||||
- **user_id** (optional): Filter by user ID
|
||||
- **property_id** (optional): Filter by property ID
|
||||
- **start** (optional): Only bookings starting from this datetime
|
||||
- **limit** (optional): Max results (1-100, default 20)
|
||||
"""
|
||||
query = (
|
||||
db.query(Booking)
|
||||
.join(Space, Booking.space_id == Space.id)
|
||||
.join(User, Booking.user_id == User.id)
|
||||
.outerjoin(User, Booking.user_id == User.id)
|
||||
)
|
||||
|
||||
# Property scoping for managers
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
query = query.filter(Space.property_id.in_(managed_ids))
|
||||
|
||||
if property_id is not None:
|
||||
query = query.filter(Space.property_id == property_id)
|
||||
|
||||
if status_filter is not None:
|
||||
query = query.filter(Booking.status == status_filter)
|
||||
|
||||
@@ -681,7 +770,6 @@ def get_all_bookings(
|
||||
query = query.filter(Booking.user_id == user_id)
|
||||
|
||||
if start is not None:
|
||||
# Use end_datetime to include bookings still in progress (started but not ended)
|
||||
query = query.filter(Booking.end_datetime > start)
|
||||
|
||||
bookings = (
|
||||
@@ -697,26 +785,36 @@ def get_all_bookings(
|
||||
def get_pending_bookings(
|
||||
space_id: Annotated[int | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
property_id: Annotated[int | None, Query()] = None,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
|
||||
) -> list[BookingPendingDetail]:
|
||||
"""
|
||||
Get all pending booking requests (admin only).
|
||||
Get all pending booking requests (admin/manager).
|
||||
|
||||
Returns pending bookings with user and space details, sorted by creation time (FIFO).
|
||||
|
||||
Query parameters:
|
||||
- **space_id** (optional): Filter by space ID
|
||||
- **user_id** (optional): Filter by user ID
|
||||
- **property_id** (optional): Filter by property ID
|
||||
"""
|
||||
# Base query: pending bookings with joins
|
||||
# Base query: pending bookings with joins (outerjoin for anonymous bookings)
|
||||
query = (
|
||||
db.query(Booking)
|
||||
.join(Space, Booking.space_id == Space.id)
|
||||
.join(User, Booking.user_id == User.id)
|
||||
.outerjoin(User, Booking.user_id == User.id)
|
||||
.filter(Booking.status == "pending")
|
||||
)
|
||||
|
||||
# Property scoping for managers
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
query = query.filter(Space.property_id.in_(managed_ids))
|
||||
|
||||
if property_id is not None:
|
||||
query = query.filter(Space.property_id == property_id)
|
||||
|
||||
# Apply filters if provided
|
||||
if space_id is not None:
|
||||
query = query.filter(Booking.space_id == space_id)
|
||||
@@ -735,7 +833,7 @@ def approve_booking(
|
||||
id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Approve a pending booking request (admin only).
|
||||
@@ -755,6 +853,9 @@ def approve_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking is pending
|
||||
if booking.status != "pending":
|
||||
raise HTTPException(
|
||||
@@ -764,11 +865,12 @@ def approve_booking(
|
||||
|
||||
# Re-validate booking rules to prevent race conditions
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
|
||||
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
user_id=booking_user_id,
|
||||
start_datetime=booking.start_datetime, # type: ignore[arg-type]
|
||||
end_datetime=booking.end_datetime, # type: ignore[arg-type]
|
||||
exclude_booking_id=int(booking.id), # type: ignore[arg-type]
|
||||
@@ -790,13 +892,14 @@ def approve_booking(
|
||||
db.refresh(booking)
|
||||
|
||||
# Create Google Calendar event if user has connected their calendar
|
||||
google_event_id = create_calendar_event(
|
||||
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
|
||||
)
|
||||
if google_event_id:
|
||||
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
if booking.user_id:
|
||||
google_event_id = create_calendar_event(
|
||||
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
|
||||
)
|
||||
if google_event_id:
|
||||
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Log the action
|
||||
log_action(
|
||||
@@ -809,24 +912,35 @@ def approve_booking(
|
||||
)
|
||||
|
||||
# Notify the user about approval
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_approved",
|
||||
title="Rezervare Aprobată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id and booking.user:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_approved",
|
||||
title="Rezervare Aprobată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"approved",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
None,
|
||||
)
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"approved",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
None,
|
||||
)
|
||||
elif booking.guest_email:
|
||||
# Send email notification to anonymous guest
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_approved",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
None,
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -837,7 +951,7 @@ def reject_booking(
|
||||
reject_data: RejectRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Reject a pending booking request (admin only).
|
||||
@@ -857,6 +971,9 @@ def reject_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking is pending
|
||||
if booking.status != "pending":
|
||||
raise HTTPException(
|
||||
@@ -882,24 +999,34 @@ def reject_booking(
|
||||
)
|
||||
|
||||
# Notify the user about rejection
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rejected",
|
||||
title="Rezervare Respinsă",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id and booking.user:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rejected",
|
||||
title="Rezervare Respinsă",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"rejected",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"rejected",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
elif booking.guest_email:
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_rejected",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -909,10 +1036,10 @@ def admin_update_booking(
|
||||
id: int,
|
||||
data: BookingUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Update any booking (admin only).
|
||||
Update any booking (admin/manager).
|
||||
|
||||
Admin can edit any booking (pending or approved), but cannot edit bookings
|
||||
that have already started.
|
||||
@@ -928,6 +1055,9 @@ def admin_update_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking already started (cannot edit past bookings)
|
||||
if booking.start_datetime < datetime.utcnow() and booking.status == "approved": # type: ignore[operator]
|
||||
raise HTTPException(
|
||||
@@ -947,13 +1077,14 @@ def admin_update_booking(
|
||||
|
||||
# Re-validate booking rules
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
|
||||
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
start_datetime=booking.start_datetime, # type: ignore[arg-type]
|
||||
end_datetime=booking.end_datetime, # type: ignore[arg-type]
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
user_id=booking_user_id,
|
||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
@@ -965,7 +1096,7 @@ def admin_update_booking(
|
||||
)
|
||||
|
||||
# Sync with Google Calendar if event exists
|
||||
if booking.google_calendar_event_id:
|
||||
if booking.google_calendar_event_id and booking.user_id:
|
||||
update_calendar_event(
|
||||
db=db,
|
||||
booking=booking,
|
||||
@@ -994,10 +1125,10 @@ def admin_cancel_booking(
|
||||
cancel_data: AdminCancelRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Cancel any booking (admin only).
|
||||
Cancel any booking (admin/manager).
|
||||
|
||||
Admin can cancel any booking at any time, regardless of status or timing.
|
||||
No time restrictions apply (unlike user cancellations).
|
||||
@@ -1015,13 +1146,16 @@ def admin_cancel_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Admin can cancel any booking (no status check needed)
|
||||
# Update booking status
|
||||
booking.status = "canceled" # type: ignore[assignment]
|
||||
booking.cancellation_reason = cancel_data.cancellation_reason # type: ignore[assignment]
|
||||
|
||||
# Delete from Google Calendar if event exists
|
||||
if booking.google_calendar_event_id:
|
||||
if booking.google_calendar_event_id and booking.user_id:
|
||||
delete_calendar_event(
|
||||
db=db,
|
||||
event_id=booking.google_calendar_event_id,
|
||||
@@ -1043,24 +1177,25 @@ def admin_cancel_booking(
|
||||
)
|
||||
|
||||
# Notify the user about cancellation
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_canceled",
|
||||
title="Rezervare Anulată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id and booking.user:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_canceled",
|
||||
title="Rezervare Anulată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"canceled",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"cancellation_reason": cancel_data.cancellation_reason},
|
||||
)
|
||||
# Send email notification to user
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"canceled",
|
||||
booking.user.email,
|
||||
booking.user.full_name,
|
||||
{"cancellation_reason": cancel_data.cancellation_reason},
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -1070,10 +1205,10 @@ def reschedule_booking(
|
||||
id: int,
|
||||
data: BookingReschedule,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Reschedule booking to new time slot (admin only, drag-and-drop).
|
||||
Reschedule booking to new time slot (admin/manager, drag-and-drop).
|
||||
|
||||
Validates the new time slot and updates the booking times.
|
||||
Only approved bookings that haven't started yet can be rescheduled.
|
||||
@@ -1091,6 +1226,9 @@ def reschedule_booking(
|
||||
detail="Booking not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this booking's property
|
||||
_verify_manager_booking_access(db, booking, current_admin)
|
||||
|
||||
# Check if booking already started (cannot reschedule past bookings)
|
||||
if booking.start_datetime < datetime.utcnow(): # type: ignore[operator]
|
||||
raise HTTPException(
|
||||
@@ -1104,13 +1242,14 @@ def reschedule_booking(
|
||||
|
||||
# Validate new time slot
|
||||
# Use booking owner's timezone for validation
|
||||
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
|
||||
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
|
||||
booking_user_id = int(booking.user_id) if booking.user_id else 0
|
||||
errors = validate_booking_rules(
|
||||
db=db,
|
||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||
start_datetime=data.start_datetime,
|
||||
end_datetime=data.end_datetime,
|
||||
user_id=int(booking.user_id), # type: ignore[arg-type]
|
||||
user_id=booking_user_id,
|
||||
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||
user_timezone=user_timezone,
|
||||
)
|
||||
@@ -1126,7 +1265,7 @@ def reschedule_booking(
|
||||
booking.end_datetime = data.end_datetime # type: ignore[assignment]
|
||||
|
||||
# Sync with Google Calendar if event exists
|
||||
if booking.google_calendar_event_id:
|
||||
if booking.google_calendar_event_id and booking.user_id:
|
||||
update_calendar_event(
|
||||
db=db,
|
||||
booking=booking,
|
||||
@@ -1150,14 +1289,15 @@ def reschedule_booking(
|
||||
)
|
||||
|
||||
# Notify user about reschedule
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rescheduled",
|
||||
title="Rezervare Reprogramată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
if booking.user_id:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=booking.user_id, # type: ignore[arg-type]
|
||||
type="booking_rescheduled",
|
||||
title="Rezervare Reprogramată",
|
||||
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
@@ -1169,10 +1309,10 @@ def reschedule_booking(
|
||||
def admin_create_booking(
|
||||
booking_data: BookingAdminCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Booking:
|
||||
"""
|
||||
Create a booking directly with approved status (admin only, bypass approval workflow).
|
||||
Create a booking directly with approved status (admin/manager, bypass approval workflow).
|
||||
|
||||
- **space_id**: ID of the space to book
|
||||
- **user_id**: Optional user ID (defaults to current admin if not provided)
|
||||
@@ -1196,6 +1336,9 @@ def admin_create_booking(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this space's property
|
||||
_verify_manager_space_access(db, space, current_admin)
|
||||
|
||||
# Use current admin ID if user_id not provided
|
||||
target_user_id = booking_data.user_id if booking_data.user_id is not None else int(current_admin.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
280
backend/app/api/organizations.py
Normal file
280
backend/app/api/organizations.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Organization management endpoints."""
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_admin, get_current_user, get_db
|
||||
from app.models.organization import Organization
|
||||
from app.models.organization_member import OrganizationMember
|
||||
from app.models.user import User
|
||||
from app.schemas.organization import (
|
||||
AddMemberRequest,
|
||||
OrganizationCreate,
|
||||
OrganizationMemberResponse,
|
||||
OrganizationResponse,
|
||||
OrganizationUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
admin_router = APIRouter(prefix="/admin/organizations", tags=["organizations-admin"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[OrganizationResponse])
|
||||
def list_organizations(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_user)],
|
||||
) -> list[OrganizationResponse]:
|
||||
"""List organizations (authenticated users)."""
|
||||
orgs = db.query(Organization).filter(Organization.is_active == True).order_by(Organization.name).all() # noqa: E712
|
||||
result = []
|
||||
for org in orgs:
|
||||
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
|
||||
result.append(OrganizationResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
description=org.description,
|
||||
is_active=org.is_active,
|
||||
created_at=org.created_at,
|
||||
member_count=member_count,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=OrganizationResponse)
|
||||
def get_organization(
|
||||
org_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_user)],
|
||||
) -> OrganizationResponse:
|
||||
"""Get organization detail."""
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
|
||||
return OrganizationResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
description=org.description,
|
||||
is_active=org.is_active,
|
||||
created_at=org.created_at,
|
||||
member_count=member_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{org_id}/members", response_model=list[OrganizationMemberResponse])
|
||||
def list_organization_members(
|
||||
org_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> list[OrganizationMemberResponse]:
|
||||
"""List organization members (org admin or superadmin)."""
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
# Check permission: superadmin or org admin
|
||||
if current_user.role not in ("admin", "superadmin"):
|
||||
membership = db.query(OrganizationMember).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.role == "admin",
|
||||
).first()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
|
||||
members = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org_id).all()
|
||||
result = []
|
||||
for m in members:
|
||||
u = db.query(User).filter(User.id == m.user_id).first()
|
||||
result.append(OrganizationMemberResponse(
|
||||
id=m.id,
|
||||
organization_id=m.organization_id,
|
||||
user_id=m.user_id,
|
||||
role=m.role,
|
||||
user_name=u.full_name if u else None,
|
||||
user_email=u.email if u else None,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{org_id}/members", response_model=OrganizationMemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
def add_organization_member(
|
||||
org_id: int,
|
||||
data: AddMemberRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> OrganizationMemberResponse:
|
||||
"""Add member to organization (org admin or superadmin)."""
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
# Check permission
|
||||
if current_user.role not in ("admin", "superadmin"):
|
||||
membership = db.query(OrganizationMember).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.role == "admin",
|
||||
).first()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
|
||||
# Check if user exists
|
||||
user = db.query(User).filter(User.id == data.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if already member
|
||||
existing = db.query(OrganizationMember).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == data.user_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User is already a member")
|
||||
|
||||
member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=data.user_id,
|
||||
role=data.role,
|
||||
)
|
||||
db.add(member)
|
||||
db.commit()
|
||||
db.refresh(member)
|
||||
|
||||
return OrganizationMemberResponse(
|
||||
id=member.id,
|
||||
organization_id=member.organization_id,
|
||||
user_id=member.user_id,
|
||||
role=member.role,
|
||||
user_name=user.full_name,
|
||||
user_email=user.email,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{org_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_organization_member(
|
||||
org_id: int,
|
||||
user_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> None:
|
||||
"""Remove member from organization."""
|
||||
if current_user.role not in ("admin", "superadmin"):
|
||||
membership = db.query(OrganizationMember).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.role == "admin",
|
||||
).first()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
|
||||
member = db.query(OrganizationMember).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == user_id,
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
db.delete(member)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/{org_id}/members/{user_id}", response_model=OrganizationMemberResponse)
|
||||
def update_member_role(
|
||||
org_id: int,
|
||||
user_id: int,
|
||||
data: AddMemberRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> OrganizationMemberResponse:
|
||||
"""Change member role in organization."""
|
||||
if current_user.role not in ("admin", "superadmin"):
|
||||
membership = db.query(OrganizationMember).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.role == "admin",
|
||||
).first()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
|
||||
member = db.query(OrganizationMember).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == user_id,
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
member.role = data.role
|
||||
db.commit()
|
||||
db.refresh(member)
|
||||
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
return OrganizationMemberResponse(
|
||||
id=member.id,
|
||||
organization_id=member.organization_id,
|
||||
user_id=member.user_id,
|
||||
role=member.role,
|
||||
user_name=u.full_name if u else None,
|
||||
user_email=u.email if u else None,
|
||||
)
|
||||
|
||||
|
||||
# === Superadmin endpoints ===
|
||||
|
||||
|
||||
@admin_router.post("", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_organization(
|
||||
data: OrganizationCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
) -> OrganizationResponse:
|
||||
"""Create an organization (superadmin)."""
|
||||
existing = db.query(Organization).filter(Organization.name == data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Organization with this name already exists")
|
||||
|
||||
org = Organization(name=data.name, description=data.description)
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
|
||||
return OrganizationResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
description=org.description,
|
||||
is_active=org.is_active,
|
||||
created_at=org.created_at,
|
||||
member_count=0,
|
||||
)
|
||||
|
||||
|
||||
@admin_router.put("/{org_id}", response_model=OrganizationResponse)
|
||||
def update_organization(
|
||||
org_id: int,
|
||||
data: OrganizationUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
) -> OrganizationResponse:
|
||||
"""Update an organization (superadmin)."""
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
if data.name is not None:
|
||||
org.name = data.name
|
||||
if data.description is not None:
|
||||
org.description = data.description
|
||||
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
|
||||
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
|
||||
return OrganizationResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
description=org.description,
|
||||
is_active=org.is_active,
|
||||
created_at=org.created_at,
|
||||
member_count=member_count,
|
||||
)
|
||||
575
backend/app/api/properties.py
Normal file
575
backend/app/api/properties.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""Property management endpoints."""
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import (
|
||||
get_current_admin,
|
||||
get_current_manager_or_superadmin,
|
||||
get_current_user,
|
||||
get_db,
|
||||
get_optional_user,
|
||||
)
|
||||
from app.core.permissions import get_manager_property_ids, verify_property_access
|
||||
from app.models.organization import Organization
|
||||
from app.models.property import Property
|
||||
from app.models.property_access import PropertyAccess
|
||||
from app.models.property_manager import PropertyManager
|
||||
from app.models.property_settings import PropertySettings
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
from app.schemas.property import (
|
||||
PropertyAccessCreate,
|
||||
PropertyAccessResponse,
|
||||
PropertyCreate,
|
||||
PropertyManagerInfo,
|
||||
PropertyResponse,
|
||||
PropertySettingsResponse,
|
||||
PropertySettingsUpdate,
|
||||
PropertyStatusUpdate,
|
||||
PropertyUpdate,
|
||||
PropertyWithSpaces,
|
||||
)
|
||||
from app.schemas.space import SpaceResponse
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
|
||||
def _get_property_managers(db: Session, property_id: int) -> list[PropertyManagerInfo]:
|
||||
"""Get manager info for a property."""
|
||||
managers = (
|
||||
db.query(User)
|
||||
.join(PropertyManager, PropertyManager.user_id == User.id)
|
||||
.filter(PropertyManager.property_id == property_id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
PropertyManagerInfo(user_id=m.id, full_name=m.full_name, email=m.email)
|
||||
for m in managers
|
||||
]
|
||||
|
||||
router = APIRouter(prefix="/properties", tags=["properties"])
|
||||
manager_router = APIRouter(prefix="/manager/properties", tags=["properties-manager"])
|
||||
admin_router = APIRouter(prefix="/admin/properties", tags=["properties-admin"])
|
||||
|
||||
|
||||
# === User-facing endpoints ===
|
||||
|
||||
|
||||
@router.get("", response_model=list[PropertyResponse])
|
||||
def list_properties(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User | None, Depends(get_optional_user)],
|
||||
managed_only: bool = False,
|
||||
) -> list[PropertyResponse]:
|
||||
"""List visible properties based on user role.
|
||||
|
||||
Query params:
|
||||
- managed_only: If true, managers only see properties they manage (for management pages)
|
||||
"""
|
||||
if current_user and current_user.role in ("admin", "superadmin"):
|
||||
# Superadmin sees all
|
||||
properties = db.query(Property).filter(Property.is_active == True).order_by(Property.name).all() # noqa: E712
|
||||
elif current_user and current_user.role == "manager":
|
||||
# Manager sees managed properties (+ public if not managed_only)
|
||||
managed_ids = get_manager_property_ids(db, current_user.id)
|
||||
if managed_only:
|
||||
properties = (
|
||||
db.query(Property)
|
||||
.filter(
|
||||
Property.is_active == True, # noqa: E712
|
||||
Property.id.in_(managed_ids),
|
||||
)
|
||||
.order_by(Property.name)
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
properties = (
|
||||
db.query(Property)
|
||||
.filter(
|
||||
Property.is_active == True, # noqa: E712
|
||||
(Property.is_public == True) | (Property.id.in_(managed_ids)), # noqa: E712
|
||||
)
|
||||
.order_by(Property.name)
|
||||
.all()
|
||||
)
|
||||
elif current_user:
|
||||
# Regular user sees public + explicitly granted
|
||||
from app.core.permissions import get_user_accessible_property_ids
|
||||
accessible_ids = get_user_accessible_property_ids(db, current_user.id)
|
||||
properties = (
|
||||
db.query(Property)
|
||||
.filter(Property.is_active == True, Property.id.in_(accessible_ids)) # noqa: E712
|
||||
.order_by(Property.name)
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
# Anonymous sees only public
|
||||
properties = (
|
||||
db.query(Property)
|
||||
.filter(Property.is_public == True, Property.is_active == True) # noqa: E712
|
||||
.order_by(Property.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for p in properties:
|
||||
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
|
||||
result.append(PropertyResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
address=p.address,
|
||||
is_public=p.is_public,
|
||||
is_active=p.is_active,
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, p.id),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{property_id}", response_model=PropertyWithSpaces)
|
||||
def get_property(
|
||||
property_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User | None, Depends(get_optional_user)],
|
||||
) -> PropertyWithSpaces:
|
||||
"""Get property detail with visibility check."""
|
||||
verify_property_access(db, current_user, property_id)
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
spaces = db.query(Space).filter(Space.property_id == property_id, Space.is_active == True).all() # noqa: E712
|
||||
space_count = len(spaces)
|
||||
|
||||
return PropertyWithSpaces(
|
||||
id=prop.id,
|
||||
name=prop.name,
|
||||
description=prop.description,
|
||||
address=prop.address,
|
||||
is_public=prop.is_public,
|
||||
is_active=prop.is_active,
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
spaces=[SpaceResponse.model_validate(s) for s in spaces],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{property_id}/spaces", response_model=list[SpaceResponse])
|
||||
def get_property_spaces(
|
||||
property_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User | None, Depends(get_optional_user)],
|
||||
include_inactive: bool = False,
|
||||
) -> list[SpaceResponse]:
|
||||
"""List spaces in a property."""
|
||||
verify_property_access(db, current_user, property_id)
|
||||
query = db.query(Space).filter(Space.property_id == property_id)
|
||||
|
||||
# Managers/admins can see inactive spaces, regular users cannot
|
||||
is_admin_like = current_user and current_user.role in ("admin", "superadmin", "manager")
|
||||
if not (include_inactive and is_admin_like):
|
||||
query = query.filter(Space.is_active == True) # noqa: E712
|
||||
|
||||
spaces = query.order_by(Space.name).all()
|
||||
return [SpaceResponse.model_validate(s) for s in spaces]
|
||||
|
||||
|
||||
# === Manager endpoints ===
|
||||
|
||||
|
||||
@manager_router.post("", response_model=PropertyResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_property(
|
||||
data: PropertyCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> PropertyResponse:
|
||||
"""Create a property. Creator becomes manager."""
|
||||
prop = Property(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
address=data.address,
|
||||
is_public=data.is_public,
|
||||
)
|
||||
db.add(prop)
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
|
||||
# Creator becomes manager
|
||||
pm = PropertyManager(property_id=prop.id, user_id=current_user.id)
|
||||
db.add(pm)
|
||||
db.commit()
|
||||
|
||||
log_action(
|
||||
db=db,
|
||||
action="property_created",
|
||||
user_id=current_user.id,
|
||||
target_type="property",
|
||||
target_id=prop.id,
|
||||
details={"name": prop.name},
|
||||
)
|
||||
|
||||
return PropertyResponse(
|
||||
id=prop.id,
|
||||
name=prop.name,
|
||||
description=prop.description,
|
||||
address=prop.address,
|
||||
is_public=prop.is_public,
|
||||
is_active=prop.is_active,
|
||||
created_at=prop.created_at,
|
||||
space_count=0,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
)
|
||||
|
||||
|
||||
@manager_router.put("/{property_id}", response_model=PropertyResponse)
|
||||
def update_property(
|
||||
property_id: int,
|
||||
data: PropertyUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> PropertyResponse:
|
||||
"""Update a property (ownership check)."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
|
||||
if data.name is not None:
|
||||
prop.name = data.name
|
||||
if data.description is not None:
|
||||
prop.description = data.description
|
||||
if data.address is not None:
|
||||
prop.address = data.address
|
||||
if data.is_public is not None:
|
||||
prop.is_public = data.is_public
|
||||
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
|
||||
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
|
||||
return PropertyResponse(
|
||||
id=prop.id,
|
||||
name=prop.name,
|
||||
description=prop.description,
|
||||
address=prop.address,
|
||||
is_public=prop.is_public,
|
||||
is_active=prop.is_active,
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
)
|
||||
|
||||
|
||||
@manager_router.patch("/{property_id}/status", response_model=PropertyResponse)
|
||||
def update_property_status(
|
||||
property_id: int,
|
||||
data: PropertyStatusUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> PropertyResponse:
|
||||
"""Activate/deactivate a property."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
prop.is_active = data.is_active
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
|
||||
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
|
||||
return PropertyResponse(
|
||||
id=prop.id,
|
||||
name=prop.name,
|
||||
description=prop.description,
|
||||
address=prop.address,
|
||||
is_public=prop.is_public,
|
||||
is_active=prop.is_active,
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
)
|
||||
|
||||
|
||||
@manager_router.delete("/{property_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_property(
|
||||
property_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> None:
|
||||
"""Delete a property (only if it has no active bookings)."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
|
||||
from app.models.booking import Booking
|
||||
|
||||
# Check for active bookings (pending or approved) in this property's spaces
|
||||
space_ids = [s.id for s in db.query(Space).filter(Space.property_id == property_id).all()]
|
||||
if space_ids:
|
||||
active_bookings = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.space_id.in_(space_ids),
|
||||
Booking.status.in_(["pending", "approved"]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if active_bookings > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete property with {active_bookings} active booking(s). Cancel or reject them first.",
|
||||
)
|
||||
|
||||
# Delete related data
|
||||
db.query(PropertyManager).filter(PropertyManager.property_id == property_id).delete()
|
||||
db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).delete()
|
||||
db.query(PropertySettings).filter(PropertySettings.property_id == property_id).delete()
|
||||
|
||||
# Unlink spaces (set property_id to None) rather than deleting them
|
||||
db.query(Space).filter(Space.property_id == property_id).update({"property_id": None})
|
||||
|
||||
db.delete(prop)
|
||||
db.commit()
|
||||
|
||||
log_action(
|
||||
db=db,
|
||||
action="property_deleted",
|
||||
user_id=current_user.id,
|
||||
target_type="property",
|
||||
target_id=property_id,
|
||||
details={"name": prop.name},
|
||||
)
|
||||
|
||||
|
||||
@manager_router.get("/{property_id}/access", response_model=list[PropertyAccessResponse])
|
||||
def list_property_access(
|
||||
property_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> list[PropertyAccessResponse]:
|
||||
"""List access grants for a property."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
accesses = db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).all()
|
||||
|
||||
result = []
|
||||
for a in accesses:
|
||||
user_name = None
|
||||
user_email = None
|
||||
org_name = None
|
||||
if a.user_id:
|
||||
u = db.query(User).filter(User.id == a.user_id).first()
|
||||
if u:
|
||||
user_name = u.full_name
|
||||
user_email = u.email
|
||||
if a.organization_id:
|
||||
org = db.query(Organization).filter(Organization.id == a.organization_id).first()
|
||||
if org:
|
||||
org_name = org.name
|
||||
result.append(PropertyAccessResponse(
|
||||
id=a.id,
|
||||
property_id=a.property_id,
|
||||
user_id=a.user_id,
|
||||
organization_id=a.organization_id,
|
||||
granted_by=a.granted_by,
|
||||
user_name=user_name,
|
||||
user_email=user_email,
|
||||
organization_name=org_name,
|
||||
created_at=a.created_at,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@manager_router.post("/{property_id}/access", response_model=PropertyAccessResponse, status_code=status.HTTP_201_CREATED)
|
||||
def grant_property_access(
|
||||
property_id: int,
|
||||
data: PropertyAccessCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> PropertyAccessResponse:
|
||||
"""Grant access to a property."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
|
||||
if not data.user_id and not data.organization_id:
|
||||
raise HTTPException(status_code=400, detail="Must provide user_id or organization_id")
|
||||
|
||||
access = PropertyAccess(
|
||||
property_id=property_id,
|
||||
user_id=data.user_id,
|
||||
organization_id=data.organization_id,
|
||||
granted_by=current_user.id,
|
||||
)
|
||||
db.add(access)
|
||||
db.commit()
|
||||
db.refresh(access)
|
||||
|
||||
user_name = None
|
||||
user_email = None
|
||||
org_name = None
|
||||
if access.user_id:
|
||||
u = db.query(User).filter(User.id == access.user_id).first()
|
||||
if u:
|
||||
user_name = u.full_name
|
||||
user_email = u.email
|
||||
if access.organization_id:
|
||||
org = db.query(Organization).filter(Organization.id == access.organization_id).first()
|
||||
if org:
|
||||
org_name = org.name
|
||||
|
||||
return PropertyAccessResponse(
|
||||
id=access.id,
|
||||
property_id=access.property_id,
|
||||
user_id=access.user_id,
|
||||
organization_id=access.organization_id,
|
||||
granted_by=access.granted_by,
|
||||
user_name=user_name,
|
||||
user_email=user_email,
|
||||
organization_name=org_name,
|
||||
created_at=access.created_at,
|
||||
)
|
||||
|
||||
|
||||
@manager_router.delete("/{property_id}/access/{access_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def revoke_property_access(
|
||||
property_id: int,
|
||||
access_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> None:
|
||||
"""Revoke access to a property."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
access = db.query(PropertyAccess).filter(
|
||||
PropertyAccess.id == access_id,
|
||||
PropertyAccess.property_id == property_id,
|
||||
).first()
|
||||
if not access:
|
||||
raise HTTPException(status_code=404, detail="Access grant not found")
|
||||
db.delete(access)
|
||||
db.commit()
|
||||
|
||||
|
||||
@manager_router.get("/{property_id}/settings", response_model=PropertySettingsResponse)
|
||||
def get_property_settings(
|
||||
property_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> PropertySettingsResponse:
|
||||
"""Get property settings."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
|
||||
if not ps:
|
||||
# Create default settings
|
||||
ps = PropertySettings(property_id=property_id, require_approval=True)
|
||||
db.add(ps)
|
||||
db.commit()
|
||||
db.refresh(ps)
|
||||
return PropertySettingsResponse.model_validate(ps)
|
||||
|
||||
|
||||
@manager_router.put("/{property_id}/settings", response_model=PropertySettingsResponse)
|
||||
def update_property_settings(
|
||||
property_id: int,
|
||||
data: PropertySettingsUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> PropertySettingsResponse:
|
||||
"""Update property settings."""
|
||||
verify_property_access(db, current_user, property_id, require_manager=True)
|
||||
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
|
||||
if not ps:
|
||||
ps = PropertySettings(property_id=property_id)
|
||||
db.add(ps)
|
||||
db.commit()
|
||||
db.refresh(ps)
|
||||
|
||||
for field in data.model_fields:
|
||||
value = getattr(data, field)
|
||||
if value is not None or field == "require_approval":
|
||||
setattr(ps, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(ps)
|
||||
return PropertySettingsResponse.model_validate(ps)
|
||||
|
||||
|
||||
# === Superadmin endpoints ===
|
||||
|
||||
|
||||
@admin_router.get("", response_model=list[PropertyResponse])
|
||||
def admin_list_all_properties(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
include_inactive: bool = Query(False),
|
||||
) -> list[PropertyResponse]:
|
||||
"""Superadmin: list all properties."""
|
||||
query = db.query(Property)
|
||||
if not include_inactive:
|
||||
query = query.filter(Property.is_active == True) # noqa: E712
|
||||
properties = query.order_by(Property.name).all()
|
||||
|
||||
result = []
|
||||
for p in properties:
|
||||
space_count = db.query(Space).filter(Space.property_id == p.id).count()
|
||||
result.append(PropertyResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
address=p.address,
|
||||
is_public=p.is_public,
|
||||
is_active=p.is_active,
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, p.id),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@admin_router.post("/{property_id}/managers", status_code=status.HTTP_201_CREATED)
|
||||
def assign_property_manager(
|
||||
property_id: int,
|
||||
user_id: int = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Superadmin: assign a manager to a property."""
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
existing = db.query(PropertyManager).filter(
|
||||
PropertyManager.property_id == property_id,
|
||||
PropertyManager.user_id == user_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User is already a manager of this property")
|
||||
|
||||
pm = PropertyManager(property_id=property_id, user_id=user_id)
|
||||
db.add(pm)
|
||||
|
||||
# Ensure user has manager role
|
||||
if user.role == "user":
|
||||
user.role = "manager"
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"User {user.full_name} assigned as manager of {prop.name}"}
|
||||
|
||||
|
||||
@admin_router.delete("/{property_id}/managers/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_property_manager(
|
||||
property_id: int,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
"""Superadmin: remove a manager from a property."""
|
||||
pm = db.query(PropertyManager).filter(
|
||||
PropertyManager.property_id == property_id,
|
||||
PropertyManager.user_id == user_id,
|
||||
).first()
|
||||
if not pm:
|
||||
raise HTTPException(status_code=404, detail="Manager assignment not found")
|
||||
|
||||
db.delete(pm)
|
||||
db.commit()
|
||||
227
backend/app/api/public.py
Normal file
227
backend/app/api/public.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Public/anonymous endpoints (no auth required)."""
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.models.booking import Booking
|
||||
from app.models.property import Property
|
||||
from app.models.property_manager import PropertyManager
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
from app.schemas.booking import AnonymousBookingCreate, AvailabilityCheck, BookingResponse, ConflictingBooking
|
||||
from app.schemas.property import PropertyResponse
|
||||
from app.schemas.space import SpaceResponse
|
||||
from app.services.booking_service import validate_booking_rules
|
||||
from app.services.email_service import send_booking_notification
|
||||
from app.services.notification_service import create_notification
|
||||
|
||||
router = APIRouter(prefix="/public", tags=["public"])
|
||||
|
||||
|
||||
@router.get("/properties", response_model=list[PropertyResponse])
|
||||
def list_public_properties(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> list[PropertyResponse]:
|
||||
"""List public properties (no auth required)."""
|
||||
properties = (
|
||||
db.query(Property)
|
||||
.filter(Property.is_public == True, Property.is_active == True) # noqa: E712
|
||||
.order_by(Property.name)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for p in properties:
|
||||
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
|
||||
result.append(PropertyResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
address=p.address,
|
||||
is_public=p.is_public,
|
||||
is_active=p.is_active,
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/properties/{property_id}/spaces", response_model=list[SpaceResponse])
|
||||
def list_public_property_spaces(
|
||||
property_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> list[SpaceResponse]:
|
||||
"""List spaces of a public property (no auth required)."""
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
if not prop.is_public:
|
||||
raise HTTPException(status_code=403, detail="Property is private")
|
||||
|
||||
spaces = (
|
||||
db.query(Space)
|
||||
.filter(Space.property_id == property_id, Space.is_active == True) # noqa: E712
|
||||
.order_by(Space.name)
|
||||
.all()
|
||||
)
|
||||
return [SpaceResponse.model_validate(s) for s in spaces]
|
||||
|
||||
|
||||
@router.get("/spaces/{space_id}/availability", response_model=AvailabilityCheck)
|
||||
def check_public_availability(
|
||||
space_id: int,
|
||||
start_datetime: Annotated[datetime, Query()],
|
||||
end_datetime: Annotated[datetime, Query()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> AvailabilityCheck:
|
||||
"""Check availability for a space (no auth required)."""
|
||||
space = db.query(Space).filter(Space.id == space_id).first()
|
||||
if not space:
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
|
||||
# Verify space belongs to a public property
|
||||
if space.property_id:
|
||||
prop = db.query(Property).filter(Property.id == space.property_id).first()
|
||||
if prop and not prop.is_public:
|
||||
raise HTTPException(status_code=403, detail="Property is private")
|
||||
|
||||
# Find conflicting bookings
|
||||
conflicts = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.space_id == space_id,
|
||||
Booking.status.in_(["approved", "pending"]),
|
||||
or_(
|
||||
and_(
|
||||
Booking.start_datetime <= start_datetime,
|
||||
Booking.end_datetime > start_datetime,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime < end_datetime,
|
||||
Booking.end_datetime >= end_datetime,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime >= start_datetime,
|
||||
Booking.end_datetime <= end_datetime,
|
||||
),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not conflicts:
|
||||
return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available")
|
||||
|
||||
approved_count = sum(1 for b in conflicts if b.status == "approved")
|
||||
pending_count = sum(1 for b in conflicts if b.status == "pending")
|
||||
|
||||
if approved_count > 0:
|
||||
message = f"Time slot has {approved_count} approved booking(s)."
|
||||
else:
|
||||
message = f"Time slot has {pending_count} pending request(s)."
|
||||
|
||||
return AvailabilityCheck(
|
||||
available=approved_count == 0,
|
||||
conflicts=[
|
||||
ConflictingBooking(
|
||||
id=b.id,
|
||||
user_name=b.user.full_name if b.user else (b.guest_name or "Anonymous"),
|
||||
title=b.title,
|
||||
status=b.status,
|
||||
start_datetime=b.start_datetime,
|
||||
end_datetime=b.end_datetime,
|
||||
)
|
||||
for b in conflicts
|
||||
],
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bookings", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_anonymous_booking(
|
||||
data: AnonymousBookingCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> BookingResponse:
|
||||
"""Create an anonymous/guest booking (no auth required)."""
|
||||
# Validate space exists
|
||||
space = db.query(Space).filter(Space.id == data.space_id).first()
|
||||
if not space:
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
|
||||
# Verify space belongs to a public property
|
||||
if space.property_id:
|
||||
prop = db.query(Property).filter(Property.id == space.property_id).first()
|
||||
if prop and not prop.is_public:
|
||||
raise HTTPException(status_code=403, detail="Cannot book in a private property without authentication")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Space is not assigned to any property")
|
||||
|
||||
# Basic validation (no user_id needed for anonymous)
|
||||
if data.end_datetime <= data.start_datetime:
|
||||
raise HTTPException(status_code=400, detail="End time must be after start time")
|
||||
|
||||
# Check for overlapping approved bookings
|
||||
overlapping = db.query(Booking).filter(
|
||||
Booking.space_id == data.space_id,
|
||||
Booking.status == "approved",
|
||||
and_(
|
||||
Booking.start_datetime < data.end_datetime,
|
||||
Booking.end_datetime > data.start_datetime,
|
||||
),
|
||||
).first()
|
||||
if overlapping:
|
||||
raise HTTPException(status_code=400, detail="Time slot is already booked")
|
||||
|
||||
# Create anonymous booking
|
||||
booking = Booking(
|
||||
user_id=None,
|
||||
space_id=data.space_id,
|
||||
start_datetime=data.start_datetime,
|
||||
end_datetime=data.end_datetime,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
status="pending",
|
||||
guest_name=data.guest_name,
|
||||
guest_email=data.guest_email,
|
||||
guest_organization=data.guest_organization,
|
||||
is_anonymous=True,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(booking)
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Notify property managers
|
||||
if space.property_id:
|
||||
manager_ids = [
|
||||
pm.user_id
|
||||
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
|
||||
]
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||
# Also notify superadmins
|
||||
superadmins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
|
||||
notify_users = {u.id: u for u in list(managers) + list(superadmins)}
|
||||
|
||||
for user in notify_users.values():
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type="booking_created",
|
||||
title="Cerere Anonimă de Rezervare",
|
||||
message=f"Persoana {data.guest_name} ({data.guest_email}) a solicitat rezervarea spațiului {space.name}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_created",
|
||||
user.email,
|
||||
data.guest_name,
|
||||
{"guest_email": data.guest_email},
|
||||
)
|
||||
|
||||
return BookingResponse.model_validate(booking)
|
||||
@@ -7,7 +7,8 @@ from sqlalchemy import and_, case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_current_admin, get_db
|
||||
from app.core.deps import get_current_manager_or_superadmin, get_db
|
||||
from app.core.permissions import get_manager_property_ids
|
||||
from app.models.booking import Booking
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
@@ -41,8 +42,9 @@ def get_usage_report(
|
||||
start_date: date | None = Query(None),
|
||||
end_date: date | None = Query(None),
|
||||
space_id: int | None = Query(None),
|
||||
property_id: int | None = Query(None),
|
||||
db: Annotated[Session, Depends(get_db)] = None,
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None,
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
|
||||
) -> SpaceUsageReport:
|
||||
"""Get booking usage report by space."""
|
||||
query = (
|
||||
@@ -81,6 +83,13 @@ def get_usage_report(
|
||||
)
|
||||
if space_id:
|
||||
filters.append(Booking.space_id == space_id)
|
||||
if property_id:
|
||||
filters.append(Space.property_id == property_id)
|
||||
|
||||
# Property scoping for managers
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
filters.append(Space.property_id.in_(managed_ids))
|
||||
|
||||
if filters:
|
||||
query = query.filter(and_(*filters))
|
||||
@@ -114,7 +123,7 @@ def get_top_users_report(
|
||||
end_date: date | None = Query(None),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
db: Annotated[Session, Depends(get_db)] = None,
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None,
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
|
||||
) -> TopUsersReport:
|
||||
"""Get top users by booking count."""
|
||||
query = (
|
||||
@@ -129,6 +138,7 @@ def get_top_users_report(
|
||||
func.sum(calculate_hours_expr()).label("total_hours"),
|
||||
)
|
||||
.join(User, Booking.user_id == User.id)
|
||||
.join(Space, Booking.space_id == Space.id)
|
||||
.group_by(Booking.user_id, User.full_name, User.email)
|
||||
)
|
||||
|
||||
@@ -143,6 +153,11 @@ def get_top_users_report(
|
||||
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
|
||||
)
|
||||
|
||||
# Property scoping for managers
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
query = query.filter(Space.property_id.in_(managed_ids))
|
||||
|
||||
# Order by total bookings desc
|
||||
query = query.order_by(func.count(Booking.id).desc()).limit(limit)
|
||||
|
||||
@@ -171,7 +186,7 @@ def get_approval_rate_report(
|
||||
start_date: date | None = Query(None),
|
||||
end_date: date | None = Query(None),
|
||||
db: Annotated[Session, Depends(get_db)] = None,
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None,
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
|
||||
) -> ApprovalRateReport:
|
||||
"""Get approval/rejection rate report."""
|
||||
query = db.query(
|
||||
@@ -180,7 +195,7 @@ def get_approval_rate_report(
|
||||
func.sum(case((Booking.status == "rejected", 1), else_=0)).label("rejected"),
|
||||
func.sum(case((Booking.status == "pending", 1), else_=0)).label("pending"),
|
||||
func.sum(case((Booking.status == "canceled", 1), else_=0)).label("canceled"),
|
||||
)
|
||||
).join(Space, Booking.space_id == Space.id)
|
||||
|
||||
# Apply date filters
|
||||
if start_date:
|
||||
@@ -193,6 +208,11 @@ def get_approval_rate_report(
|
||||
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
|
||||
)
|
||||
|
||||
# Property scoping for managers
|
||||
if current_admin.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
query = query.filter(Space.property_id.in_(managed_ids))
|
||||
|
||||
result = query.first()
|
||||
|
||||
total = result.total or 0
|
||||
|
||||
@@ -4,7 +4,8 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_admin, get_current_user, get_db
|
||||
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
|
||||
from app.core.permissions import get_manager_property_ids
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
from app.schemas.space import SpaceCreate, SpaceResponse, SpaceStatusUpdate, SpaceUpdate
|
||||
@@ -18,36 +19,59 @@ admin_router = APIRouter(prefix="/admin/spaces", tags=["admin"])
|
||||
def list_spaces(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> list[Space]:
|
||||
property_id: int | None = None,
|
||||
) -> list[SpaceResponse]:
|
||||
"""
|
||||
Get list of spaces.
|
||||
|
||||
- Users see only active spaces
|
||||
- Admins see all spaces (active + inactive)
|
||||
- Admins/superadmins/managers see all spaces (active + inactive)
|
||||
"""
|
||||
query = db.query(Space)
|
||||
|
||||
# Filter by property_id if provided
|
||||
if property_id is not None:
|
||||
query = query.filter(Space.property_id == property_id)
|
||||
|
||||
# Filter by active status for non-admin users
|
||||
if current_user.role != "admin":
|
||||
if current_user.role not in ("admin", "superadmin", "manager"):
|
||||
query = query.filter(Space.is_active == True) # noqa: E712
|
||||
elif current_user.role == "manager":
|
||||
managed_ids = get_manager_property_ids(db, current_user.id)
|
||||
if property_id is not None:
|
||||
# When filtering by specific property, manager sees all spaces (active + inactive) IF they manage it
|
||||
if property_id not in managed_ids:
|
||||
query = query.filter(Space.is_active == True) # noqa: E712
|
||||
else:
|
||||
# No property filter: manager sees only their managed properties' spaces
|
||||
query = query.filter(Space.property_id.in_(managed_ids))
|
||||
|
||||
spaces = query.order_by(Space.name).all()
|
||||
return spaces
|
||||
|
||||
# Build response with property_name
|
||||
result = []
|
||||
for s in spaces:
|
||||
resp = SpaceResponse.model_validate(s)
|
||||
if s.property and hasattr(s.property, 'name'):
|
||||
resp.property_name = s.property.name
|
||||
result.append(resp)
|
||||
return result
|
||||
|
||||
|
||||
@admin_router.post("", response_model=SpaceResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_space(
|
||||
space_data: SpaceCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
) -> Space:
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> SpaceResponse:
|
||||
"""
|
||||
Create a new space (admin only).
|
||||
Create a new space (admin/manager).
|
||||
|
||||
- name: required, non-empty
|
||||
- type: "sala" or "birou"
|
||||
- capacity: must be > 0
|
||||
- description: optional
|
||||
- property_id: optional, assign to property
|
||||
"""
|
||||
# Check if space with same name exists
|
||||
existing = db.query(Space).filter(Space.name == space_data.name).first()
|
||||
@@ -57,11 +81,17 @@ def create_space(
|
||||
detail=f"Space with name '{space_data.name}' already exists",
|
||||
)
|
||||
|
||||
# If manager, verify they manage the property
|
||||
if space_data.property_id and current_admin.role == "manager":
|
||||
from app.core.permissions import verify_property_access
|
||||
verify_property_access(db, current_admin, space_data.property_id, require_manager=True)
|
||||
|
||||
space = Space(
|
||||
name=space_data.name,
|
||||
type=space_data.type,
|
||||
capacity=space_data.capacity,
|
||||
description=space_data.description,
|
||||
property_id=space_data.property_id,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@@ -79,7 +109,10 @@ def create_space(
|
||||
details={"name": space.name, "type": space.type, "capacity": space.capacity}
|
||||
)
|
||||
|
||||
return space
|
||||
resp = SpaceResponse.model_validate(space)
|
||||
if space.property and hasattr(space.property, 'name'):
|
||||
resp.property_name = space.property.name
|
||||
return resp
|
||||
|
||||
|
||||
@admin_router.put("/{space_id}", response_model=SpaceResponse)
|
||||
@@ -87,7 +120,7 @@ def update_space(
|
||||
space_id: int,
|
||||
space_data: SpaceUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_admin: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Space:
|
||||
"""
|
||||
Update an existing space (admin only).
|
||||
@@ -101,6 +134,15 @@ def update_space(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this space's property
|
||||
if current_admin.role == "manager" and space.property_id:
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
if space.property_id not in managed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions",
|
||||
)
|
||||
|
||||
# Check if new name conflicts with another space
|
||||
if space_data.name != space.name:
|
||||
existing = db.query(Space).filter(Space.name == space_data.name).first()
|
||||
@@ -147,7 +189,7 @@ def update_space_status(
|
||||
space_id: int,
|
||||
status_data: SpaceStatusUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
) -> Space:
|
||||
"""
|
||||
Activate or deactivate a space (admin only).
|
||||
@@ -161,6 +203,15 @@ def update_space_status(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Verify manager has access to this space's property
|
||||
if current_admin.role == "manager" and space.property_id:
|
||||
managed_ids = get_manager_property_ids(db, current_admin.id)
|
||||
if space.property_id not in managed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions",
|
||||
)
|
||||
|
||||
setattr(space, "is_active", status_data.is_active)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_admin, get_current_user, get_db
|
||||
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
@@ -65,12 +65,12 @@ def update_timezone(
|
||||
@admin_router.get("", response_model=list[UserResponse])
|
||||
def list_users(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
_: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||
role: str | None = None,
|
||||
organization: str | None = None,
|
||||
) -> list[User]:
|
||||
"""
|
||||
Get list of users (admin only).
|
||||
Get list of users (manager or admin).
|
||||
|
||||
Supports filtering by role and organization.
|
||||
"""
|
||||
@@ -109,10 +109,10 @@ def create_user(
|
||||
)
|
||||
|
||||
# Validate role
|
||||
if user_data.role not in ["admin", "user"]:
|
||||
if user_data.role not in ["admin", "superadmin", "manager", "user"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Role must be 'admin' or 'user'",
|
||||
detail="Role must be 'superadmin', 'manager', or 'user'",
|
||||
)
|
||||
|
||||
user = User(
|
||||
@@ -170,10 +170,10 @@ def update_user(
|
||||
)
|
||||
|
||||
# Validate role
|
||||
if user_data.role and user_data.role not in ["admin", "user"]:
|
||||
if user_data.role and user_data.role not in ["admin", "superadmin", "manager", "user"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Role must be 'admin' or 'user'",
|
||||
detail="Role must be 'superadmin', 'manager', or 'user'",
|
||||
)
|
||||
|
||||
# Track what changed
|
||||
|
||||
@@ -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
|
||||
|
||||
115
backend/app/core/permissions.py
Normal file
115
backend/app/core/permissions.py
Normal 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))
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
backend/app/models/organization.py
Normal file
18
backend/app/models/organization.py
Normal 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)
|
||||
17
backend/app/models/organization_member.py
Normal file
17
backend/app/models/organization_member.py
Normal 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"),)
|
||||
20
backend/app/models/property.py
Normal file
20
backend/app/models/property.py
Normal 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)
|
||||
19
backend/app/models/property_access.py
Normal file
19
backend/app/models/property_access.py
Normal 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)
|
||||
16
backend/app/models/property_manager.py
Normal file
16
backend/app/models/property_manager.py
Normal 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"),)
|
||||
20
backend/app/models/property_settings.py
Normal file
20
backend/app/models/property_settings.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
41
backend/app/schemas/organization.py
Normal file
41
backend/app/schemas/organization.py
Normal 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"
|
||||
82
backend/app/schemas/property.py
Normal file
82
backend/app/schemas/property.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user