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")
|
@app.post("/api/v1/portal/recompute-trips")
|
||||||
@require_login
|
@require_login
|
||||||
def recompute_trips():
|
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()
|
device_id = _portal_device_id()
|
||||||
if device_id is None:
|
if device_id is None:
|
||||||
return jsonify(error="device not configured"), 404
|
return jsonify(error="device not configured"), 404
|
||||||
|
db.clear_deleted_trip_markers(device_id)
|
||||||
n = db.compute_trips_for_device(device_id)
|
n = db.compute_trips_for_device(device_id)
|
||||||
return jsonify(status="ok", trips_computed=n)
|
return jsonify(status="ok", trips_computed=n)
|
||||||
|
|
||||||
@@ -591,6 +592,20 @@ def portal_delete_trip(trip_id):
|
|||||||
return jsonify(status="ok")
|
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>")
|
@app.delete("/api/v1/portal/dashcam/<int:file_id>")
|
||||||
@require_login
|
@require_login
|
||||||
def portal_delete_dashcam(file_id):
|
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_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_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_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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
+44
-3
@@ -398,6 +398,15 @@ def compute_trips_for_device(device_id):
|
|||||||
"point_count": len(seg_rows),
|
"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:
|
with get_cursor(commit=True) as cur:
|
||||||
for t in trips:
|
for t in trips:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -540,13 +549,45 @@ def get_dashcam_file(device_id, file_id):
|
|||||||
|
|
||||||
|
|
||||||
def delete_trip(device_id, trip_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:
|
with get_cursor(commit=True) as cur:
|
||||||
|
# Record the start_time before deleting so recompute skips this trip.
|
||||||
cur.execute(
|
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),
|
(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):
|
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 ──────────────────────────────────────────────────── #}
|
{# ── Trip list ──────────────────────────────────────────────────── #}
|
||||||
<div class="col-12 col-xl-5">
|
<div class="col-12 col-xl-5">
|
||||||
<div class="card shadow-sm">
|
<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>
|
<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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div id="trip-loading" class="text-center text-muted py-4">
|
<div id="trip-loading" class="text-center text-muted py-4">
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
true
|
true
|
||||||
) }}
|
) }}
|
||||||
<script>document.getElementById('trip-list').dataset.deleteUrl = '/api/v1/portal/trip/';</script>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -232,6 +232,53 @@
|
|||||||
const tripList = document.getElementById("trip-list");
|
const tripList = document.getElementById("trip-list");
|
||||||
const deletable = tripList && tripList.dataset.deletable === "1";
|
const deletable = tripList && tripList.dataset.deletable === "1";
|
||||||
const deleteUrl = (tripList && tripList.dataset.deleteUrl) || "/api/trip/";
|
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) {
|
function renderTrips(trips) {
|
||||||
body.replaceChildren();
|
body.replaceChildren();
|
||||||
@@ -248,7 +295,11 @@
|
|||||||
<i class="fas fa-trash fa-fw" aria-hidden="true"></i>
|
<i class="fas fa-trash fa-fw" aria-hidden="true"></i>
|
||||||
</button>`
|
</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 = `
|
row.innerHTML = `
|
||||||
|
${checkboxCol}
|
||||||
<td>${formatDate(trip.start_datetime)}</td>
|
<td>${formatDate(trip.start_datetime)}</td>
|
||||||
<td>${formatDistance(trip.distance_km)}</td>
|
<td>${formatDistance(trip.distance_km)}</td>
|
||||||
<td>${formatDuration(trip.duration_seconds)}</td>
|
<td>${formatDuration(trip.duration_seconds)}</td>
|
||||||
@@ -261,6 +312,12 @@
|
|||||||
</td>`;
|
</td>`;
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire checkbox listeners
|
||||||
|
body.querySelectorAll("input.trip-checkbox").forEach(cb => {
|
||||||
|
cb.addEventListener("change", updateDeleteSelectedBtn);
|
||||||
|
});
|
||||||
|
updateDeleteSelectedBtn();
|
||||||
}
|
}
|
||||||
|
|
||||||
body.addEventListener("click", function (event) {
|
body.addEventListener("click", function (event) {
|
||||||
|
|||||||
Reference in New Issue
Block a user