877 lines
31 KiB
Python
877 lines
31 KiB
Python
"""
|
|
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,
|
|
}
|