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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user