update main to v0.10.0 #48

Merged
serversdown merged 32 commits from feature/sfm-integration into main 2026-05-14 16:56:43 -04:00
2 changed files with 275 additions and 4 deletions
Showing only changes of commit 09db988a35 - Show all commits
+128
View File
@@ -453,6 +453,134 @@ async def unassign_unit(
return {"success": True, "message": "Unit unassigned successfully"} return {"success": True, "message": "Unit unassigned successfully"}
@router.patch("/assignments/{assignment_id}")
async def update_assignment(
project_id: str,
assignment_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Update an assignment's date window and/or notes.
Common use case: backdate a deployment so events emitted before the
operator created the assignment in terra-view (e.g. a unit that was
physically deployed in December but only recorded in the system today)
get correctly attributed to the location.
Accepts JSON body with optional fields:
- assigned_at: ISO datetime (or empty string to leave unchanged)
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
- notes: string
Sets `status` to "active" when assigned_until is cleared, "completed"
when it's set in the past.
"""
assignment = db.query(UnitAssignment).filter_by(
id=assignment_id,
project_id=project_id,
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
# Parse new values (None = unchanged, explicit None/"" for assigned_until = clear)
new_assigned_at = assignment.assigned_at
new_assigned_until = assignment.assigned_until
new_notes = assignment.notes
if "assigned_at" in payload:
raw = payload["assigned_at"]
if raw is None or raw == "":
raise HTTPException(
status_code=400,
detail="assigned_at is required; cannot be cleared.",
)
try:
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
new_assigned_at = datetime.fromisoformat(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid assigned_at datetime: {raw!r}",
)
if "assigned_until" in payload:
raw = payload["assigned_until"]
if raw is None or raw == "":
new_assigned_until = None
else:
try:
new_assigned_until = datetime.fromisoformat(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid assigned_until datetime: {raw!r}",
)
if "notes" in payload:
raw = payload["notes"]
new_notes = (raw or "").strip() or None
# Validation: end must be after start if both set.
if new_assigned_until is not None and new_assigned_until <= new_assigned_at:
raise HTTPException(
status_code=400,
detail="assigned_until must be after assigned_at.",
)
# Sanity: reject creating an overlap with another assignment of the SAME
# unit at the SAME location. Different units at the same location can
# legitimately overlap during a swap window (rare but valid).
new_end_for_overlap = new_assigned_until or datetime.utcnow()
overlapping = (
db.query(UnitAssignment)
.filter(UnitAssignment.location_id == assignment.location_id)
.filter(UnitAssignment.unit_id == assignment.unit_id)
.filter(UnitAssignment.id != assignment.id)
.all()
)
for other in overlapping:
other_start = other.assigned_at
other_end = other.assigned_until or datetime.utcnow()
if new_assigned_at < other_end and new_end_for_overlap > other_start:
raise HTTPException(
status_code=400,
detail=(
f"This window overlaps with another assignment for the "
f"same unit ({other.assigned_at:%Y-%m-%d}"
f"{other.assigned_until and other.assigned_until.strftime('%Y-%m-%d') or 'present'})."
),
)
# Apply.
assignment.assigned_at = new_assigned_at
assignment.assigned_until = new_assigned_until
assignment.notes = new_notes
assignment.status = "completed" if new_assigned_until is not None else "active"
db.commit()
db.refresh(assignment)
return {
"success": True,
"assignment": {
"id": assignment.id,
"unit_id": assignment.unit_id,
"location_id": assignment.location_id,
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
"assigned_until": assignment.assigned_until.isoformat() if assignment.assigned_until else None,
"status": assignment.status,
"notes": assignment.notes,
},
}
@router.post("/locations/{location_id}/swap") @router.post("/locations/{location_id}/swap")
async def swap_unit_on_location( async def swap_unit_on_location(
project_id: str, project_id: str,
+147 -4
View File
@@ -220,6 +220,66 @@
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span> <span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
</div> </div>
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div> <div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
<span class="inline-block w-4 text-center"></span>
Click the pencil to backdate a deployment so historical events get attributed to this location.
</p>
</div>
<!-- Edit-assignment modal -->
<div id="assignment-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Edit Deployment Window</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<span id="ae-unit-label" class="font-mono text-seismo-orange"></span>
</p>
</div>
<button onclick="closeAssignmentEditModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="assignment-edit-form" class="p-6 space-y-4">
<input type="hidden" id="ae-assignment-id">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned From</label>
<input type="datetime-local" id="ae-assigned-at" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Assigned Until
<span class="text-xs text-gray-500 ml-1">(leave blank if still active)</span>
</label>
<input type="datetime-local" id="ae-assigned-until"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="ae-notes" rows="2" placeholder="Optional — e.g. 'backdated to reflect physical install date'"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div id="ae-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignmentEditModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" id="ae-submit-btn"
class="px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save
</button>
</div>
</form>
</div>
</div> </div>
<!-- Filters --> <!-- Filters -->
@@ -504,17 +564,100 @@ function renderAssignmentsUsed(assignments) {
const badge = isActive const badge = isActive
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>' ? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: ''; : '';
return `<div class="py-2 flex items-center justify-between"> const editAttr = encodeURIComponent(JSON.stringify({
<div> id: a.assignment_id,
unit_id: a.unit_id,
assigned_at: a.assigned_at,
assigned_until: a.assigned_until,
}));
return `<div class="py-2 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a> <a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
${badge} ${badge}
<span class="ml-3 text-sm text-gray-600 dark:text-gray-400">${start}${end}</span> <span class="text-sm text-gray-600 dark:text-gray-400">${start}${end}</span>
<button type="button"
onclick="openAssignmentEditModal('${editAttr}')"
title="Edit deployment dates"
class="text-gray-400 hover:text-seismo-orange transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
</div> </div>
<span class="text-sm text-gray-700 dark:text-gray-300">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span> <span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
</div>`; </div>`;
}).join(''); }).join('');
} }
// ── Assignment-edit modal ───────────────────────────────────────────────────
function _isoToInputValue(iso) {
// Convert "2026-04-14T02:19:27" (or "2026-04-14 02:19:27") to "2026-04-14T02:19" for datetime-local input.
if (!iso) return '';
const cleaned = iso.replace(' ', 'T');
return cleaned.slice(0, 16);
}
function openAssignmentEditModal(encodedJson) {
const data = JSON.parse(decodeURIComponent(encodedJson));
document.getElementById('ae-assignment-id').value = data.id;
document.getElementById('ae-unit-label').textContent = data.unit_id;
document.getElementById('ae-assigned-at').value = _isoToInputValue(data.assigned_at);
document.getElementById('ae-assigned-until').value = _isoToInputValue(data.assigned_until);
document.getElementById('ae-notes').value = '';
document.getElementById('ae-error').classList.add('hidden');
document.getElementById('assignment-edit-modal').classList.remove('hidden');
}
function closeAssignmentEditModal() {
document.getElementById('assignment-edit-modal').classList.add('hidden');
}
document.getElementById('assignment-edit-form').addEventListener('submit', async function(e) {
e.preventDefault();
const errEl = document.getElementById('ae-error');
errEl.classList.add('hidden');
const assignmentId = document.getElementById('ae-assignment-id').value;
const assignedAt = document.getElementById('ae-assigned-at').value;
const assignedUntil = document.getElementById('ae-assigned-until').value;
const notes = document.getElementById('ae-notes').value.trim();
if (!assignedAt) {
errEl.textContent = 'Assigned From is required.';
errEl.classList.remove('hidden');
return;
}
const payload = { assigned_at: assignedAt };
payload.assigned_until = assignedUntil || null;
if (notes) payload.notes = notes;
const btn = document.getElementById('ae-submit-btn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
closeAssignmentEditModal();
await loadLocationEvents(); // Refresh stats + table with new window.
} catch (err) {
errEl.textContent = err.message || 'Failed to update assignment.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Save';
}
});
document.getElementById('assignment-edit-modal').addEventListener('click', function(e) {
if (e.target === this) closeAssignmentEditModal();
});
function renderEventTable(events, total, container) { function renderEventTable(events, total, container) {
if (!events || events.length === 0) { if (!events || events.length === 0) {
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden'); const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');