""" 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 {} # Resolve location names in one batch query (used by the Gantt view # for per-bar tooltips). from backend.models import MonitoringLocation loc_ids = {a.location_id for a in assignments} loc_name_map = { l.id: l.name for l in db.query(MonitoringLocation).filter( MonitoringLocation.id.in_(loc_ids) ).all() } if loc_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). # Also collect raw per-assignment bar data for the Gantt view. 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] = {} project_bars: dict[str, list[dict]] = {} 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 # Per-assignment bar data — used by the Gantt view's renderer. # `is_active` reflects whether the assignment_until was still NULL # at fetch time (open-ended deployment); the clipped `end` here # is just for visual bar drawing. project_bars.setdefault(a.project_id, []).append({ "unit_id": a.unit_id, "location_id": a.location_id, "location_name": loc_name_map.get(a.location_id, "(unknown location)"), "start": start.isoformat(), "end": end.isoformat(), "is_active": a.assigned_until is None, "source": a.source, }) # 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, "bars": project_bars.get(pid, []), }) 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, "bars": project_bars.get(pid, []), }) projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"])) # ── Per-unit view data (Gantt-by-Unit tab) ──────────────────────── # Same source assignments, re-grouped by unit_id. Each bar carries # the project's color + name so the renderer can paint by job # without doing a second lookup. unit_bars: dict[str, list[dict]] = {} project_lookup = {p["id"]: p for p in projects_data} 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 p_info = project_lookup.get(a.project_id, {}) unit_bars.setdefault(a.unit_id, []).append({ "project_id": a.project_id, "project_name": p_info.get("name", "(deleted project)"), "project_color": p_info.get("color", _color_for_project(a.project_id)), "location_id": a.location_id, "location_name": loc_name_map.get(a.location_id, "(unknown location)"), "start": start.isoformat(), "end": end.isoformat(), "is_active": a.assigned_until is None, "source": a.source, }) # Sort units by first-active date so the most-recently-deployed # units sit at the top. Reverse if we want oldest-first. units_data = [] for uid, bars in unit_bars.items(): bars.sort(key=lambda b: b["start"]) first_start = bars[0]["start"] # "active now" flag = any bar is still active any_active = any(b["is_active"] for b in bars) units_data.append({ "id": uid, "bars": bars, "first_active": first_start, "assignment_count": len(bars), "any_active": any_active, }) units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"])) # 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, "units": units_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