""" 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, MonitoringLocation, UnitAssignment ) 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.in_(["active", "upcoming", "on_hold"]) ).order_by(Project.name).all() # Build a serializable list of items with dates for calendar bars # Includes both tracked Projects (with dates) and Job Reservations (matching device_type) project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316'] # Map calendar device_type to project_type_ids device_type_to_project_types = { "seismograph": ["vibration_monitoring", "combined"], "slm": ["sound_monitoring", "combined"], } relevant_project_types = device_type_to_project_types.get(device_type, []) calendar_projects = [] for i, p in enumerate(projects): if p.start_date and p.project_type_id in relevant_project_types: calendar_projects.append({ "id": p.id, "name": p.name, "start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat() if p.end_date else None, "color": project_colors[i % len(project_colors)], "confirmed": True, }) # Add job reservations for this device_type as bars from sqlalchemy import or_ as _or cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1) reservations_for_cal = db.query(JobReservation).filter( JobReservation.device_type == device_type, JobReservation.start_date <= cal_window_end, _or( JobReservation.end_date >= date(year, month, 1), JobReservation.end_date == None, ) ).all() for res in reservations_for_cal: end = res.end_date or res.estimated_end_date calendar_projects.append({ "id": res.id, "name": res.name, "start_date": res.start_date.isoformat(), "end_date": end.isoformat() if end else None, "color": res.color, "confirmed": bool(res.project_id), }) # 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, "calendar_projects": calendar_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") import json as _json 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"), estimated_units=data.get("estimated_units"), location_slots=_json.dumps(data["location_slots"]) if data.get("location_slots") is not None else None, 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() # Sort assignments by slot_index so order is preserved assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999)) unit_ids = [a.unit_id for a in assignments_sorted] units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else [] units_by_id = {u.id: u for u in units} # Build per-unit lookups from assignments assignment_map = {a.unit_id: a for a in assignments_sorted} import json as _json stored_slots = _json.loads(reservation.location_slots) if reservation.location_slots else None 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, "estimated_units": reservation.estimated_units, "location_slots": stored_slots, "notes": reservation.notes, "color": reservation.color, "assigned_units": [ { "id": uid, "last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None, "deployed": units_by_id[uid].deployed if uid in units_by_id else False, "power_type": assignment_map[uid].power_type, "notes": assignment_map[uid].notes, "location_name": assignment_map[uid].location_name, "slot_index": assignment_map[uid].slot_index, } for uid in unit_ids ] } @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 "estimated_units" in data: reservation.estimated_units = data["estimated_units"] if "location_slots" in data: import json as _json reservation.location_slots = _json.dumps(data["location_slots"]) if data["location_slots"] is not None else None 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", []) # Optional per-unit dicts keyed by unit_id power_types = data.get("power_types", {}) location_notes = data.get("location_notes", {}) location_names = data.get("location_names", {}) # slot_indices: {"BE17354": 0, "BE9441": 1, ...} slot_indices = data.get("slot_indices", {}) # Verify units exist (allow empty list to clear all assignments) if unit_ids: 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)}") # Full replace: delete all existing assignments for this reservation first db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete() db.flush() # Check for conflicts with other reservations and insert new assignments conflicts = [] for unit_id in unit_ids: # Check overlapping reservations if reservation.end_date: 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", power_type=power_types.get(unit_id), notes=location_notes.get(unit_id), location_name=location_names.get(unit_id), slot_index=slot_indices.get(unit_id), ) 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) # Filter by device_type and date window 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: assignments = db.query(JobReservationUnit).filter_by( reservation_id=res.id ).all() assigned_count = len(assignments) # Enrich assignments with unit details, sorted by slot_index assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999)) unit_ids = [a.unit_id for a in assignments_sorted] units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else [] units_by_id = {u.id: u for u in units} assigned_units = [ { "id": a.unit_id, "power_type": a.power_type, "notes": a.notes, "location_name": a.location_name, "slot_index": a.slot_index, "deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False, "last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None, } for a in assignments_sorted ] # Check for calibration conflicts conflicts = check_calibration_conflicts(db, res.id) location_count = res.quantity_needed or assigned_count reservation_data.append({ "reservation": res, "assigned_count": assigned_count, "location_count": location_count, "assigned_units": assigned_units, "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/planner-availability", response_class=JSONResponse) async def get_planner_availability( device_type: str = "seismograph", start_date: Optional[str] = None, end_date: Optional[str] = None, exclude_reservation_id: Optional[str] = None, db: Session = Depends(get_db) ): """Get available units for the reservation planner split-panel UI. Dates are optional — if omitted, returns all non-retired units regardless of reservations. """ if start_date and end_date: 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") units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id) else: # No dates: return all non-retired units of this type, with current reservation info from backend.models import RosterUnit as RU from datetime import timedelta today = date.today() all_units = db.query(RU).filter( RU.device_type == device_type, RU.retired == False ).all() # Build a map: unit_id -> list of active/upcoming reservations active_assignments = db.query(JobReservationUnit).join( JobReservation, JobReservationUnit.reservation_id == JobReservation.id ).filter( JobReservation.device_type == device_type, JobReservation.end_date >= today ).all() unit_reservations = {} for assignment in active_assignments: res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first() if not res: continue unit_reservations.setdefault(assignment.unit_id, []).append({ "reservation_id": res.id, "reservation_name": res.name, "start_date": res.start_date.isoformat() if res.start_date else None, "end_date": res.end_date.isoformat() if res.end_date else None, "color": res.color or "#3B82F6" }) units = [] for u in all_units: expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None units.append({ "id": u.id, "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, "expiry_date": expiry.isoformat() if expiry else None, "calibration_status": "needs_calibration" if not u.last_calibrated else "valid", "deployed": u.deployed, "out_for_calibration": u.out_for_calibration or False, "note": u.note or "", "reservations": unit_reservations.get(u.id, []) }) # Sort: benched first (easier to assign), then deployed, then by ID units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"])) return { "units": units, "start_date": start_date, "end_date": end_date, "count": len(units) } @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() } ) # ============================================================================ # Promote Reservation to Project # ============================================================================ @router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse) async def promote_reservation_to_project( reservation_id: str, request: Request, db: Session = Depends(get_db) ): """ Promote a job reservation to a full project in the projects DB. Creates: Project + MonitoringLocations + UnitAssignments. """ 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() project_number = data.get("project_number") or None client_name = data.get("client_name") or None # Map device_type to project_type_id if reservation.device_type == "slm": project_type_id = "sound_monitoring" location_type = "sound" else: project_type_id = "vibration_monitoring" location_type = "vibration" # Check for duplicate project name existing = db.query(Project).filter_by(name=reservation.name).first() if existing: raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.") # Create the project project_id = str(uuid.uuid4()) project = Project( id=project_id, name=reservation.name, project_number=project_number, client_name=client_name, project_type_id=project_type_id, status="upcoming", start_date=reservation.start_date, end_date=reservation.end_date, description=reservation.notes, ) db.add(project) db.flush() # Load assignments sorted by slot_index assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all() assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999)) locations_created = 0 units_assigned = 0 for i, assignment in enumerate(assignments_sorted): loc_num = str(i + 1).zfill(3) loc_name = assignment.location_name or f"Location {i + 1}" location = MonitoringLocation( id=str(uuid.uuid4()), project_id=project_id, location_type=location_type, name=loc_name, description=assignment.notes, ) db.add(location) db.flush() locations_created += 1 if assignment.unit_id: unit_assignment = UnitAssignment( id=str(uuid.uuid4()), unit_id=assignment.unit_id, location_id=location.id, project_id=project_id, device_type=reservation.device_type or "seismograph", status="active", notes=f"Power: {assignment.power_type}" if assignment.power_type else None, ) db.add(unit_assignment) units_assigned += 1 db.commit() logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}") return { "success": True, "project_id": project_id, "project_name": reservation.name, "locations_created": locations_created, "units_assigned": units_assigned, }