2 Commits

Author SHA1 Message Date
serversdown a073b9b06e fix(deployment-timeline): respect user timezone for display and edits
Deployment timestamps were stored correctly as UTC but rendered raw —
a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the
naive UTC ISO string straight to the screen.

Display side: deployment_timeline.py now converts every emitted
timestamp (starts_at, ends_at, event_overlay.peak_pvs_at and last_event)
through `utc_to_local()` using the user's configured timezone from
UserPreferences before serializing.  Frontend slice keeps working — it
just slices a local-time string now.

Write side (so the new edit / add-historical-assignment modals stay
consistent):
  - PATCH /api/projects/{pid}/assignments/{aid}
  - POST  /api/projects/{pid}/locations/{loc}/assign
both now interpret a *naive* assigned_at / assigned_until ISO string as
the user's local time and convert to UTC for storage via
`local_to_utc()`.  Explicit tz-aware strings ("...Z" or "...+00:00")
skip the conversion so programmatic callers that already speak UTC
keep working.

Verified live: BE13121's stored 2026-01-28 18:06:29 UTC now serializes
as 2026-01-28 13:06:29 in the timeline endpoint; PATCHing
"2026-01-28T13:06:29" round-trips back to the same UTC value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:45:52 +00:00
serversdown 502bf5bbeb fix(roster): bench outgoing unit on swap / unassign / deploy-classify
The legacy RosterUnit.deployed flag drives heartbeat polling and
benched-vs-deployed roster filters.  Three workflows ended an
assignment without flipping it, so the outgoing unit kept being
polled and showed up as "deployed" forever:

  - swap endpoint            (POST /locations/{loc}/swap)
  - unassign endpoint        (POST /assignments/{aid}/unassign)
  - promote-pending endpoint (POST /deployments/pending/{id}/promote)

All three now: close the previous active assignment, break the
outgoing unit's modem pairing (both directions), and set
`deployed = False` on the outgoing unit.  Unassign and swap also
clear the modem's back-reference.

The promote-pending path additionally handles the case where the
target location already has an active assignment — that previously
silently created two active assignments at the same location.  Now
the old one is closed (assigned_until = pending capture time, status
= completed), the old unit is benched and unpaired, and an
"assignment_swapped" history row is written.  Incoming unit gets
`deployed = True` if it was on the bench.

