From 99035e86ee69fe4ae8db2ecc18592f32b16a7566 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 15 Aug 2026 16:55:30 -0400 Subject: [PATCH] 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) --- app.py | 13 +++++++++++++ modules/app_sql.py | 37 ++++++++++++++++++++++++++++++++++++- templates/history.html | 7 ++++++- tests/test_trip_sql.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index eb14a65..f667d7a 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/modules/app_sql.py b/modules/app_sql.py index bffd0ce..ad607c8 100644 --- a/modules/app_sql.py +++ b/modules/app_sql.py @@ -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() diff --git a/templates/history.html b/templates/history.html index f17c807..6be8a82 100644 --- a/templates/history.html +++ b/templates/history.html @@ -15,8 +15,11 @@ {# ── Trip list ──────────────────────────────────────────────────── #}
-
+
Recorded trips
+
@@ -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 ) }} +
diff --git a/tests/test_trip_sql.py b/tests/test_trip_sql.py index edf95f8..a9c05bf 100644 --- a/tests/test_trip_sql.py +++ b/tests/test_trip_sql.py @@ -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()