Add delete with confirmation for trips and dashcam

- Confirmation modal in server base.html (window.confirmDelete promise helper)
- Trip delete: trash button in Replay column (server-only via data-deletable on
  trip-list div), DELETE /api/v1/portal/trip/<id>, removes computed trip only
  (raw tracking_points kept)
- Dashcam delete: trash button next to download, DELETE /api/v1/portal/dashcam/<id>,
  removes from DB + disk (best-effort file removal)
- history.js: delete button gated on data-deletable='1' attribute; click handler
  intercepts before replay logic
- db.py: delete_trip(), delete_dashcam_file() helpers

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 04:40:22 -04:00
co-authored by Copilot
parent a13d74e18e
commit 7aa9ffda16
7 changed files with 151 additions and 6 deletions
+30
View File
@@ -559,6 +559,36 @@ def recompute_trips():
return jsonify(status="ok", trips_computed=n)
@app.delete("/api/v1/portal/trip/<int:trip_id>")
@require_login
def portal_delete_trip(trip_id):
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
deleted = db.delete_trip(device_id, trip_id)
if not deleted:
return jsonify(error="trip not found"), 404
return jsonify(status="ok")
@app.delete("/api/v1/portal/dashcam/<int:file_id>")
@require_login
def portal_delete_dashcam(file_id):
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
storage_path = db.delete_dashcam_file(device_id, file_id)
if storage_path is None:
return jsonify(error="file not found"), 404
# Remove from disk (best-effort — don't fail if file is already gone)
try:
if os.path.isfile(storage_path):
os.remove(storage_path)
except OSError:
pass
return jsonify(status="ok")
@app.get("/api/trips")
@require_login
def portal_trips_compat():
+21
View File
@@ -348,6 +348,27 @@ def get_dashcam_file(device_id, file_id):
return {"id": r[0], "relative_path": r[1], "size_bytes": r[2], "uploaded_at": r[3].isoformat(), "storage_path": r[4]}
def delete_trip(device_id, trip_id):
"""Delete a computed trip record. Raw tracking_points are NOT removed."""
with get_cursor(commit=True) as cur:
cur.execute(
"DELETE FROM trips WHERE id = %s AND device_id = %s RETURNING id",
(trip_id, device_id),
)
return cur.fetchone() is not None
def delete_dashcam_file(device_id, file_id):
"""Delete dashcam file from DB and disk. Returns storage_path if deleted, None if not found."""
with get_cursor(commit=True) as cur:
cur.execute(
"DELETE FROM dashcam_files WHERE id = %s AND device_id = %s RETURNING storage_path",
(file_id, device_id),
)
row = cur.fetchone()
return row[0] if row else None
def get_overview_stats(device_id):
with get_cursor() as cur:
cur.execute(
+38
View File
@@ -106,6 +106,44 @@
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
{# ── Shared confirmation modal ─────────────────────────────────────────── #}
<div class="modal fade" id="confirm-modal" tabindex="-1" aria-labelledby="confirm-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirm-modal-label">Confirm delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="confirm-modal-body">Are you sure?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-modal-ok">Delete</button>
</div>
</div>
</div>
</div>
<script>
// Global helper: show confirmation modal, resolve true/false on user choice.
window.confirmDelete = function(message) {
return new Promise(function(resolve) {
const modal = document.getElementById('confirm-modal');
document.getElementById('confirm-modal-body').textContent = message;
const bsModal = bootstrap.Modal.getOrCreate(modal);
const okBtn = document.getElementById('confirm-modal-ok');
function onOk() { cleanup(); resolve(true); }
function onHide() { cleanup(); resolve(false); }
function cleanup() {
okBtn.removeEventListener('click', onOk);
modal.removeEventListener('hidden.bs.modal', onHide);
bsModal.hide();
}
okBtn.addEventListener('click', onOk);
modal.addEventListener('hidden.bs.modal', onHide, { once: true });
bsModal.show();
});
};
</script>
{% block scripts %}{% endblock %}
</body>
+2 -2
View File
@@ -1,5 +1,5 @@
{% macro data_table(id, columns, empty_title, empty_message) %}
<div class="data-list" id="{{ id }}" data-data-list>
{% macro data_table(id, columns, empty_title, empty_message, deletable=false) %}
<div class="data-list" id="{{ id }}" data-data-list{% if deletable %} data-deletable="1"{% endif %}>
<div class="table-responsive data-list-table" data-data-list-table hidden>
<table class="table table-hover align-middle mb-0">
<thead>
+28 -1
View File
@@ -133,12 +133,16 @@ async function loadFiles() {
<td class="text-truncate" style="max-width:200px;" title="${f.relative_path}">${name}</td>
<td class="text-nowrap">${fmtSize(f.size_bytes)}</td>
<td class="text-nowrap d-none d-sm-table-cell">${fmtDate(f.uploaded_at)}</td>
<td class="text-end">
<td class="text-end text-nowrap">
<a href="/api/v1/portal/dashcam/stream/${f.id}" download="${name}"
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download"
onclick="event.stopPropagation()">
<i class="fas fa-download fa-fw"></i>
</a>
<button class="btn btn-outline-danger btn-sm py-0 px-1 ms-1" title="Delete"
onclick="event.stopPropagation(); deleteFile(${f.id}, '${name.replace(/'/g,"\\'")}')">
<i class="fas fa-trash fa-fw"></i>
</button>
</td>
</tr>
`;
@@ -152,5 +156,28 @@ async function loadFiles() {
}
loadFiles();
async function deleteFile(id, name) {
const confirmed = await window.confirmDelete(`Delete "${name}"? This removes the file from disk permanently.`);
if (!confirmed) return;
const res = await fetch(`/api/v1/portal/dashcam/${id}`, { method: 'DELETE' });
if (!res.ok) { alert('Delete failed.'); return; }
// Clear player if this file was playing
const player = document.getElementById('video-player');
if (currentFileId === id) {
player.pause();
player.src = '';
player.hidden = true;
document.getElementById('player-placeholder').hidden = false;
document.getElementById('player-meta').hidden = true;
document.getElementById('btn-download').hidden = true;
currentFileId = null;
}
// Remove row
const row = document.querySelector(`#files-body tr[data-file-id="${id}"]`);
if (row) row.remove();
const countEl = document.getElementById('file-count');
countEl.textContent = parseInt(countEl.textContent) - 1;
}
</script>
{% endblock %}
+2 -1
View File
@@ -34,7 +34,8 @@
{"key": "action", "label": "", "sortable": false, "class": "d-none d-sm-table-cell"}
],
"No trips detected",
"Trips appear once the Pi has uploaded tracking data."
"Trips appear once the Pi has uploaded tracking data.",
true
) }}
</div>
</div>
+29 -1
View File
@@ -179,6 +179,9 @@
initializeTimeline(result.points);
}
const tripList = document.getElementById("trip-list");
const deletable = tripList && tripList.dataset.deletable === "1";
function renderTrips(trips) {
body.replaceChildren();
trips.forEach(function (trip) {
@@ -189,6 +192,11 @@
row.dataset.maxSpeed = trip.max_speed_kmh;
row.dataset.tripId = trip.id;
row.style.cursor = "pointer";
const deleteBtn = deletable
? `<button class="btn btn-sm btn-outline-danger ms-1" type="button" data-delete-trip-id="${trip.id}" title="Delete trip">
<i class="fas fa-trash fa-fw" aria-hidden="true"></i>
</button>`
: "";
row.innerHTML = `
<td>${formatDate(trip.start_datetime)}</td>
<td>${formatDistance(trip.distance_km)}</td>
@@ -199,13 +207,33 @@
<button class="btn btn-sm btn-primary" type="button" data-trip-id="${trip.id}">
Replay
</button>
${deleteBtn}
</td>`;
body.appendChild(row);
});
}
body.addEventListener("click", function (event) {
// Button click (desktop) or row click (mobile)
// Delete button
const deleteBtn = event.target.closest("[data-delete-trip-id]");
if (deleteBtn) {
event.stopPropagation();
const tripId = deleteBtn.dataset.deleteTripId;
const date = deleteBtn.closest("tr").querySelector("td").textContent;
window.confirmDelete(`Delete trip from ${date}? The raw GPS data is kept — only the computed trip record is removed.`)
.then(function(confirmed) {
if (!confirmed) return;
return fetch(`/api/v1/portal/trip/${tripId}`, { method: "DELETE" })
.then(function(res) {
if (!res.ok) throw new Error("Delete failed");
deleteBtn.closest("tr").remove();
})
.catch(function(e) { showError(e.message); });
});
return;
}
// Replay button click (desktop) or row click (mobile)
const button = event.target.closest("[data-trip-id]");
const row = event.target.closest("tr[data-trip-id]");
const tripId = button ? button.dataset.tripId : (row ? row.dataset.tripId : null);