Verified live: triggered a swap via the existing endpoint and saw
the outgoing unit flip True → False while the incoming flipped
False → True.  Test mutations rolled back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:04:09 +00:00
3 changed files with 123 additions and 38 deletions
+36
View File
@@ -336,6 +336,36 @@ async def promote_pending(
db.add(location) db.add(location)
db.flush() db.flush()
# If this location already has an active assignment, the /deploy
# capture means someone replaced that unit in the field — close the
# old assignment, break the outgoing unit's modem pairing, and bench
# it so the heartbeat / polling subsystem stops chasing it.
existing_active = db.query(UnitAssignment).filter(
UnitAssignment.location_id == location.id,
UnitAssignment.assigned_until == None, # noqa: E711
).first()
if existing_active and existing_active.unit_id != pd.unit_id:
existing_active.assigned_until = pd.captured_at
existing_active.status = "completed"
old_unit = db.query(RosterUnit).filter_by(id=existing_active.unit_id).first()
if old_unit:
if old_unit.deployed_with_modem_id:
old_modem = db.query(RosterUnit).filter_by(
id=old_unit.deployed_with_modem_id, device_type="modem"
).first()
if old_modem and old_modem.deployed_with_unit_id == old_unit.id:
old_modem.deployed_with_unit_id = None
old_unit.deployed_with_modem_id = None
if old_unit.deployed:
old_unit.deployed = False
_record_history(
db, unit_id=existing_active.unit_id,
change_type="assignment_swapped",
old_value=location.name,
new_value=f"superseded by /deploy capture → {pd.unit_id}",
notes=notes,
)
# Create the assignment. assigned_at = pending capture time (so # Create the assignment. assigned_at = pending capture time (so
# events emitted after the install are correctly attributed back # events emitted after the install are correctly attributed back
# to this location). # to this location).
@@ -354,6 +384,12 @@ async def promote_pending(
db.add(assignment) db.add(assignment)
db.flush() db.flush()
# Incoming unit is in the field again — flip it back to deployed
# if it was on the bench (mirrors the swap endpoint).
incoming_unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
if incoming_unit and not incoming_unit.deployed:
incoming_unit.deployed = True
# Promote the pending row. # Promote the pending row.
pd.status = "assigned" pd.status = "assigned"
pd.promoted_at = datetime.utcnow() pd.promoted_at = datetime.utcnow()
+54 -31
View File
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo
from typing import Optional from typing import Optional
import uuid import uuid
import json import json
@@ -34,7 +35,7 @@ from backend.models import (
ScheduledAction, ScheduledAction,
) )
from backend.templates_config import templates from backend.templates_config import templates
from backend.utils.timezone import local_to_utc from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
@@ -761,27 +762,22 @@ async def assign_unit_to_location(
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'", detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
) )
# Parse dates. # Parse dates. Naive datetimes from datetime-local inputs are
assigned_at_str = form_data.get("assigned_at") # interpreted as user-local and converted to UTC for storage; explicit
assigned_until_str = form_data.get("assigned_until") # tz-aware ISO strings (Z / +00:00) skip the conversion.
try: def _parse_user_dt(s: str | None, field: str):
assigned_at = ( if not s:
datetime.fromisoformat(assigned_at_str) return None
if assigned_at_str else datetime.utcnow() try:
) parsed = datetime.fromisoformat(s)
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException( raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}")
status_code=400, detail=f"Invalid assigned_at: {assigned_at_str!r}" if parsed.tzinfo is None:
) return local_to_utc(parsed)
try: return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
assigned_until = (
datetime.fromisoformat(assigned_until_str) assigned_at = _parse_user_dt(form_data.get("assigned_at"), "assigned_at") or datetime.utcnow()
if assigned_until_str else None assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until")
)
except (TypeError, ValueError):
raise HTTPException(
status_code=400, detail=f"Invalid assigned_until: {assigned_until_str!r}"
)
if assigned_until is not None and assigned_until <= assigned_at: if assigned_until is not None and assigned_until <= assigned_at:
raise HTTPException( raise HTTPException(
status_code=400, detail="assigned_until must be after assigned_at.", status_code=400, detail="assigned_until must be after assigned_at.",
@@ -876,6 +872,20 @@ async def unassign_unit(
assignment.status = "completed" assignment.status = "completed"
assignment.assigned_until = datetime.utcnow() assignment.assigned_until = datetime.utcnow()
# Unit is leaving the field — bench it so the heartbeat / polling
# subsystem stops chasing it. Also break the modem pairing both ways.
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if unit:
if unit.deployed_with_modem_id:
modem = db.query(RosterUnit).filter_by(
id=unit.deployed_with_modem_id, device_type="modem"
).first()
if modem and modem.deployed_with_unit_id == unit.id:
modem.deployed_with_unit_id = None
unit.deployed_with_modem_id = None
if unit.deployed:
unit.deployed = False
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first() location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
_record_assignment_history( _record_assignment_history(
db, db,
@@ -910,6 +920,11 @@ async def update_assignment(
- assigned_until: ISO datetime, or null/"" to mark indefinite (active) - assigned_until: ISO datetime, or null/"" to mark indefinite (active)
- notes: string - notes: string
Naive datetimes (no tz suffix) are interpreted as the user's
configured timezone and converted to UTC for storage. Send an
explicit "+00:00" / "Z" suffix to skip the conversion (programmatic
callers that already have UTC).
Sets `status` to "active" when assigned_until is cleared, "completed" Sets `status` to "active" when assigned_until is cleared, "completed"
when it's set in the past. when it's set in the past.
""" """
@@ -940,12 +955,14 @@ async def update_assignment(
) )
try: try:
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO. # Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
new_assigned_at = datetime.fromisoformat(raw) parsed = datetime.fromisoformat(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid assigned_at datetime: {raw!r}", detail=f"Invalid assigned_at datetime: {raw!r}",
) )
# Naive (no tz) → treat as user's local time and store as UTC.
new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
if "assigned_until" in payload: if "assigned_until" in payload:
raw = payload["assigned_until"] raw = payload["assigned_until"]
@@ -953,12 +970,13 @@ async def update_assignment(
new_assigned_until = None new_assigned_until = None
else: else:
try: try:
new_assigned_until = datetime.fromisoformat(raw) parsed = datetime.fromisoformat(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid assigned_until datetime: {raw!r}", detail=f"Invalid assigned_until datetime: {raw!r}",
) )
new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
if "notes" in payload: if "notes" in payload:
raw = payload["notes"] raw = payload["notes"]
@@ -1320,13 +1338,18 @@ async def swap_unit_on_location(
# deployed_with_modem_id / deployed_with_unit_id back-reference # deployed_with_modem_id / deployed_with_unit_id back-reference
# doesn't orphan onto the unit that just left the field. # doesn't orphan onto the unit that just left the field.
old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first() old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first()
if old_unit and old_unit.deployed_with_modem_id: if old_unit:
old_modem = db.query(RosterUnit).filter_by( if old_unit.deployed_with_modem_id:
id=old_unit.deployed_with_modem_id, device_type="modem" old_modem = db.query(RosterUnit).filter_by(
).first() id=old_unit.deployed_with_modem_id, device_type="modem"
if old_modem and old_modem.deployed_with_unit_id == current.unit_id: ).first()
old_modem.deployed_with_unit_id = None if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
old_unit.deployed_with_modem_id = None old_modem.deployed_with_unit_id = None
old_unit.deployed_with_modem_id = None
# Bench the outgoing unit — it's no longer in the field, so
# the heartbeat / polling subsystem should stop chasing it.
if old_unit.deployed:
old_unit.deployed = False
# Create new assignment # Create new assignment
new_assignment = UnitAssignment( new_assignment = UnitAssignment(
+33 -7
View File
@@ -39,9 +39,35 @@ from backend.services.sfm_events import (
_fetch_events_for_serial, _fetch_events_for_serial,
_iso_utc, _iso_utc,
) )
from backend.utils.timezone import utc_to_local
log = logging.getLogger("backend.services.deployment_timeline") log = logging.getLogger("backend.services.deployment_timeline")
def _iso_local(dt) -> Optional[str]:
"""Serialize a datetime / ISO-string in the user's configured timezone.
The timeline frontend slices these strings to character 19 to produce
"YYYY-MM-DD HH:MM:SS" — no JS-side timezone conversion happens. We
therefore emit *already-local* timestamps here so the displayed time
matches what the operator actually saw on the wall clock.
Accepts either a ``datetime`` (DB column) or an ISO ``str`` (SFM
response). Returns ``None`` for ``None`` input. Naive ISO strings
from SFM are interpreted as UTC.
"""
if dt is None:
return None
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt.replace("Z", "").replace(" ", "T"))
except ValueError:
return dt # give up gracefully — emit whatever SFM sent
local = utc_to_local(dt)
if local is None:
return None
return local.replace(tzinfo=None).isoformat()
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual # Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
# clutter from a sub-second handoff during a swap workflow. # clutter from a sub-second handoff during a swap workflow.
_MIN_GAP_SECONDS = 24 * 3600 # 1 day _MIN_GAP_SECONDS = 24 * 3600 # 1 day
@@ -185,8 +211,8 @@ async def deployment_timeline_for_unit(
overlays[a.id] = { overlays[a.id] = {
"event_count": len(events), "event_count": len(events),
"peak_pvs": peak, "peak_pvs": peak,
"peak_pvs_at": peak_at, "peak_pvs_at": _iso_local(peak_at),
"last_event": last_ev, "last_event": _iso_local(last_ev),
} }
# 4. Build entries. Start by emitting assignment rows + gap rows between # 4. Build entries. Start by emitting assignment rows + gap rows between
@@ -202,8 +228,8 @@ async def deployment_timeline_for_unit(
entry = { entry = {
"kind": "assignment", "kind": "assignment",
"starts_at": _iso_utc(a.assigned_at), "starts_at": _iso_local(a.assigned_at),
"ends_at": _iso_utc(a.assigned_until), "ends_at": _iso_local(a.assigned_until),
"duration_days": round(duration_days, 1) if duration_days is not None else None, "duration_days": round(duration_days, 1) if duration_days is not None else None,
"assignment_id": a.id, "assignment_id": a.id,
"location_id": a.location_id, "location_id": a.location_id,
@@ -227,8 +253,8 @@ async def deployment_timeline_for_unit(
if gap_seconds >= _MIN_GAP_SECONDS: if gap_seconds >= _MIN_GAP_SECONDS:
entries.append({ entries.append({
"kind": "gap", "kind": "gap",
"starts_at": _iso_utc(gap_start), "starts_at": _iso_local(gap_start),
"ends_at": _iso_utc(gap_end), "ends_at": _iso_local(gap_end),
"duration_days": round(gap_seconds / 86400, 1), "duration_days": round(gap_seconds / 86400, 1),
"context": "between assignments", "context": "between assignments",
}) })
@@ -241,7 +267,7 @@ async def deployment_timeline_for_unit(
continue continue
entries.append({ entries.append({
"kind": "state_change", "kind": "state_change",
"starts_at": _iso_utc(h.changed_at), "starts_at": _iso_local(h.changed_at),
"ends_at": None, "ends_at": None,
"duration_days": None, "duration_days": None,
"change_type": h.change_type, "change_type": h.change_type,