Compare commits
2 Commits
472c25372d
...
a073b9b06e
| Author | SHA1 | Date | |
|---|---|---|---|
| a073b9b06e | |||
| 502bf5bbeb |
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
def _parse_user_dt(s: str | None, field: str):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
assigned_at = (
|
parsed = datetime.fromisoformat(s)
|
||||||
datetime.fromisoformat(assigned_at_str)
|
|
||||||
if assigned_at_str else datetime.utcnow()
|
|
||||||
)
|
|
||||||
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:
|
||||||
|
if old_unit.deployed_with_modem_id:
|
||||||
old_modem = db.query(RosterUnit).filter_by(
|
old_modem = db.query(RosterUnit).filter_by(
|
||||||
id=old_unit.deployed_with_modem_id, device_type="modem"
|
id=old_unit.deployed_with_modem_id, device_type="modem"
|
||||||
).first()
|
).first()
|
||||||
if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
|
if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
|
||||||
old_modem.deployed_with_unit_id = None
|
old_modem.deployed_with_unit_id = None
|
||||||
old_unit.deployed_with_modem_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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user