fix(phase26): server trip delete persists across recompute + batch delete with checkboxes
This commit is contained in:
+16
-1
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user