""" Fleet Calendar Router API endpoints for the Fleet Calendar feature: - Calendar page and data - Job reservation CRUD - Unit assignment management - Availability checking """ from fastapi import APIRouter, Request, Depends, HTTPException, Query from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from datetime import datetime, date, timedelta from typing import Optional, List import uuid import logging from backend.database import get_db from backend.models import ( RosterUnit, JobReservation, JobReservationUnit, UserPreferences, Project ) from backend.templates_config import templates from backend.services.fleet_calendar_service import ( get_day_summary, get_calendar_year_data, get_rolling_calendar_data, check_calibration_conflicts, get_available_units_for_period, get_calibration_status ) router = APIRouter(tags=["fleet-calendar"]) logger = logging.getLogger(__name__) # ============================================================================ # Calendar Page # ============================================================================ @router.get("/fleet-calendar", response_class=HTMLResponse) async def fleet_calendar_page( request: Request, year: Optional[int] = None, month: Optional[int] = None, device_type: str = "seismograph", db: Session = Depends(get_db) ): """Main Fleet Calendar page with rolling 12-month view.""" today = date.today() # Default to current month as the start if year is None: year = today.year if month is None: month = today.month # Get calendar data for 12 months starting from year/month calendar_data = get_rolling_calendar_data(db, year, month, device_type) # Get projects for the reservation form dropdown projects = db.query(Project).filter( Project.status == "active" ).order_by(Project.name).all() # Calculate prev/next month navigation prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1) next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1) return templates.TemplateResponse( "fleet_calendar.html", { "request": request, "start_year": year, "start_month": month, "prev_year": prev_year, "prev_month": prev_month, "next_year": next_year, "next_month": next_month, "device_type": device_type, "calendar_data": calendar_data, "projects": projects, "today": today.isoformat() } ) # ============================================================================ # Calendar Data API # ============================================================================ @router.get("/api/fleet-calendar/data", response_class=JSONResponse) async def get_calendar_data( year: int, device_type: str = "seismograph", db: Session = Depends(get_db) ): """Get calendar data for a specific year.""" return get_calendar_year_data(db, year, device_type) @router.get("/api/fleet-calendar/day/{date_str}", response_class=HTMLResponse) async def get_day_detail( request: Request, date_str: str, device_type: str = "seismograph", db: Session = Depends(get_db) ): """Get detailed view for a specific day (HTMX partial).""" try: check_date = date.fromisoformat(date_str) except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") day_data = get_day_summary(db, check_date, device_type) # Get projects for display names projects = {p.id: p for p in db.query(Project).all()} return templates.TemplateResponse( "partials/fleet_calendar/day_detail.html", { "request": request, "day_data": day_data, "date_str": date_str, "date_display": check_date.strftime("%B %d, %Y"), "device_type": device_type, "projects": projects } ) # ============================================================================ # Reservation CRUD # ============================================================================ @router.post("/api/fleet-calendar/reservations", response_class=JSONResponse) async def create_reservation( request: Request, db: Session = Depends(get_db) ): """Create a new job reservation.""" data = await request.json() # Validate required fields required = ["name", "start_date", "assignment_type"] for field in required: if field not in data: raise HTTPException(status_code=400, detail=f"Missing required field: {field}") # Need either end_date or end_date_tbd end_date_tbd = data.get("end_date_tbd", False) if not end_date_tbd and not data.get("end_date"): raise HTTPException(status_code=400, detail="End date is required unless marked as TBD") try: start_date = date.fromisoformat(data["start_date"]) end_date = date.fromisoformat(data["end_date"]) if data.get("end_date") else None estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data.get("estimated_end_date") else None except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") if end_date and end_date < start_date: raise HTTPException(status_code=400, detail="End date must be after start date") if estimated_end_date and estimated_end_date < start_date: raise HTTPException(status_code=400, detail="Estimated end date must be after start date") reservation = JobReservation( id=str(uuid.uuid4()), name=data["name"], project_id=data.get("project_id"), start_date=start_date, end_date=end_date, estimated_end_date=estimated_end_date, end_date_tbd=end_date_tbd, assignment_type=data["assignment_type"], device_type=data.get("device_type", "seismograph"), quantity_needed=data.get("quantity_needed"), notes=data.get("notes"), color=data.get("color", "#3B82F6") ) db.add(reservation) # If specific units were provided, assign them if data.get("unit_ids") and data["assignment_type"] == "specific": for unit_id in data["unit_ids"]: assignment = JobReservationUnit( id=str(uuid.uuid4()), reservation_id=reservation.id, unit_id=unit_id, assignment_source="specific" ) db.add(assignment) db.commit() logger.info(f"Created reservation: {reservation.name} ({reservation.id})") return { "success": True, "reservation_id": reservation.id, "message": f"Created reservation: {reservation.name}" } @router.get("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse) async def get_reservation( reservation_id: str, db: Session = Depends(get_db) ): """Get a specific reservation with its assigned units.""" reservation = db.query(JobReservation).filter_by(id=reservation_id).first() if not reservation: raise HTTPException(status_code=404, detail="Reservation not found") # Get assigned units assignments = db.query(JobReservationUnit).filter_by( reservation_id=reservation_id ).all() unit_ids = [a.unit_id for a in assignments] units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else [] return { "id": reservation.id, "name": reservation.name, "project_id": reservation.project_id, "start_date": reservation.start_date.isoformat(), "end_date": reservation.end_date.isoformat() if reservation.end_date else None, "estimated_end_date": reservation.estimated_end_date.isoformat() if reservation.estimated_end_date else None, "end_date_tbd": reservation.end_date_tbd, "assignment_type": reservation.assignment_type, "device_type": reservation.device_type, "quantity_needed": reservation.quantity_needed, "notes": reservation.notes, "color": reservation.color, "assigned_units": [ { "id": u.id, "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, "deployed": u.deployed } for u in units ] } @router.put("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse) async def update_reservation( reservation_id: str, request: Request, db: Session = Depends(get_db) ): """Update an existing reservation.""" reservation = db.query(JobReservation).filter_by(id=reservation_id).first() if not reservation: raise HTTPException(status_code=404, detail="Reservation not found") data = await request.json() # Update fields if provided if "name" in data: reservation.name = data["name"] if "project_id" in data: reservation.project_id = data["project_id"] if "start_date" in data: reservation.start_date = date.fromisoformat(data["start_date"]) if "end_date" in data: reservation.end_date = date.fromisoformat(data["end_date"]) if data["end_date"] else None if "estimated_end_date" in data: reservation.estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data["estimated_end_date"] else None if "end_date_tbd" in data: reservation.end_date_tbd = data["end_date_tbd"] if "assignment_type" in data: reservation.assignment_type = data["assignment_type"] if "quantity_needed" in data: reservation.quantity_needed = data["quantity_needed"] if "notes" in data: reservation.notes = data["notes"] if "color" in data: reservation.color = data["color"] reservation.updated_at = datetime.utcnow() db.commit() logger.info(f"Updated reservation: {reservation.name} ({reservation.id})") return { "success": True, "message": f"Updated reservation: {reservation.name}" } @router.delete("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse) async def delete_reservation( reservation_id: str, db: Session = Depends(get_db) ): """Delete a reservation and its unit assignments.""" reservation = db.query(JobReservation).filter_by(id=reservation_id).first() if not reservation: raise HTTPException(status_code=404, detail="Reservation not found") # Delete unit assignments first db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete() # Delete the reservation db.delete(reservation) db.commit() logger.info(f"Deleted reservation: {reservation.name} ({reservation_id})") return { "success": True, "message": "Reservation deleted" } # ============================================================================ # Unit Assignment # ============================================================================ @router.post("/api/fleet-calendar/reservations/{reservation_id}/assign-units", response_class=JSONResponse) async def assign_units_to_reservation( reservation_id: str, request: Request, db: Session = Depends(get_db) ): """Assign specific units to a reservation.""" reservation = db.query(JobReservation).filter_by(id=reservation_id).first() if not reservation: raise HTTPException(status_code=404, detail="Reservation not found") data = await request.json() unit_ids = data.get("unit_ids", []) if not unit_ids: raise HTTPException(status_code=400, detail="No units specified") # Verify units exist units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() found_ids = {u.id for u in units} missing = set(unit_ids) - found_ids if missing: raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}") # Check for conflicts (already assigned to overlapping reservations) conflicts = [] for unit_id in unit_ids: # Check if unit is already assigned to this reservation existing = db.query(JobReservationUnit).filter_by( reservation_id=reservation_id, unit_id=unit_id ).first() if existing: continue # Already assigned, skip # Check overlapping reservations overlapping = db.query(JobReservation).join( JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id ).filter( JobReservationUnit.unit_id == unit_id, JobReservation.id != reservation_id, JobReservation.start_date <= reservation.end_date, JobReservation.end_date >= reservation.start_date ).first() if overlapping: conflicts.append({ "unit_id": unit_id, "conflict_reservation": overlapping.name, "conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}" }) continue # Add assignment assignment = JobReservationUnit( id=str(uuid.uuid4()), reservation_id=reservation_id, unit_id=unit_id, assignment_source="filled" if reservation.assignment_type == "quantity" else "specific" ) db.add(assignment) db.commit() # Check for calibration conflicts cal_conflicts = check_calibration_conflicts(db, reservation_id) assigned_count = db.query(JobReservationUnit).filter_by( reservation_id=reservation_id ).count() return { "success": True, "assigned_count": assigned_count, "conflicts": conflicts, "calibration_warnings": cal_conflicts, "message": f"Assigned {len(unit_ids) - len(conflicts)} units" } @router.delete("/api/fleet-calendar/reservations/{reservation_id}/units/{unit_id}", response_class=JSONResponse) async def remove_unit_from_reservation( reservation_id: str, unit_id: str, db: Session = Depends(get_db) ): """Remove a unit from a reservation.""" assignment = db.query(JobReservationUnit).filter_by( reservation_id=reservation_id, unit_id=unit_id ).first() if not assignment: raise HTTPException(status_code=404, detail="Unit assignment not found") db.delete(assignment) db.commit() return { "success": True, "message": f"Removed {unit_id} from reservation" } # ============================================================================ # Availability & Conflicts # ============================================================================ @router.get("/api/fleet-calendar/availability", response_class=JSONResponse) async def check_availability( start_date: str, end_date: str, device_type: str = "seismograph", exclude_reservation_id: Optional[str] = None, db: Session = Depends(get_db) ): """Get units available for a specific date range.""" try: start = date.fromisoformat(start_date) end = date.fromisoformat(end_date) except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") available = get_available_units_for_period( db, start, end, device_type, exclude_reservation_id ) return { "start_date": start_date, "end_date": end_date, "device_type": device_type, "available_units": available, "count": len(available) } @router.get("/api/fleet-calendar/reservations/{reservation_id}/conflicts", response_class=JSONResponse) async def get_reservation_conflicts( reservation_id: str, db: Session = Depends(get_db) ): """Check for calibration conflicts in a reservation.""" reservation = db.query(JobReservation).filter_by(id=reservation_id).first() if not reservation: raise HTTPException(status_code=404, detail="Reservation not found") conflicts = check_calibration_conflicts(db, reservation_id) return { "reservation_id": reservation_id, "reservation_name": reservation.name, "conflicts": conflicts, "has_conflicts": len(conflicts) > 0 } # ============================================================================ # HTMX Partials # ============================================================================ @router.get("/api/fleet-calendar/reservations-list", response_class=HTMLResponse) async def get_reservations_list( request: Request, year: Optional[int] = None, month: Optional[int] = None, device_type: str = "seismograph", db: Session = Depends(get_db) ): """Get list of reservations as HTMX partial.""" from sqlalchemy import or_ today = date.today() if year is None: year = today.year if month is None: month = today.month # Calculate 12-month window start_date = date(year, month, 1) # End date is 12 months later end_year = year + ((month + 10) // 12) end_month = ((month + 10) % 12) + 1 if end_month == 12: end_date = date(end_year, 12, 31) else: end_date = date(end_year, end_month + 1, 1) - timedelta(days=1) # Include TBD reservations that started before window end reservations = db.query(JobReservation).filter( JobReservation.device_type == device_type, JobReservation.start_date <= end_date, or_( JobReservation.end_date >= start_date, JobReservation.end_date == None # TBD reservations ) ).order_by(JobReservation.start_date).all() # Get assignment counts reservation_data = [] for res in reservations: assigned_count = db.query(JobReservationUnit).filter_by( reservation_id=res.id ).count() # Check for calibration conflicts conflicts = check_calibration_conflicts(db, res.id) reservation_data.append({ "reservation": res, "assigned_count": assigned_count, "has_conflicts": len(conflicts) > 0, "conflict_count": len(conflicts) }) return templates.TemplateResponse( "partials/fleet_calendar/reservations_list.html", { "request": request, "reservations": reservation_data, "year": year, "device_type": device_type } ) @router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse) async def get_available_units_partial( request: Request, start_date: str, end_date: str, device_type: str = "seismograph", reservation_id: Optional[str] = None, db: Session = Depends(get_db) ): """Get available units as HTMX partial for the assignment modal.""" try: start = date.fromisoformat(start_date) end = date.fromisoformat(end_date) except ValueError: raise HTTPException(status_code=400, detail="Invalid date format") available = get_available_units_for_period( db, start, end, device_type, reservation_id ) return templates.TemplateResponse( "partials/fleet_calendar/available_units.html", { "request": request, "units": available, "start_date": start_date, "end_date": end_date, "device_type": device_type, "reservation_id": reservation_id } ) @router.get("/api/fleet-calendar/month/{year}/{month}", response_class=HTMLResponse) async def get_month_partial( request: Request, year: int, month: int, device_type: str = "seismograph", db: Session = Depends(get_db) ): """Get a single month calendar as HTMX partial.""" calendar_data = get_calendar_year_data(db, year, device_type) month_data = calendar_data["months"].get(month) if not month_data: raise HTTPException(status_code=404, detail="Invalid month") return templates.TemplateResponse( "partials/fleet_calendar/month_grid.html", { "request": request, "year": year, "month": month, "month_data": month_data, "device_type": device_type, "today": date.today().isoformat() } )