diff --git a/backend/app/api/audit_log.py b/backend/app/api/audit_log.py index 74fc441..3a32426 100644 --- a/backend/app/api/audit_log.py +++ b/backend/app/api/audit_log.py @@ -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) diff --git a/backend/app/api/bookings.py b/backend/app/api/bookings.py index 846f5e3..04f3d7c 100644 --- a/backend/app/api/bookings.py +++ b/backend/app/api/bookings.py @@ -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] diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py new file mode 100644 index 0000000..b5a6d01 --- /dev/null +++ b/backend/app/api/organizations.py @@ -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, + ) diff --git a/backend/app/api/properties.py b/backend/app/api/properties.py new file mode 100644 index 0000000..1bb01e2 --- /dev/null +++ b/backend/app/api/properties.py @@ -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() diff --git a/backend/app/api/public.py b/backend/app/api/public.py new file mode 100644 index 0000000..1ea4c3a --- /dev/null +++ b/backend/app/api/public.py @@ -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) diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index fa14f47..fe38547 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -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 diff --git a/backend/app/api/spaces.py b/backend/app/api/spaces.py index 109011c..a3d4922 100644 --- a/backend/app/api/spaces.py +++ b/backend/app/api/spaces.py @@ -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() diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 5ba0b25..80f64b2 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -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 diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 0222603..3fb2df8 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -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 diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py new file mode 100644 index 0000000..4a73179 --- /dev/null +++ b/backend/app/core/permissions.py @@ -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)) diff --git a/backend/app/main.py b/backend/app/main.py index acaeee1..d6fd1cf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 445b05c..2aa3070 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/booking.py b/backend/app/models/booking.py index bfe8f16..8a7171a 100644 --- a/backend/app/models/booking.py +++ b/backend/app/models/booking.py @@ -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) diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py new file mode 100644 index 0000000..0962675 --- /dev/null +++ b/backend/app/models/organization.py @@ -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) diff --git a/backend/app/models/organization_member.py b/backend/app/models/organization_member.py new file mode 100644 index 0000000..d5fcf4f --- /dev/null +++ b/backend/app/models/organization_member.py @@ -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"),) diff --git a/backend/app/models/property.py b/backend/app/models/property.py new file mode 100644 index 0000000..76a30de --- /dev/null +++ b/backend/app/models/property.py @@ -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) diff --git a/backend/app/models/property_access.py b/backend/app/models/property_access.py new file mode 100644 index 0000000..c530a5e --- /dev/null +++ b/backend/app/models/property_access.py @@ -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) diff --git a/backend/app/models/property_manager.py b/backend/app/models/property_manager.py new file mode 100644 index 0000000..7b1e475 --- /dev/null +++ b/backend/app/models/property_manager.py @@ -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"),) diff --git a/backend/app/models/property_settings.py b/backend/app/models/property_settings.py new file mode 100644 index 0000000..4e0ec91 --- /dev/null +++ b/backend/app/models/property_settings.py @@ -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) diff --git a/backend/app/models/space.py b/backend/app/models/space.py index 7dbe1e3..bfac14b 100644 --- a/backend/app/models/space.py +++ b/backend/app/models/space.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 86bd593..b8d23c6 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/schemas/booking.py b/backend/app/schemas/booking.py index 9179f8e..59232e8 100644 --- a/backend/app/schemas/booking.py +++ b/backend/app/schemas/booking.py @@ -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 diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py new file mode 100644 index 0000000..03ae934 --- /dev/null +++ b/backend/app/schemas/organization.py @@ -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" diff --git a/backend/app/schemas/property.py b/backend/app/schemas/property.py new file mode 100644 index 0000000..ede5c35 --- /dev/null +++ b/backend/app/schemas/property.py @@ -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 diff --git a/backend/app/schemas/space.py b/backend/app/schemas/space.py index b0a2769..77db2c5 100644 --- a/backend/app/schemas/space.py +++ b/backend/app/schemas/space.py @@ -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 diff --git a/backend/app/services/booking_service.py b/backend/app/services/booking_service.py index 78ba187..3d7480e 100644 --- a/backend/app/services/booking_service.py +++ b/backend/app/services/booking_service.py @@ -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 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 9e85c67..7db6035 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -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 """ diff --git a/backend/migrate_to_multi_property.py b/backend/migrate_to_multi_property.py new file mode 100644 index 0000000..ff01aeb --- /dev/null +++ b/backend/migrate_to_multi_property.py @@ -0,0 +1,106 @@ +"""Migration script to add multi-property support to existing database.""" +import sys +import os + +# Add backend to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from sqlalchemy import inspect, text +from app.db.session import Base, SessionLocal, engine +from app.models import ( + Organization, OrganizationMember, Property, PropertyAccess, + PropertyManager, PropertySettings, User, Space, +) + + +def migrate(): + """Run migration to add multi-property tables and data.""" + db = SessionLocal() + inspector = inspect(engine) + existing_tables = inspector.get_table_names() + + print("Starting multi-property migration...") + + # Step 1: Create all new tables + print("1. Creating new tables...") + Base.metadata.create_all(bind=engine) + print(" Tables created successfully.") + + # Step 2: Add property_id column to spaces if not exists + space_columns = [col["name"] for col in inspector.get_columns("spaces")] + if "property_id" not in space_columns: + print("2. Adding property_id column to spaces...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE spaces ADD COLUMN property_id INTEGER REFERENCES properties(id)")) + conn.commit() + print(" Column added.") + else: + print("2. property_id column already exists in spaces.") + + # Step 3: Add guest columns to bookings if not exists + booking_columns = [col["name"] for col in inspector.get_columns("bookings")] + with engine.connect() as conn: + if "guest_name" not in booking_columns: + print("3. Adding guest columns to bookings...") + conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_name VARCHAR")) + conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_email VARCHAR")) + conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_organization VARCHAR")) + conn.execute(text("ALTER TABLE bookings ADD COLUMN is_anonymous BOOLEAN DEFAULT 0 NOT NULL")) + conn.commit() + print(" Guest columns added.") + else: + print("3. Guest columns already exist in bookings.") + + # Step 4: Create "Default Property" + print("4. Creating Default Property...") + existing_default = db.query(Property).filter(Property.name == "Default Property").first() + if not existing_default: + default_prop = Property( + name="Default Property", + description="Default property for migrated spaces", + is_public=True, + is_active=True, + ) + db.add(default_prop) + db.flush() + + # Step 5: Migrate existing spaces to Default Property + print("5. Migrating existing spaces to Default Property...") + spaces_without_property = db.query(Space).filter(Space.property_id == None).all() # noqa: E711 + for space in spaces_without_property: + space.property_id = default_prop.id + db.flush() + print(f" Migrated {len(spaces_without_property)} spaces.") + + # Step 6: Rename admin users to superadmin + print("6. Updating admin roles to superadmin...") + admin_users = db.query(User).filter(User.role == "admin").all() + for u in admin_users: + u.role = "superadmin" + db.flush() + print(f" Updated {len(admin_users)} users.") + + # Step 7: Create PropertyManager entries for superadmins + print("7. Creating PropertyManager entries for superadmins...") + superadmins = db.query(User).filter(User.role == "superadmin").all() + for sa in superadmins: + existing_pm = db.query(PropertyManager).filter( + PropertyManager.property_id == default_prop.id, + PropertyManager.user_id == sa.id, + ).first() + if not existing_pm: + db.add(PropertyManager(property_id=default_prop.id, user_id=sa.id)) + db.flush() + print(f" Created entries for {len(superadmins)} superadmins.") + + db.commit() + print("\nMigration completed successfully!") + else: + print(" Default Property already exists. Skipping data migration.") + print("\nMigration already applied.") + + db.close() + + +if __name__ == "__main__": + migrate() diff --git a/backend/seed_db.py b/backend/seed_db.py index 332c748..f49182e 100644 --- a/backend/seed_db.py +++ b/backend/seed_db.py @@ -1,12 +1,18 @@ -"""Seed database with initial data.""" +"""Seed database with initial data for multi-property system.""" from app.core.security import get_password_hash from app.db.session import Base, SessionLocal, engine +from app.models.organization import Organization +from app.models.organization_member import OrganizationMember +from app.models.property import Property +from app.models.property_access import PropertyAccess +from app.models.property_manager import PropertyManager from app.models.settings import Settings +from app.models.space import Space from app.models.user import User def seed_database() -> None: - """Create initial users for testing.""" + """Create initial data for testing multi-property system.""" # Create tables Base.metadata.create_all(bind=engine) @@ -18,16 +24,27 @@ def seed_database() -> None: print("Database already seeded. Skipping...") return - # Create admin user - admin = User( + # Create superadmin user + superadmin = User( email="admin@example.com", - full_name="Admin User", + full_name="Super Admin", hashed_password=get_password_hash("adminpassword"), - role="admin", + role="superadmin", organization="Management", is_active=True, ) - db.add(admin) + db.add(superadmin) + + # Create manager user + manager = User( + email="manager@example.com", + full_name="Property Manager", + hashed_password=get_password_hash("managerpassword"), + role="manager", + organization="Management", + is_active=True, + ) + db.add(manager) # Create regular user user = User( @@ -40,6 +57,93 @@ def seed_database() -> None: ) db.add(user) + db.flush() # Get IDs + + # Create properties + prop1 = Property( + name="Clădirea Centrală", + description="Clădirea principală din centru", + address="Str. Principală nr. 1", + is_public=True, + is_active=True, + ) + db.add(prop1) + + prop2 = Property( + name="Biroul Privat", + description="Spațiu privat pentru echipă", + address="Str. Secundară nr. 5", + is_public=False, + is_active=True, + ) + db.add(prop2) + + db.flush() # Get property IDs + + # Assign manager to both properties + db.add(PropertyManager(property_id=prop1.id, user_id=manager.id)) + db.add(PropertyManager(property_id=prop2.id, user_id=manager.id)) + + # Create spaces (2 in first property, 1 in second) + space1 = Space( + name="Sala Mare", + type="sala", + capacity=20, + description="Sală de conferințe mare", + is_active=True, + property_id=prop1.id, + ) + db.add(space1) + + space2 = Space( + name="Birou A1", + type="birou", + capacity=4, + description="Birou deschis", + is_active=True, + property_id=prop1.id, + ) + db.add(space2) + + space3 = Space( + name="Sala Privată", + type="sala", + capacity=10, + description="Sală privată pentru echipă", + is_active=True, + property_id=prop2.id, + ) + db.add(space3) + + # Create organizations + org1 = Organization( + name="Engineering", + description="Echipa de dezvoltare", + is_active=True, + ) + db.add(org1) + + org2 = Organization( + name="Management", + description="Echipa de management", + is_active=True, + ) + db.add(org2) + + db.flush() # Get org IDs + + # Create organization members + db.add(OrganizationMember(organization_id=org1.id, user_id=user.id, role="member")) + db.add(OrganizationMember(organization_id=org2.id, user_id=manager.id, role="admin")) + db.add(OrganizationMember(organization_id=org2.id, user_id=superadmin.id, role="admin")) + + # Grant user access to private property + db.add(PropertyAccess( + property_id=prop2.id, + user_id=user.id, + granted_by=manager.id, + )) + # Create default settings if not exist existing_settings = db.query(Settings).filter(Settings.id == 1).first() if not existing_settings: @@ -55,9 +159,12 @@ def seed_database() -> None: db.add(default_settings) db.commit() - print("✓ Database seeded successfully!") - print("Admin: admin@example.com / adminpassword") + print("Database seeded successfully!") + print("Superadmin: admin@example.com / adminpassword") + print("Manager: manager@example.com / managerpassword") print("User: user@example.com / userpassword") + print(f"Properties: '{prop1.name}' (public), '{prop2.name}' (private)") + print(f"Organizations: '{org1.name}', '{org2.name}'") except Exception as e: print(f"Error seeding database: {e}") db.rollback() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bc08a14..bb93e46 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,10 +1,10 @@