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:
@@ -559,6 +559,36 @@ def recompute_trips():
|
|||||||
return jsonify(status="ok", trips_computed=n)
|
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")
|
@app.get("/api/trips")
|
||||||
@require_login
|
@require_login
|
||||||
def portal_trips_compat():
|
def portal_trips_compat():
|
||||||
|
|||||||
@@ -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]}
|
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):
|
def get_overview_stats(device_id):
|
||||||
with get_cursor() as cur:
|
with get_cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|||||||
@@ -106,6 +106,44 @@
|
|||||||
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
|
<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/bs-init.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/theme.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 %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% macro data_table(id, columns, empty_title, empty_message) %}
|
{% macro data_table(id, columns, empty_title, empty_message, deletable=false) %}
|
||||||
<div class="data-list" id="{{ id }}" data-data-list>
|
<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>
|
<div class="table-responsive data-list-table" data-data-list-table hidden>
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -133,12 +133,16 @@ async function loadFiles() {
|
|||||||
<td class="text-truncate" style="max-width:200px;" title="${f.relative_path}">${name}</td>
|
<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">${fmtSize(f.size_bytes)}</td>
|
||||||
<td class="text-nowrap d-none d-sm-table-cell">${fmtDate(f.uploaded_at)}</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}"
|
<a href="/api/v1/portal/dashcam/stream/${f.id}" download="${name}"
|
||||||
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download"
|
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download"
|
||||||
onclick="event.stopPropagation()">
|
onclick="event.stopPropagation()">
|
||||||
<i class="fas fa-download fa-fw"></i>
|
<i class="fas fa-download fa-fw"></i>
|
||||||
</a>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -152,5 +156,28 @@ async function loadFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
{"key": "action", "label": "", "sortable": false, "class": "d-none d-sm-table-cell"}
|
{"key": "action", "label": "", "sortable": false, "class": "d-none d-sm-table-cell"}
|
||||||
],
|
],
|
||||||
"No trips detected",
|
"No trips detected",
|
||||||
"Trips appear once the Pi has uploaded tracking data."
|
"Trips appear once the Pi has uploaded tracking data.",
|
||||||
|
true
|
||||||
) }}
|
) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+29
-1
@@ -179,6 +179,9 @@
|
|||||||
initializeTimeline(result.points);
|
initializeTimeline(result.points);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tripList = document.getElementById("trip-list");
|
||||||
|
const deletable = tripList && tripList.dataset.deletable === "1";
|
||||||
|
|
||||||
function renderTrips(trips) {
|
function renderTrips(trips) {
|
||||||
body.replaceChildren();
|
body.replaceChildren();
|
||||||
trips.forEach(function (trip) {
|
trips.forEach(function (trip) {
|
||||||
@@ -189,6 +192,11 @@
|
|||||||
row.dataset.maxSpeed = trip.max_speed_kmh;
|
row.dataset.maxSpeed = trip.max_speed_kmh;
|
||||||
row.dataset.tripId = trip.id;
|
row.dataset.tripId = trip.id;
|
||||||
row.style.cursor = "pointer";
|
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 = `
|
row.innerHTML = `
|
||||||
<td>${formatDate(trip.start_datetime)}</td>
|
<td>${formatDate(trip.start_datetime)}</td>
|
||||||
<td>${formatDistance(trip.distance_km)}</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}">
|
<button class="btn btn-sm btn-primary" type="button" data-trip-id="${trip.id}">
|
||||||
Replay
|
Replay
|
||||||
</button>
|
</button>
|
||||||
|
${deleteBtn}
|
||||||
</td>`;
|
</td>`;
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
body.addEventListener("click", function (event) {
|
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 button = event.target.closest("[data-trip-id]");
|
||||||
const row = event.target.closest("tr[data-trip-id]");
|
const row = event.target.closest("tr[data-trip-id]");
|
||||||
const tripId = button ? button.dataset.tripId : (row ? row.dataset.tripId : null);
|
const tripId = button ? button.dataset.tripId : (row ? row.dataset.tripId : null);
|
||||||
|
|||||||
Reference in New Issue
Block a user