Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 26 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion public/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
22 changes: 22 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading