fix(phase26): server trip delete persists across recompute + batch delete with checkboxes

This commit is contained in:
Mathieu Moran
2026-08-11 04:18:58 -04:00
parent e6bc488e84
commit bdc7a9afb7
6 changed files with 133 additions and 5 deletions
+16 -1
View File
@@ -571,10 +571,11 @@ def geocode():
@app.post("/api/v1/portal/recompute-trips")
@require_login
def recompute_trips():
"""Manually trigger trip recomputation. Called after upload batch for freshness."""
"""Recompute trips from scratch, clearing all deletion markers."""
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
db.clear_deleted_trip_markers(device_id)
n = db.compute_trips_for_device(device_id)
return jsonify(status="ok", trips_computed=n)
@@ -591,6 +592,20 @@ def portal_delete_trip(trip_id):
return jsonify(status="ok")
@app.post("/api/v1/portal/trips/delete")
@require_login
def portal_delete_trips_batch():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
body = request.get_json(silent=True) or {}
ids = body.get("ids")
if not isinstance(ids, list) or not ids:
return jsonify(error="expected {ids: [...]}"), 400
deleted = db.delete_trips_batch(device_id, ids)
return jsonify(status="ok", deleted=deleted)
@app.delete("/api/v1/portal/dashcam/<int:file_id>")
@require_login
def portal_delete_dashcam(file_id):
+1
View File
@@ -13,6 +13,7 @@ services:
- ./sql/migrate_phase24.sql:/docker-entrypoint-initdb.d/migrate_phase24.sql:ro
- ./sql/migrate_phase25_5a.sql:/docker-entrypoint-initdb.d/migrate_phase25_5a.sql:ro
- ./sql/migrate_phase25_5c.sql:/docker-entrypoint-initdb.d/migrate_phase25_5c.sql:ro
- ./sql/migrate_phase26_deleted_trips.sql:/docker-entrypoint-initdb.d/migrate_phase26_deleted_trips.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
interval: 10s
+44 -3
View File
@@ -398,6 +398,15 @@ def compute_trips_for_device(device_id):
"point_count": len(seg_rows),
})
# Skip trips that were manually deleted (recompute won't re-insert them).
with get_cursor() as cur:
cur.execute(
"SELECT start_time FROM deleted_trip_markers WHERE device_id = %s",
(device_id,),
)
deleted_starts = {row[0] for row in cur.fetchall()}
trips = [t for t in trips if t["start_time"] not in deleted_starts]
with get_cursor(commit=True) as cur:
for t in trips:
cur.execute(
@@ -540,13 +549,45 @@ def get_dashcam_file(device_id, file_id):
def delete_trip(device_id, trip_id):
"""Delete a computed trip record. Raw tracking_points are NOT removed."""
"""Delete a computed trip record and mark its time range as deleted
so auto-recompute doesn't re-insert it. Raw tracking_points are kept."""
with get_cursor(commit=True) as cur:
# Record the start_time before deleting so recompute skips this trip.
cur.execute(
"DELETE FROM trips WHERE id = %s AND device_id = %s RETURNING id",
"SELECT start_time FROM trips WHERE id = %s AND device_id = %s",
(trip_id, device_id),
)
return cur.fetchone() is not None
row = cur.fetchone()
if row is None:
return False
cur.execute(
"""INSERT INTO deleted_trip_markers (device_id, start_time)
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
(device_id, row[0]),
)
cur.execute(
"DELETE FROM trips WHERE id = %s AND device_id = %s",
(trip_id, device_id),
)
return True
def delete_trips_batch(device_id, trip_ids):
"""Delete multiple trips in one transaction."""
deleted = 0
for tid in trip_ids:
if delete_trip(device_id, tid):
deleted += 1
return deleted
def clear_deleted_trip_markers(device_id):
"""Remove all deleted trip markers so recompute rebuilds everything."""
with get_cursor(commit=True) as cur:
cur.execute(
"DELETE FROM deleted_trip_markers WHERE device_id = %s",
(device_id,),
)
def delete_dashcam_file(device_id, file_id):
@@ -0,0 +1,10 @@
-- Migration: deleted trip markers to prevent recompute from re-inserting
-- manually deleted trips. Each row records a trip the user explicitly
-- removed. compute_trips_for_device() skips time ranges matching any marker.
-- The "Recompute trips" button clears all markers before rebuilding.
CREATE TABLE IF NOT EXISTS deleted_trip_markers (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
start_time TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, start_time)
);
+5 -1
View File
@@ -14,8 +14,11 @@
{# ── Trip list ──────────────────────────────────────────────────── #}
<div class="col-12 col-xl-5">
<div class="card shadow-sm">
<div class="card-header py-2">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">Recorded trips</h6>
<button id="delete-selected-btn" class="btn btn-sm btn-outline-danger" type="button" hidden title="Delete selected trips">
<i class="fas fa-trash fa-fw"></i> Delete selected
</button>
</div>
<div class="card-body p-0">
<div id="trip-loading" class="text-center text-muted py-4">
@@ -38,6 +41,7 @@
true
) }}
<script>document.getElementById('trip-list').dataset.deleteUrl = '/api/v1/portal/trip/';</script>
<script>document.getElementById('trip-list').dataset.batchDeleteUrl = '/api/v1/portal/trips/delete';</script>
</div>
</div>
</div>
+57
View File
@@ -232,6 +232,53 @@
const tripList = document.getElementById("trip-list");
const deletable = tripList && tripList.dataset.deletable === "1";
const deleteUrl = (tripList && tripList.dataset.deleteUrl) || "/api/trip/";
const batchDeleteUrl = tripList && tripList.dataset.batchDeleteUrl;
const deleteSelectedBtn = document.getElementById("delete-selected-btn");
function getCheckedIds() {
return Array.from(body.querySelectorAll("input.trip-checkbox:checked"))
.map(cb => parseInt(cb.value));
}
function updateDeleteSelectedBtn() {
if (!deleteSelectedBtn) return;
const count = getCheckedIds().length;
deleteSelectedBtn.hidden = count === 0;
deleteSelectedBtn.textContent = `Delete selected (${count})`;
}
function selectAllTrips() {
const cbs = body.querySelectorAll("input.trip-checkbox");
const allChecked = cbs.length > 0 && Array.from(cbs).every(cb => cb.checked);
cbs.forEach(cb => { cb.checked = !allChecked; });
updateDeleteSelectedBtn();
}
async function deleteSelected() {
const ids = getCheckedIds();
if (!ids.length || !batchDeleteUrl) return;
const confirmed = await window.confirmDelete(
`Delete ${ids.length} selected trip(s)?\n\nRaw GPS data is kept — only computed trip records are removed.`
);
if (!confirmed) return;
try {
const res = await fetch(batchDeleteUrl, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ids: ids}),
});
if (!res.ok) throw new Error("Delete failed");
ids.forEach(id => {
const row = body.querySelector(`tr[data-trip-id="${id}"]`);
if (row) row.remove();
});
updateDeleteSelectedBtn();
} catch(e) { showError(e.message); }
}
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener("click", deleteSelected);
}
function renderTrips(trips) {
body.replaceChildren();
@@ -248,7 +295,11 @@
<i class="fas fa-trash fa-fw" aria-hidden="true"></i>
</button>`
: "";
const checkboxCol = batchDeleteUrl
? `<td class="text-center" onclick="event.stopPropagation()"><input type="checkbox" class="trip-checkbox form-check-input" value="${trip.id}"></td>`
: "";
row.innerHTML = `
${checkboxCol}
<td>${formatDate(trip.start_datetime)}</td>
<td>${formatDistance(trip.distance_km)}</td>
<td>${formatDuration(trip.duration_seconds)}</td>
@@ -261,6 +312,12 @@
</td>`;
body.appendChild(row);
});
// Wire checkbox listeners
body.querySelectorAll("input.trip-checkbox").forEach(cb => {
cb.addEventListener("change", updateDeleteSelectedBtn);
});
updateDeleteSelectedBtn();
}
body.addEventListener("click", function (event) {