7ed94cd8fc
Third view on /tools/deployment-history. Where 'Gantt by Project' has
one row per project showing that project's deployments, 'Gantt by Unit'
inverts it — one row per seismograph, bars colored by the project the
unit was deployed to.
The natural use case: "where has BE11529 been across all my jobs?"
Spotting unit rotation patterns, idle gaps, and concurrent assignments
gets immediate visually.
Service
- deployment_history.get_deployment_history_data() now also returns a
`units` array. Each unit dict carries:
{id, bars[], first_active, assignment_count, any_active}
Each bar has the project_name + project_color baked in so the
renderer can paint by job without a second lookup.
- Units sorted: currently-active first, then by first_active ascending.
UI
- Third tab "Gantt by Unit" added next to Calendar / Gantt by Project.
- Tab switcher refactored to a small registry (_DH_TABS) so adding more
views in the future is a one-line addition.
- URL hash sync now supports #gantt and #byunit; nav buttons preserve
the active tab across month-paging.
- SVG layout: 160px label gutter (smaller than the project Gantt's
220px since unit IDs are short), 32px row height, green dot for
units with at least one active deployment. Unit ID is clickable
→ /unit/{id}; each bar is clickable → /projects/{p}.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
388 lines
15 KiB
Python
388 lines
15 KiB
Python
"""
|
||
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
|