From dac48d70b08407597616bd8aeaa1827150edfc9e Mon Sep 17 00:00:00 2001 From: pralav-25 <174412353+pralav-25@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:48:41 +0530 Subject: [PATCH] feat: export complete workspace audit history --- README.md | 8 ++++++++ main.py | 26 ++++++++++++++++++++++++++ public/static/app.js | 9 ++++++++- tests/test_api.py | 22 ++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd6694d..85aaa9c 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,11 @@ Built with FastAPI, SQLAlchemy, PostgreSQL/SQLite, Pillow, and browser JavaScrip Leaflet 1.9.4 is bundled with its BSD license in `public/static/vendor/`. Map tiles use OpenStreetMap with visible attribution. Fonts are loaded from Google Fonts; the interface falls back to system sans-serif if they are unavailable. + +### Export audit history + +The **Activity log → Export full history (CSV)** action downloads every event in +the signed-in workspace, newest first. The timeline still shows the latest 100. +The authenticated `GET /api/activity/export` route includes event type, message, +asset ID, demonstration health score, and timestamp. CSV quoting preserves +multiline notes, and formula-like message cells are escaped for spreadsheet use. diff --git a/main.py b/main.py index f4e7244..d7d9533 100644 --- a/main.py +++ b/main.py @@ -403,6 +403,32 @@ def events(account=Depends(workspace), db: Session = Depends(get_db)): ] +@app.get("/api/activity/export") +def export_activity(account=Depends(workspace), db: Session = Depends(get_db)): + """Export the complete audit history of the authenticated workspace.""" + stream = io.StringIO() + writer = csv.writer(stream) + writer.writerow(["ID", "Kind", "Message", "Asset ID", "Demo health score", "Created at"]) + rows = ( + db.query(dbm.Activity) + .filter_by(workspace_id=account.id) + .order_by(dbm.Activity.id.desc()) + .yield_per(500) + ) + for row in rows: + message = row.message + if message.lstrip().startswith(("=", "+", "-", "@")) or message.startswith(("\t", "\r", "\n")): + message = "'" + message + writer.writerow( + [row.id, row.kind, message, row.asset_id, row.health_score, iso(row.created_at)] + ) + return Response( + stream.getvalue(), + media_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="structiq-activity.csv"'}, + ) + + @app.get("/reports", include_in_schema=False) @app.get("/api/reports") def reports( diff --git a/public/static/app.js b/public/static/app.js index f83b84f..934d393 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -1186,7 +1186,14 @@ function activityView() { heading( "Activity log", "A persistent record of registrations, reports, maintenance, and scenarios.", - [button("Refresh", loadWorkspace, "button secondary", "clock")], + [ + button("Refresh", loadWorkspace, "button secondary", "clock"), + el("a", { + class: "button secondary", + href: "/api/activity/export", + download: "structiq-activity.csv", + }, "Export full history (CSV)"), + ], ), ); const section = panel("Workspace timeline", "The 100 most recent events"), diff --git a/tests/test_api.py b/tests/test_api.py index 908ed9e..d7e6dbd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -65,6 +65,28 @@ def health(self): a for a in self.client.get("/api/assets").json() if a["id"] == self.asset["id"] )["health_score"] + def test_activity_export_is_private_complete_and_csv_safe(self): + with database.SessionLocal() as db: + workspace_id = db.get(database.Asset, self.asset["id"]).workspace_id + for index in range(105): + db.add(database.Activity( + workspace_id=workspace_id, + kind="maintenance", + message='=HYPERLINK("example", "test")' if index == 0 else f"Event {index}", + asset_id=self.asset["id"], + )) + db.commit() + response = self.client.get("/api/activity/export") + self.assertEqual(response.status_code, 200) + self.assertIn("structiq-activity.csv", response.headers["content-disposition"]) + rows = list(csv.DictReader(io.StringIO(response.text))) + self.assertGreater(len(rows), 100) + self.assertEqual(rows[0]["Message"], "Event 104") + self.assertTrue(any(row["Message"].startswith("'=HYPERLINK") for row in rows)) + self.assertNotIn("Event 104", self.other.get("/api/activity/export").text) + with TestClient(app) as stranger: + self.assertEqual(stranger.get("/api/activity/export").status_code, 401) + def test_report_summaries_do_not_load_photo_blobs(self): from sqlalchemy import event, inspect