diff --git a/backend/main.py b/backend/main.py index f88e18c..aca1b1f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -109,6 +109,9 @@ app.include_router(watcher_manager.router) from backend.routers import admin_modules app.include_router(admin_modules.router) +from backend.routers import deployment_history +app.include_router(deployment_history.router) + # Projects system routers app.include_router(projects.router) app.include_router(project_locations.router) diff --git a/backend/routers/deployment_history.py b/backend/routers/deployment_history.py new file mode 100644 index 0000000..2beece5 --- /dev/null +++ b/backend/routers/deployment_history.py @@ -0,0 +1,99 @@ +""" +Fleet-wide deployment-history calendar — Phase 2 of the +deployment-history visualisation work (Phase 1 is the per-unit Gantt +on /unit/{id}). + +Renders all UnitAssignment windows across all projects on a 12-month +calendar grid styled like the Job Planner. Each day cell shows one +mini-bar per project that had ≥1 active assignment that day. Click a +day → side panel with the (unit, location) pairs active. + +Routes: + GET /tools/deployment-history — HTML page + GET /api/admin/deployment-history/day — JSON list of deployments + on a specific date (used + by the day-detail panel) +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.services.deployment_history import ( + get_deployment_history_data, + get_deployments_on_day, +) +from backend.templates_config import templates + +router = APIRouter() + + +@router.get("/tools/deployment-history", response_class=HTMLResponse) +def deployment_history_page( + request: Request, + year: Optional[int] = Query(None), + month: Optional[int] = Query(None), + db: Session = Depends(get_db), +): + """Fleet-wide deployment history calendar. + + Defaults to a 12-month window ending in the current month (so the + operator sees the recent past, not the future). ?year=&month= can + override the START of the window to scroll backward or forward. + """ + today = date.today() + # Default: 12-month window ending this month → start = 11 months back. + if year is None or month is None: + # 11 months back from current month. + m = today.month - 11 + y = today.year + while m < 1: + m += 12 + y -= 1 + start_year, start_month = y, m + else: + start_year, start_month = year, month + + calendar = get_deployment_history_data(db, start_year, start_month) + + # Build prev/next navigation values. + prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1) + next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1) + + return templates.TemplateResponse("admin/deployment_history.html", { + "request": request, + "calendar": calendar, + "today": today.isoformat(), + "prev_year": prev_y, + "prev_month": prev_m, + "next_year": next_y, + "next_month": next_m, + }) + + +@router.get("/api/admin/deployment-history/day") +def deployment_history_day( + target_date: str = Query(..., description="YYYY-MM-DD"), + db: Session = Depends(get_db), +): + """Return assignments active on a specific calendar day.""" + try: + d = date.fromisoformat(target_date) + except ValueError: + return JSONResponse( + {"error": f"Invalid date: {target_date!r}"}, + status_code=400, + ) + + deployments = get_deployments_on_day(db, d) + return JSONResponse({ + "date": target_date, + "count": len(deployments), + "deployments": deployments, + }) diff --git a/backend/services/deployment_history.py b/backend/services/deployment_history.py new file mode 100644 index 0000000..0a5bd27 --- /dev/null +++ b/backend/services/deployment_history.py @@ -0,0 +1,316 @@ +""" +Deployment-history calendar service — builds the data structure for the +fleet-wide deployment-history grid (`/tools/deployment-history`). + +For each calendar day in a 12-month window, computes which projects had +at least one unit assigned to a location on that day. Renders as +multi-month grid (job-planner style) with project-colored bars per day. + +Distinct from `services/fleet_calendar_service.py` which renders +forward-looking RESERVATIONS for the planner. This one is purely +historical / current — it walks `unit_assignments` instead of +`job_reservations`. +""" + +from __future__ import annotations + +import hashlib +from datetime import date, datetime, timedelta +from calendar import monthrange +from typing import Optional + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from backend.models import Project, UnitAssignment + + +# Color palette for projects without an explicit color attribute. Chosen +# to have decent contrast on both light and dark backgrounds; cycles +# deterministically by SHA1(project_id). +_PROJECT_COLOR_PALETTE = [ + "#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d", + "#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8", + "#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c", +] + + +def _color_for_project(project_id: str) -> str: + """Deterministic color assignment from a fixed palette.""" + h = hashlib.sha1(project_id.encode("utf-8")).digest()[0] + return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)] + + +def _month_short(m: int) -> str: + return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1] + + +def _month_full(m: int) -> str: + return ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"][m - 1] + + +def get_deployment_history_data( + db: Session, + start_year: int, + start_month: int, +) -> dict: + """ + Build the calendar data structure for a 12-month window starting at + (start_year, start_month). + + Returns: + { + "months": [ + { + "year": int, + "month": int, # 1-12 + "name": "January", + "short_name": "Jan", + "year_short": "26", + "num_days": int, + "first_weekday": int, # 0=Mon..6=Sun (datetime.weekday()) + "active_days": { + day_num: [project_id, project_id, ...] # projects with + # ≥1 active assignment + # on that day + }, + }, + ... # 12 entries + ], + "projects": [ + { + "id": str, + "name": str, + "color": str, + "status": str, + "client_name": str | None, + "assignment_count": int, # total assignments contributing to + # this 12-month window + "first_active": "YYYY-MM-DD" | None, + "last_active": "YYYY-MM-DD" | None, + }, + ... # only projects with + # ≥1 assignment in the + # window, sorted by + # first_active ASC + ], + "total_assignments": int, + "total_active_units": int, # distinct unit_ids across the window + "window": { + "start_year": int, + "start_month": int, + "end_year": int, + "end_month": int, + "first_date": "YYYY-MM-DD", + "last_date": "YYYY-MM-DD", + }, + } + """ + # Compute window edges. + first_date = date(start_year, start_month, 1) + # 12 months → end on day-1 of (start + 12) + end_year = start_year + ((start_month + 10) // 12) + end_month = ((start_month + 10) % 12) + 1 + last_date = date(end_year, end_month, monthrange(end_year, end_month)[1]) + + now = datetime.utcnow() + + # Fetch every assignment that overlaps the window. An assignment + # overlaps if assigned_at <= last_date AND (assigned_until is NULL + # OR assigned_until >= first_date). + assignments = ( + db.query(UnitAssignment) + .filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time())) + .filter( + or_( + UnitAssignment.assigned_until == None, # noqa: E711 — active + UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()), + ) + ) + .all() + ) + + # Resolve referenced projects in one query. + proj_ids = {a.project_id for a in assignments} + proj_map = { + p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all() + } if proj_ids else {} + + # Compute "active days per project" by walking each assignment and + # adding every day in its [start, end] ∩ [first_date, last_date]. + # O(N_assignments × avg_window_days); for a typical fleet this is + # bounded (hundreds of assignments × hundreds of days = manageable). + project_active_days: dict[str, set[date]] = {} + project_first_active: dict[str, date] = {} + project_last_active: dict[str, date] = {} + project_assignment_count: dict[str, int] = {} + distinct_units: set[str] = set() + + for a in assignments: + start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date) + end_dt = a.assigned_until or now + end = min(end_dt.date(), last_date) + if end < start: + continue + days = project_active_days.setdefault(a.project_id, set()) + d = start + while d <= end: + days.add(d) + d += timedelta(days=1) + project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1 + distinct_units.add(a.unit_id) + # Track first/last active dates in the window. + prev_first = project_first_active.get(a.project_id) + if prev_first is None or start < prev_first: + project_first_active[a.project_id] = start + prev_last = project_last_active.get(a.project_id) + if prev_last is None or end > prev_last: + project_last_active[a.project_id] = end + + # Build the projects array (sorted by first_active ascending so the + # legend reads in deployment-order). + projects_data = [] + for pid, days in project_active_days.items(): + p = proj_map.get(pid) + if not p: + # Assignment references a deleted project — surface it anyway + # with a placeholder name, since the bars still need a label. + projects_data.append({ + "id": pid, + "name": "(deleted project)", + "color": _color_for_project(pid), + "status": "deleted", + "client_name": None, + "assignment_count": project_assignment_count.get(pid, 0), + "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, + "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + }) + continue + projects_data.append({ + "id": pid, + "name": p.name, + "color": _color_for_project(pid), + "status": p.status or "active", + "client_name": p.client_name, + "assignment_count": project_assignment_count.get(pid, 0), + "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, + "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + }) + + projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"])) + + # Now build the months array. + months_data = [] + cur_year, cur_month = start_year, start_month + for _ in range(12): + num_days = monthrange(cur_year, cur_month)[1] + first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun + active_days: dict[int, list[str]] = {} + for day_num in range(1, num_days + 1): + d = date(cur_year, cur_month, day_num) + day_projects = [ + pid for pid, days in project_active_days.items() + if d in days + ] + if day_projects: + # Sort by the project's color-stable order so bars don't + # jitter between days. + day_projects.sort() + active_days[day_num] = day_projects + months_data.append({ + "year": cur_year, + "month": cur_month, + "name": _month_full(cur_month), + "short_name": _month_short(cur_month), + "year_short": f"{cur_year % 100:02d}", + "num_days": num_days, + "first_weekday": first_weekday, + "active_days": active_days, + }) + # Advance one month. + if cur_month == 12: + cur_year += 1 + cur_month = 1 + else: + cur_month += 1 + + return { + "months": months_data, + "projects": projects_data, + "total_assignments": len(assignments), + "total_active_units": len(distinct_units), + "window": { + "start_year": start_year, + "start_month": start_month, + "end_year": end_year, + "end_month": end_month, + "first_date": first_date.isoformat(), + "last_date": last_date.isoformat(), + }, + } + + +def get_deployments_on_day( + db: Session, + target_date: date, +) -> list[dict]: + """ + Return the list of (unit, location, project) tuples that were + actively assigned on a specific calendar date. Used for the + day-detail side panel when an operator clicks a day cell. + """ + from backend.models import MonitoringLocation, RosterUnit + + day_start = datetime.combine(target_date, datetime.min.time()) + day_end = datetime.combine(target_date, datetime.max.time()) + + rows = ( + db.query(UnitAssignment) + .filter(UnitAssignment.assigned_at <= day_end) + .filter( + or_( + UnitAssignment.assigned_until == None, # noqa: E711 + UnitAssignment.assigned_until >= day_start, + ) + ) + .order_by(UnitAssignment.project_id, UnitAssignment.unit_id) + .all() + ) + + if not rows: + return [] + + loc_ids = {a.location_id for a in rows} + proj_ids = {a.project_id for a in rows} + loc_map = { + l.id: l for l in db.query(MonitoringLocation).filter( + MonitoringLocation.id.in_(loc_ids) + ).all() + } + proj_map = { + p.id: p for p in db.query(Project).filter( + Project.id.in_(proj_ids) + ).all() + } + + results = [] + for a in rows: + loc = loc_map.get(a.location_id) + proj = proj_map.get(a.project_id) + results.append({ + "assignment_id": a.id, + "unit_id": a.unit_id, + "location_id": a.location_id, + "location_name": loc.name if loc else "(unknown location)", + "project_id": a.project_id, + "project_name": proj.name if proj else "(deleted project)", + "project_color": _color_for_project(a.project_id), + "assigned_at": a.assigned_at.isoformat() if a.assigned_at else None, + "assigned_until": a.assigned_until.isoformat() if a.assigned_until else None, + "is_active": a.assigned_until is None, + "source": a.source, + }) + + return results diff --git a/templates/admin/deployment_history.html b/templates/admin/deployment_history.html new file mode 100644 index 0000000..b3fdf48 --- /dev/null +++ b/templates/admin/deployment_history.html @@ -0,0 +1,346 @@ +{% extends "base.html" %} + +{% block title %}Deployment History - Seismo Fleet Manager{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ Where every unit has been — actual assignment windows, color-coded by project. + For future / planned deployments use the Job Planner. +
+No deployments in this window.
+Try the navigation buttons below to look at a different range.
++ 12-month calendar of every unit assignment across every project. Visual bars per project per day; click a day for the full active-deployments list. +
+