feat: checkbox batch trip delete on the Pi (local-only)

- Pi history page gets a select checkbox column + 'Delete selected' button
  (mirrors the server UI)
- POST /api/trips/delete batch endpoint
- app_sql.delete_trips_batch() + deleted_trip_markers table so a later trip
  rebuild can't resurrect locally-deleted trips
- Deletion is local-only: raw tracking rows are kept and the remote server
  is never notified (it keeps its archival copy recomputed from uploaded rows)
This commit is contained in:
Mathieu Moran
2026-08-15 16:55:30 -04:00
parent c9d36cca33
commit 99035e86ee
4 changed files with 85 additions and 2 deletions
+13
View File
@@ -232,6 +232,19 @@ def api_delete_trip(trip_id):
return jsonify(error=f"Trip not found: {trip_id}"), 404
return jsonify(status="deleted"), 200
@app.route('/api/trips/delete', methods=['POST'])
def api_delete_trips_batch():
"""Batch-delete computed trips on the Pi only (local archival cleanup).
The remote server is NOT told — it keeps its own archival copy of every
trip, recomputed from the uploaded tracking rows."""
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 = app_sql.delete_trips_batch(ids)
return jsonify(status="deleted", deleted=deleted), 200
import functools
import reverse_geocoder as rg
+36 -1
View File
@@ -116,6 +116,14 @@ SQL_CREATE_ALERT_STATE_TABLE = """CREATE TABLE IF NOT EXISTS alert_state (
LAST_VALUE text
);"""
# Local-only trip deletion markers. trip_id == the trip's START_TRACKING_ID, so
# a later rebuild can skip re-inserting a trip the user deleted. This is
# independent of the server's own deleted_trip_markers — Pi deletions are never
# pushed to the remote server (archival copy kept there).
SQL_CREATE_DELETED_TRIP_MARKERS_TABLE = """CREATE TABLE IF NOT EXISTS deleted_trip_markers (
trip_id integer PRIMARY KEY
);"""
def create_conn(db_file):
"""Open a SQLite connection to db_file, creating its parent folder if needed.
@@ -161,6 +169,7 @@ def get_conn():
create_table(_conn, SQL_CREATE_TRIPS_TABLE)
create_table(_conn, SQL_CREATE_TRIP_BUILD_STATE_TABLE)
create_table(_conn, SQL_CREATE_ALERT_STATE_TABLE)
create_table(_conn, SQL_CREATE_DELETED_TRIP_MARKERS_TABLE)
_migrate_tracking_table(_conn)
_migrate_trips_table(_conn)
_migrate_trip_build_state_table(_conn)
@@ -435,6 +444,12 @@ def refresh_trips(force=False):
trips = app_trip.build_trip_summaries(rows)
with _conn_lock:
try:
deleted_ids = {
row[0] for row in conn.execute(
"SELECT trip_id FROM deleted_trip_markers"
).fetchall()
}
trips = [t for t in trips if t["id"] not in deleted_ids]
conn.execute(
"DELETE FROM trips WHERE END_TRACKING_ID >= ?",
(rebuild_start_id,),
@@ -604,16 +619,36 @@ def get_trip_route(trip_id, max_points=2000):
def delete_trip(trip_id):
"""Delete a computed trip record. Raw tracking rows are NOT removed."""
"""Delete a computed trip record. Raw tracking rows are NOT removed, and the
deletion is local-only — the remote server keeps its own archival copy
(recomputed from the uploaded tracking rows) and is never told about this.
The trip id is remembered in deleted_trip_markers so a later trip rebuild
can't silently resurrect it."""
conn = get_conn()
if conn is None:
return False
with _conn_lock:
cur = conn.execute("DELETE FROM trips WHERE id = ?", (trip_id,))
if cur.rowcount > 0:
conn.execute(
"INSERT OR IGNORE INTO deleted_trip_markers (trip_id) VALUES (?)",
(trip_id,),
)
conn.commit()
return cur.rowcount > 0
def delete_trips_batch(trip_ids):
"""Delete multiple computed trips locally (raw tracking rows preserved,
remote server untouched). Returns the number deleted."""
deleted = 0
for trip_id in trip_ids:
if delete_trip(trip_id):
deleted += 1
return deleted
def get_setting(key, default=None):
"""Read a persisted UI/app setting (e.g. the dashcam-upload toggle)."""
conn = get_conn()
+6 -1
View File
@@ -15,8 +15,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">
@@ -27,6 +30,7 @@
{{ data_table(
"trip-list",
[
{"key": "", "label": "", "sortable": false, "class": "text-center"},
{"key": "started", "label": "Started", "sortable": true},
{"key": "distance", "label": "km", "sortable": true},
{"key": "duration", "label": "Time", "sortable": true},
@@ -39,6 +43,7 @@
deletable=true
) }}
<script>document.getElementById('trip-list').dataset.deleteUrl = '/api/trip/';</script>
<script>document.getElementById('trip-list').dataset.batchDeleteUrl = '/api/trips/delete';</script>
</div>
</div>
</div>
+30
View File
@@ -76,6 +76,36 @@ class TripSqlTest(unittest.TestCase):
self.assertEqual(rebuilt[0], original[0])
def test_deleted_trip_is_not_resurrected_by_rebuild(self):
started = datetime(2026, 1, 1, 8)
self.insert_motion(1, 160, started, 45.5)
self.insert_motion(161, 10, started + timedelta(minutes=30), 45.6)
app_sql.refresh_trips(force=True)
self.assertEqual(
self.conn.execute("SELECT COUNT(*) FROM trips").fetchone()[0], 2
)
trip_ids = [
row[0]
for row in self.conn.execute("SELECT id FROM trips ORDER BY id").fetchall()
]
self.assertEqual(app_sql.delete_trips_batch(trip_ids), 2)
self.assertEqual(
self.conn.execute("SELECT COUNT(*) FROM trips").fetchone()[0], 0
)
self.assertEqual(
self.conn.execute(
"SELECT COUNT(*) FROM deleted_trip_markers"
).fetchone()[0],
2,
)
# A full rebuild must not resurrect locally-deleted trips.
app_sql.refresh_trips(force=True)
self.assertEqual(
self.conn.execute("SELECT COUNT(*) FROM trips").fetchone()[0], 0
)
if __name__ == "__main__":
unittest.main()